대부호 CPU 플레이어 만들기 Part 1: 달무티 봇을 그대로 못 쓰는 이유
이 글은 Claude Fable 5.1 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
네 번째 봇#
이 블로그에서 CPU 플레이어 만들기를 다룬 것이 세 번입니다. 달무티, 렉시오, 훌라 였습니다. 이번에는 일본의 국민 카드게임 대부호(大富豪) 입니다. 달무티를 아는 분을 위한 규칙 입문을 먼저 썼고, 이 시리즈는 그 게임에 easy 봇과 hard 봇 을 넣은 이야기입니다.
규칙 입문에서 저는 이렇게 썼습니다.
이 블로그의 달무티 봇 실험은 규칙 세 줄짜리 봇이 꽤 강하다는 것을 보여 줬는데, 대부호는 무늬·계단·8·도락치가 더해져 판단할 것이 훨씬 많습니다.
이 글은 그 문장을 코드로 확인한 기록입니다. 결론부터 말하면, 달무티 봇에서 그대로 가져올 수 있었던 것은 원칙과 검증 순서 뿐이었고, 판단 로직은 전부 새로 썼습니다. 그리고 그렇게 만든 hard 봇은 사전에 정해 둔 시험을 통과해 혼자하기의 기본 봇이 됐습니다.
시리즈는 일곱 편입니다. 이 편은 봇을 짜기 전의 이야기입니다. 대부호가 봇에게 왜 어려운지, 앞선 세 시리즈에서 무엇을 배웠는지, 그리고 봇이 지켜야 할 계약이 무엇인지입니다.
1. 앞선 세 시리즈에서 가져온 것#
봇을 네 번째 만들면 더 쉬워질 것 같지만, 쉬워지는 것은 무엇을 하면 안 되는지 를 안다는 점뿐입니다. 세 시리즈의 실패에서 가져온 원칙은 다섯 가지였습니다.
| 원칙 | 어디서 배웠나 |
|---|---|
| easy 가 가드레일이다.1 hard 는 easy 의 수를 기본으로 두고, 증거가 문턱을 넘을 때만 바꾼다 | 달무티 — 리드2를 점수에 통째로 맡긴 시도는 전부 졌습니다. 약한 큰 뭉치를 계속 뒤로 미루다 끝내 못 털었습니다 |
| 시뮬레이터가 먼저다. hard 를 만들기 전에 봇끼리 재는 장치와, 그 장치가 맞다는 증거부터 둔다 | 렉시오 — hard 가 easy 에게 진 적이 있었습니다. 훌라 — 시뮬레이터가 빈 장부를 넘겨 다른 봇을 재고 있었습니다 |
| 운영과 시뮬레이터는 한 경로다. 정책 함수만 같아서는 같은 게임이 아니다 | 훌라 — 시야를 만드는 코드가 달라서 생긴 사고였습니다 |
| 잴 수 있는 것만 넣는다. 행동을 바꾸지 않는 항은 0 으로 남기지 않고 지운다 | 훌라 — 계수를 20배로 키워도 수가 안 바뀌는 항이 있었습니다 |
| 시험 문제는 미리 정하고, 본 뒤에는 다시 쓰지 않는다 | 세 시리즈 공통. 달무티 v1 은 easy 대비 −0.07 로 기본 봇이 됐다가 숙련자에게 한 달 만에 읽혔습니다 |
마지막 줄이 이번 시리즈에 가장 큰 영향을 줬습니다. 달무티 hard v1 은 easy 보다 겨우 조금 나은 상태로 기본 봇이 됐고, Part 6 에서 봤듯 사람이 열 판 중 여섯 판을 이겼습니다. 그래서 이번에는 약한 hard 를 기본값으로 올리지 않는다 는 조건을 처음부터 숫자로 못 박았습니다. 그 숫자는 Part 7 에서 나옵니다.
2. 대부호가 봇에게 어려운 열 가지#
달무티 봇의 판단 로직을 대부호에 옮길 수 없었던 이유를 규칙 하나씩 적습니다. 규칙 이름은 규칙 입문의 것과, 이 블로그가 만든 게임 안에서 쓰는 이름을 함께 적었습니다. 이 시리즈에서는 게임 안의 이름을 씁니다.
| 규칙 (입문 글의 이름 → 게임 안의 이름) | 봇에게 뜻하는 것 |
|---|---|
| 패스하면 그 필드가 끝날 때까지 못 낸다 | 달무티는 패스해도 차례가 돌아오면 다시 낼 수 있습니다. 대부호의 패스는 이 필드를 포기하는 것 입니다. 모두 패스하면 리드는 패스한 사람이 아니라 마지막에 낸 사람에게 갑니다. 달무티 봇의 “패스하고 나중에 받자” 는 계산을 통째로 버려야 합니다 |
| 8 자르기 → 8컷 | 8 이 든 수를 내면 그 자리에서 필드가 정리되고 내가 다시 리드합니다. 받을 때의 8 은 “리드를 사는 카드”, 리드할 때의 8 은 “공짜로 한 번 털기” 입니다 |
| 혁명 (같은 숫자 4장 이상) | 서열이 통째로 뒤집힙니다. 내 손패의 값이 두 서열에서 다르고, 무엇을 먼저 내느냐에 따라 다음 묶음의 서열과 마지막 수의 합법 여부가 바뀝니다 |
| 무늬 묶기 → 깔맞춤 | 같은 무늬가 두 번 이어지면 그 무늬만 받을 수 있습니다. 받기 후보가 무늬로 한 번 더 줄어듭니다 |
| 스페이드 3 되받기 → ♠3극상 | 단독 조커는 ♠3 한 장으로만 받습니다. ♠3 의 값이 조커가 남았는지에 달렸고, 조커 한 장 리드의 안전도 ♠3 이 남았는지에 달렸습니다 |
| 계단 (같은 무늬 연속 3장 이상) | 같은 카드가 같은 숫자 묶음으로도, 계단으로도 읽힙니다. 손패를 묶음으로 나누는 경우의 수가 달무티보다 훨씬 큽니다 |
| 반칙 올라가기 → 반칙패 | 조커·2(혁명 중에는 3)·8 만으로 된 수·♠3 한 장으로 끝내면 그 판 꼴찌입니다. 반칙은 카드에 붙은 꼬리표가 아니라 “그 수와 그때의 혁명 상태” 로 정해집니다. 강한 카드만 남으면 끝낼 수 없습니다 |
| 도락치 → 나락 | 지난 판 대부호가 남아 있는데 다른 사람이 먼저 정상으로 끝내면 대부호는 즉시 탈락입니다. 대부호인 봇은 “1등 아니면 벌칙” 이라 목표가 다릅니다 |
| 블라인드 (4인 2장, 5인 4장) | 안 나온 카드가 전부 누군가의 손에 있는 것이 아닙니다 |
| 조공 | 아래 계급이 가장 강한 카드를 주므로, 그 사람의 남은 손패에는 상한 이 생깁니다. 위 계급은 받을 카드를 보지 않고 돌려줍니다 |
달무티 봇이 가장 많이 썼던 두 가지 계산이 여기서 무너집니다. 하나는 “패스한 자리가 다시 받을 위험” 으로, 대부호에서는 패스한 자리의 위험이 정확히 0 입니다. 다른 하나는 “이 카드는 끝내기용” 이라는 꼬리표로, 대부호에서는 혁명 한 번에 2 가 끝내기 카드가 되고 3 이 반칙 카드가 됩니다.
사람에게는 이것이 대부호의 재미입니다. 봇에게는 판단할 축이 둘에서 다섯으로 늘어난 것 입니다.
3. 계약 — 봇이 보는 것과 돌려주는 것#
봇을 짜기 전에 세 시리즈에서 쓴 계약을 그대로 가져옵니다. 봇은 그 자리의 사람이 볼 수 있는 것만 보고, 규칙을 다시 판단하지 않으며, 한 번에 한 수만 돌려줍니다.
3.1 봇이 보는 것#
// View 는 봇 한 자리가 볼 수 있는 것입니다. 남의 손패를 담을 자리가 없습니다.
type View struct {
SeatNo int
Hand []model.Card // 내 손패
Field *model.Field // 지금 필드(깔맞춤 포함). nil 이면 리드 차례
Revolution bool
Exchange *Exchange // 조공 돌려주기 차례면 돌려줄 장수
// 아래는 사람 화면에 이미 보이는 공개 상태입니다. hard 봇이 장부를 만드는 재료입니다.
Seats []SeatState // 자리마다 남은 장수 · 이번 필드 패스 여부 · 완료 · 계급
FieldSeatNo int // 지금 필드를 낸 자리
PreviousDaifugo int // 지난 판 대부호(나락 대상)
History []PublicEvent // 이번 판에 나온 카드 전부 (지난 판은 없음)
Gave, Received []ExchangeCards // 내가 조공으로 주고받은 카드
}
여기서 두 줄에 주석을 더 답니다.
History 는 이번 판 것만입니다. 처음 구현은 매치 전체 이력을 넘길 뻔했습니다. 그러면 지난 판에 나온 카드를 이번 판에서 또 빼게 되고, 세트3마다 자리가 바뀌니 엉뚱한 사람의 카드로 셉니다. 사람이 기억하는 것도 이번 판에 나온 카드까지이므로 봇도 거기까지만 봅니다.
Received 는 돌려주기를 마친 뒤부터 채워집니다. 대부호의 조공은 “받을 카드를 보지 않고 돌려준다” 는 규칙이 있습니다. 사람 화면은 돌려주기 전까지 받은 카드를 가립니다. 봇도 같은 순간부터 봅니다. 이 한 줄을 어기면 봇은 사람이 할 수 없는 계산 으로 돌려줄 카드를 고르게 됩니다.
3.2 규칙은 한 벌#
봇은 합법성을 다시 판단하지 않습니다. 엔진이 LegalPlays(손패, 필드, 혁명) 로 낼 수 있는 수 목록을 만들고, 봇은 그 목록에서 고릅니다. 반칙 종료 판정도 엔진의 IsForbiddenFinish(수, 그때의 혁명 상태) 하나입니다. 봇이 “2 는 끝내기용이 아니다” 같은 규칙을 따로 들고 있으면, 혁명이 일어난 판에서 봇과 서버가 서로 다른 게임을 하게 됩니다.
그리고 봇이 낸 수는 서버가 한 번 더 봅니다.
// ValidateDecision 은 정책이 낸 수가 이 시야에서 합법인지 봅니다.
// 내 카드로 성립하는 묶음인지, 받기면 필드를 이기는지, 조공이면 장수가 맞는지.
func ValidateDecision(view View, decision Decision) error
3.3 한 수, 그리고 물러설 자리#
정책의 모양은 세 시리즈와 같습니다. 판 하나를 받아 수 하나를 돌려줍니다.
type Policy func(ctx context.Context, view View) (Decision, error)
// 한 수에 쓸 수 있는 시간입니다. 넘기면 답을 냈더라도 버립니다.
const DecisionBudget = 100 * time.Millisecond
그리고 물러설 자리를 둡니다. 모르는 정책 이름, 정책의 오류, 시간 초과, 합법이 아닌 수 — 넷 중 무엇이 일어나도 판은 멈추지 않고 easy 가 대신 둡니다.
func DecideWithin(ctx context.Context, key string, view View, budget time.Duration) (Decision, string, error) {
policy, ok := policies[key]
if !ok {
decision, err := Easy(ctx, view)
return decision, FallbackUnknownPolicy, err // 운영 사건
}
budgeted, cancel := context.WithTimeout(ctx, budget)
decision, err := policy(budgeted, view)
expired := budgeted.Err() != nil
cancel()
var fallback string
switch {
case err != nil && expired:
fallback = FallbackTimeout // 시계의 사건
case err != nil:
fallback = FallbackHardError // 봇의 잘못
case ValidateDecision(view, decision) != nil:
fallback = FallbackInvalidMove // 봇의 잘못
default:
return decision, "", nil
}
decision, err = Easy(ctx, view)
return decision, fallback, err
}
네 가지 사유를 따로 세는 이유는 훌라 시리즈에서 적었던 그대로입니다. 한 통에 넣으면 서버가 바빴던 날의 기록이 봇의 실수로 남습니다. 이번 시리즈에서는 여기에 하나를 더했습니다. 이 사유가 봇이 둔 수의 기록에 남습니다. 어느 판에서 hard 가 두었고 어느 판에서 easy 가 대신 두었는지를 나중에 가를 수 있어야, 사람 상대 성적을 셀 때 “hard 의 성적” 과 “운영 봇 전체의 성적” 을 구분할 수 있습니다.
이 fallback4 때문에 easy 봇은 시리즈가 끝날 때까지 지워지지 않습니다. 최후의 보루가 되려면 easy 는 어떤 상황에서도 합법인 수를 내야 합니다. 그래서 easy 는 시간 초과 때 서버가 대신 두는 자동 수와 같은 함수 입니다. 둘을 따로 두면 한쪽만 고치는 날이 옵니다. Part 2 에서 그 함수를 봅니다.
4. 일의 순서#
봇 하나를 만드는 데 PR 열다섯 개가 들어갔습니다. 순서는 훌라 시리즈에서 정착한 그대로입니다. 시뮬레이터가 hard 보다 먼저 이고, hard 는 처음에 기본값이 아닌 채로 등록되며, 미리 정한 시험을 통과해야 기본값이 됩니다.
flowchart LR
A["easy 봇<br/>혼자하기"] --> B["전략 문서<br/>코드 없음"]
B --> C["시뮬레이터<br/>easy 대 easy = 0"]
C --> D["시야 확장<br/>장부 · 위험 점수"]
D --> E["손패 계획<br/>혁명 전이 · 확정 종료 줄"]
E --> F["hard 1차<br/>기본값은 아직 easy"]
F --> G["켜기 · 끄기 실험<br/>튜닝 · 동결"]
G --> H["held-out 시험<br/>통과 → 기본 봇"]
style A fill:#87CEEB,color:#000000
style B fill:#D3D3D3,color:#000000
style C fill:#FFD700,color:#000000
style D fill:#90EE90,color:#000000
style E fill:#90EE90,color:#000000
style F fill:#90EE90,color:#000000
style G fill:#FFD700,color:#000000
style H fill:#FFD700,color:#000000
파란 칸이 Part 2, 초록 칸이 Part 3~5, 노란 칸이 Part 6~7 입니다.
- Part 2 — 규칙 네 줄짜리 easy 봇. 시간 초과 자동 수와 같은 함수이고, 반칙으로 끝나는 일이 판의 10% 쯤 됩니다.
- Part 3 — hard 가 보는 것. 이번 판에 나온 카드로 54장을 네 칸에 나누는 장부와, “이 수를 내면 누가 받을 수 있는가” 를 재는 위험 점수.
- Part 4 — 손패 계획. 혁명이 일어나면 서열이 바뀐다는 것을 따라가며 “남은 손패를 몇 수에, 합법으로 끝낼 수 있는가” 를 구합니다.
- Part 5 — hard 의 결정. easy 와 hard 가 같은 자리에서 무엇을 다르게 보고 다르게 고르는지를 그림 하나로 비교합니다. 이 시리즈의 가운데입니다.
- Part 6 — 재고 거르기. 시뮬레이터, 항목을 하나씩 껐다 켜 본 실험, 가중치 튜닝.
- Part 7 — 처음 보는 판에서. 미리 정한 시험, 결과, 그리고 기본 봇이 된 뒤 남은 것.
코드 없이 결론만 보고 싶으시다면 Part 5 의 그림과 Part 7 의 결과 표만 보셔도 됩니다. 두 봇이 각각 무엇을 보고 어떻게 고르는지, 그리고 실제로 얼마나 차이가 났는지가 거기 있습니다.
시리즈 목록#
- 대부호 CPU 플레이어 만들기 Part 1: 달무티 봇을 그대로 못 쓰는 이유 (이 글)
- 대부호 CPU 플레이어 만들기 Part 2: 규칙 네 줄짜리 easy 봇
- 대부호 CPU 플레이어 만들기 Part 3: 본 것을 잊지 않는 장부
- 대부호 CPU 플레이어 만들기 Part 4: 혁명을 따라가는 손패 계획
- 대부호 CPU 플레이어 만들기 Part 5: easy 와 hard 는 어디서 갈라지는가
- 대부호 CPU 플레이어 만들기 Part 6: 항목을 하나씩 꺼 보니
- 대부호 CPU 플레이어 만들기 Part 7: 처음 보는 판에서 재다
가드레일: 도로의 난간처럼, 봇이 점수 계산 때문에 엉뚱한 쪽으로 벗어나지 못하게 막는 기본값입니다. 이 시리즈에서는 «일단 easy 의 수를 두고, 뚜렷이 더 나은 근거가 있을 때만 바꾼다» 는 규칙을 뜻합니다. ↩︎
리드 / 받기 / 필드: 필드는 테이블에 놓여 있는 지금의 카드(들)입니다. 필드가 비어 있을 때 첫 수를 내는 것이 «리드», 놓인 필드보다 센 수로 이어 내는 것이 «받기» 입니다. 모두가 패스하면 필드가 정리되고 마지막에 낸 사람이 다시 리드합니다. ↩︎
세트: 4판을 한 묶음으로 보는 단위입니다. 세트 첫 판은 자리를 다시 섞고 계급·조공 없이 시작합니다. ↩︎
fallback(물러서기): 원래 하려던 방법이 실패했을 때 대신 쓰는 예비 방법입니다. 여기서는 hard 봇이 답을 내지 못하면 easy 봇이 대신 두는 것을 가리킵니다. ↩︎