달무티 CPU 플레이어 만들기 Part 1: 규칙 기반 easy 봇
이 글은 Claude Opus 5 를 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
왜 봇부터 만드는가#
지난 글에서 대부호·달무티·렉시오를 묶어 클라이밍 게임의 전략을 정리했습니다. 이번에는 그중 달무티(The Great Dalmuti) 를 디지털 게임으로 구현한다고 가정하고, CPU 플레이어를 어떤 전략으로 움직일 것인가 를 파고들겠습니다.
공식 룰북은 달무티를 5~8명에서 권장합니다(4명이나 9명 이상도 가능합니다). 그런데 온라인 게임에서 매번 예닐곱 명을 모으는 것은 현실적이지 않습니다. 봇은 단순한 편의 기능이 아니라 게임이 성립하기 위한 전제 조건 에 가깝습니다. 게다가 봇이 있어야 룰 엔진을 자동화 테스트로 수천 판 돌려볼 수 있습니다.
이 글은 세 편으로 나누어 씁니다.
- Part 1 (이 글): 공정성 경계와 정책 인터페이스를 설계하고, 결정적 규칙만으로 동작하는 easy 난이도 봇 을 구현합니다.
- Part 2: hard 봇이 무엇을 아는가 를 다룹니다. 카드 카운팅, 패스의 확률적 해석, 확실승수 판정, 패 분해 플랜입니다.
- Part 3: hard 봇이 무엇을 결정하는가 를 다룹니다. 수 평가 함수, 전략적 패스, 세금과 혁명의 기대값, 그리고 검증 방법입니다.
이 글의 전제#
세 편 모두 특정 제품을 설명하는 글이 아니라, 달무티를 디지털 게임으로 만든다면 봇을 어떻게 설계할 것인가에 대한 설계안 입니다. 코드는 Go 로 쓰되 논지를 보이기 위한 발췌 이며, 그대로 컴파일되는 완성 패키지가 아닙니다.
달무티는 변형 룰이 많은 게임이라, 봇 이야기를 시작하기 전에 어떤 룰셋을 전제하는지부터 못 박아 두겠습니다. 아래 표의 선택이 달라지면 뒤에 나올 알고리즘의 상당 부분이 바뀝니다.
| 항목 | 이 글의 전제 | 근거 |
|---|---|---|
| 인원 | 5~8명 (4명도 가능) | 공식 룰북 기본 규칙 |
| 덱 | 80장 풀덱 (stripped deck 미사용) | 공식 룰북 기본 규칙 |
| 패스 후 재참여 | 허용. 패스해도 자기 차례가 다시 오면 낼 수 있음 | 공식 룰북 The Play |
| 트릭 종료 | 전원이 연속으로 패스하면 종료. 마지막에 낸 사람이 다음 리드 | 공식 룰북 The Play |
| 첫 판 세금 | 없음. 첫 판은 자동 혁명으로 처리 | 공식 선택 규칙 First Deal Revolution |
| 첫 판 대혁명 | 대농노가 광대 2장이면 선언 가능 | 같은 선택 규칙 |
| 광대만으로 묶음 | 불가. 최소 1장의 자연 카드가 필요 | 룰북 미명시. 이 글에서 확정 |
| 점수 | 나갈 때마다 아직 남아 있는 인원수만큼 획득, 20판 | 공식 선택 규칙 Scoring |
특히 패스 후 재참여 항목을 눈여겨봐 주시기 바랍니다. 이 계열 게임을 오래 해 온 사람도 자주 틀리는 부분입니다.
“A player who chooses to pass may still play later in the hand, when it’s his or her turn again. Play continues clockwise until everyone passes in a row.” — 공식 룰북 The Play 절
“한 번 패스하면 그 트릭에서는 끝"이라고 알고 계신 분이 많은데, 달무티 공식 룰은 그렇지 않습니다. 그리고 이건 달무티만의 특이 사항도 아닙니다. 이 글에서 확인한 대표 규칙 자료들은 모두 재참여를 허용합니다.
| 게임 | 대표 규칙 자료의 패스 처리 | 자료 |
|---|---|---|
| 달무티 | 재참여 허용 | Wizards of the Coast 공식 룰북 The Play |
| 렉시오 | 재참여 허용 — “Passing is not permanent in the hand.” | marquand.net 룰 정리 |
| 빅투 | 재참여 허용 — “Passing does not prevent you from playing when your turn comes round again.” | Pagat |
| 대부호 | 재참여 허용. “패스하면 못 낸다"는 드물게 쓰이는 변형 룰 로 별도 분류 | Pagat |
자료의 성격은 구분해 둘 필요가 있습니다. 달무티만 발행사(Wizards of the Coast)가 낸 진짜 공식 룰북 이 있습니다. 빅투와 대부호는 발행사가 따로 없는 전통 게임이라 Pagat 의 정리가 사실상의 표준 참조일 뿐 “공식"은 아니고, 렉시오의 marquand.net 페이지도 발행사 문서가 아닙니다. 그래서 위 표는 “공식 룰"이 아니라 “대표 규칙 자료가 어떻게 적고 있는가” 로 읽어 주시기 바랍니다.
즉 “패스 = 트릭 탈락"이 오히려 소수 변형 룰 입니다. 그런데 온라인 구현체와 모임 룰에서는 이 변형이 워낙 흔해서, 그쪽 감각을 그대로 들고 오기 쉽습니다. 봇 설계자에게는 이게 치명적입니다. 리드권을 언제 가져오는지가 통째로 달라지기 때문입니다.
공식 룰에서 리드권은 “내 뒤 순번을 다 넘기면” 돌아오는 것이 아니라 “살아 있는 전원이 연속으로 패스해야” 돌아옵니다. Part 3 의 리드권 계산은 전부 이 조건 위에 세워집니다. 반대로 여러분의 프로젝트가 “패스=탈락” 변형을 채택한다면, Part 3 의 리드권 계산은 그대로 쓸 수 없습니다.
용어도 하나 정리하겠습니다. 공식 룰북은 한 번의 리드가 시작되어 전원 연속 패스로 끝날 때까지를 hand 라고 부르는데, 이 글에서는 혼동을 피해 그것을 “트릭”, 카드를 새로 딜해서 계급이 갱신될 때까지를 “판” 이라고 부르겠습니다.
봇 설계에 필요한 만큼의 달무티 규칙#
봇 로직에 직접 영향을 주는 규칙만 추려 두겠습니다.
- 덱: 숫자 1~12 카드가 있고, 숫자가 곧 장수 입니다. 1은 1장, 12는 12장으로 총 78장. 여기에 광대(Jester) 2장 을 더해 80장입니다.
- 강약: 숫자가 작을수록 강합니다. 1이 최강, 12가 최약입니다.
- 광대: 단독으로 낼 때는 서열 13, 즉 가장 약한 카드 입니다. 다른 숫자와 함께 낼 때는 그 숫자의 와일드카드 가 됩니다.
- 제출: 선공은 같은 숫자를 원하는 장수만큼 냅니다. 후공은 정확히 같은 장수 를, 더 낮은 숫자 로 받아야 합니다. 못 내거나 안 내면 패스입니다.
- 패스와 트릭 종료: 패스해도 자기 차례가 다시 오면 낼 수 있습니다. 살아 있는 전원이 연속으로 패스해야 트릭이 끝나고, 마지막에 낸 사람이 다음 트릭을 리드합니다.
- 계급: 등수에 따라 대달무티 → 소달무티 → 상인 → 소농노 → 대농노 순으로 앉습니다.
- 세금(taxation): 판 시작 전, 대농노는 가장 강한 카드 2장을 대달무티에게 바치고 대달무티는 아무 카드 2장을 내려줍니다. 소농노와 소달무티는 1장씩 주고받습니다. (앞의 전제표대로 첫 판에는 세금이 없습니다.)
- 혁명(Revolution): 세금 징수 직전, 광대 2장을 모두 쥔 플레이어는 세금 징수를 취소 시킬 수 있습니다. 그 광대 2장을 대농노 가 쥐었다면 대혁명(Greater Revolution) 이 되어 좌석 순서가 통째로 뒤집힙니다. 판정이 세금보다 앞에 온다는 순서가 뒤에서 중요해집니다.
여기서 봇 설계에 가장 큰 영향을 주는 것은 “숫자가 곧 장수” 라는 덱 구조입니다. 이 한 줄이 뒤에 나올 모든 판단의 뿌리가 됩니다.
설계의 뼈대#
1. 공정성 경계를 타입으로 못 박는다#
봇 AI 를 만들 때 가장 먼저 정해야 할 것은 알고리즘이 아니라 “봇이 무엇을 볼 수 있는가” 입니다. 서버 게임 상태 객체를 그대로 봇 함수에 넘기면, 나중에 누군가가 무심코 state.Players[i].Hand 를 읽는 순간 봇은 남의 손패를 보는 치터가 됩니다. 그리고 이런 유출은 테스트로 잡히지 않습니다. 봇이 훨씬 잘 두게 될 뿐이니까요.
그래서 봇에게 허용된 정보만 담은 별도의 뷰 타입을 만들고, 봇 정책은 오직 그 타입만 인자로 받게 합니다.
// GameView 는 봇에게 노출되는 정보의 전부다.
// 다른 좌석의 손패는 이 구조체 어디에도 들어 있지 않다.
type GameView struct {
Me SeatID
Hand []Card // 내 손패
Table *Play // 현재 받아야 할 수. nil 이면 내가 선공이다
Seats []SeatInfo // 좌석별 계급과 남은 장수
Log []Action // 이번 판의 공개 행동 로그. 제출과 패스를 모두 담는다
Round int // 몇 번째 판인가 (1부터)
ConsecutivePasses int // 지금까지 연속으로 몇 명이 패스했는가
Score map[SeatID]int // 누적 점수
HandsLeft int // 남은 판 수
Rules Ruleset // 앞의 전제표에 해당하는 설정
}
// Action 은 공개된 행동 하나다. 패스도 반드시 로그에 남는다.
type Action struct {
Seat SeatID
TrickID int
Pass bool
Play *Play // Pass 가 false 일 때만 유효하다
Table *Play // 그 행동을 할 때 받아야 했던 수. nil 이면 리드였다
}
type SeatInfo struct {
Seat SeatID
Title Title
HandCount int // 남은 손패의 "장수" 만 안다. 내용은 모른다
Finished bool
}
Log 를 []Play 가 아니라 []Action 으로 둔 것이 나중에 결정적입니다. 제출만 기록하면 카드 카운팅은 되지만 “누가 어떤 트릭에서 참았는가” 를 복원할 수 없습니다. Part 2 의 hard 봇은 이 패스 기록을 상대 손패 추정의 재료로 쓰므로, 로그 스키마 단계에서부터 패스를 일급 이벤트로 남겨야 합니다. easy 봇은 이 필드를 전혀 쓰지 않지만, 나중에 스키마를 바꾸는 것보다 처음부터 넣어 두는 편이 쌉니다.
Score 와 HandsLeft 도 같은 이유로 미리 넣습니다. 공식 점수 규칙(나갈 때마다 남은 인원수만큼 획득, 20판)을 쓰면 “이번 판 1등"과 “20판 누적 우승"은 서로 다른 전략 을 만듭니다. 마지막 판에 점수 선두를 막아야 하는 상황과 첫 판의 상황이 같을 수 없습니다.
SeatInfo.HandCount 가 이 설계의 핵심입니다. 남은 장수는 테이블 위에서 눈으로 셀 수 있는 정보이므로 봇에게 줘도 공정합니다. 반면 그 장수가 어떤 카드인지는 알 수 없습니다. Part 2 의 hard 봇도 이 경계 안에서만 놉니다. 봇이 강해지는 이유는 더 많이 보기 때문이 아니라, 같은 것을 보고 더 잘 세기 때문 이어야 합니다.
2. 도메인 타입#
type Rank int
const (
RankHighest Rank = 1 // 대달무티 카드. 세상에 1장뿐이다
RankLowest Rank = 12 // 농노 카드. 12장이나 있다
RankJester Rank = 13 // 광대. 단독으로는 최약, 섞어 내면 와일드카드
)
type Card struct {
ID int // 덱 생성 시 부여하는 고유 번호. 동률 후보의 tie-break 에 쓴다
Rank Rank
}
// Play 는 테이블에 놓인 한 수다.
// Rank 는 조커를 제외한 실제 rank 이며, 조커만 냈다면 RankJester 다.
type Play struct {
Seat SeatID
Rank Rank
Count int
Cards []Card
}
func (p Play) Beats(prev Play) bool {
return p.Count == prev.Count && p.Rank < prev.Rank
}
Card.ID 를 굳이 두는 이유가 있습니다. 12를 5장 낼 때 손에 있는 12 일곱 장 중 어느 다섯 장 을 내는지는 게임 규칙상 아무 차이가 없습니다. 하지만 구현에서는 차이가 생깁니다. 선택이 map 순회 순서 같은 것에 의존하면 같은 입력에서 다른 출력이 나오고, 그 순간 봇 로직은 테스트할 수 없는 코드 가 됩니다. 그래서 모든 동률 상황의 최종 tie-break 를 card ID 오름차순으로 고정합니다.
3. 정책 인터페이스#
봇의 난이도를 나중에 여러 단계로 늘릴 것이므로, 의사결정 지점을 인터페이스로 뽑아 둡니다.
type BotPolicy interface {
// SelectPlay 는 자기 차례에 낼 카드를 고르거나 패스한다.
SelectPlay(v GameView) Decision
// DeclareRevolution 은 세금 징수 직전, 혁명 선언 여부를 결정한다.
DeclareRevolution(v GameView) bool
// SelectTaxCards 는 세금 의무에 따라 넘길 카드를 고른다.
SelectTaxCards(v GameView, duty TaxDuty) []Card
}
type Decision struct {
Pass bool
Cards []Card
}
의사결정 지점이 정확히 세 곳이라는 점을 눈여겨볼 만합니다. 달무티에서 플레이어가 선택하는 순간은 ① 자기 턴에 무엇을 낼 것인가, ② 혁명을 선언할 것인가, ③ 세금으로 어떤 카드를 넘길 것인가 뿐입니다. 난이도 차이는 전부 이 세 함수 안에서 만들어집니다.
easy 봇: 결정적 규칙만으로 만드는 기준선#
easy 봇의 설계 목표는 “약한 봇"이 아니라 “완전히 예측 가능하고 항상 합법적인 봇” 입니다. 확률도, 난수도, 탐색도 없습니다. 같은 상황에서는 언제나 같은 수를 둡니다. 이 봇은 세 가지 역할을 합니다.
- 룰 엔진의 합법성 검사를 수천 판 자동으로 두들기는 테스트 하네스
- 사람이 자리를 비웠을 때의 자동 플레이(autoplay) 대체 로직
- hard 봇의 강함을 측정할 기준선(baseline)
세 번째가 특히 중요합니다. 기준선이 없으면 hard 봇이 정말 나아졌는지 말할 수 없습니다.
일반 제출과 패스#
정책은 다음과 같습니다.
- 선공이면 광대를 제외한 가장 약한 rank 묶음 전체 를 제출합니다.
- 손에 광대만 남았으면 광대 한 장을 제출합니다.
- 후공이면 현재 묶음을 이길 수 있는 후보 중 가장 약한 합법 수 를 선택합니다.
- 광대 없는 후보를 우선하고, 반드시 필요할 때만 최소 수량 의 광대를 씁니다.
- 합법 수가 있으면 전략적으로 패스하지 않고 항상 제출합니다.
- 합법 수가 없을 때만 패스합니다.
- 동률 후보는 card ID 로 결정합니다.
flowchart TD
S["내 차례 시작"] --> T{"테이블이 비어 있는가?"}
T -- "예 (선공)" --> L1{"광대 말고 카드가 있는가?"}
L1 -- 예 --> L2["가장 약한 rank 묶음<br/>전체 제출"]
L1 -- 아니오 --> L3["광대 1장 제출"]
T -- "아니오 (후공)" --> F1["같은 장수 + 더 강한 rank<br/>합법 후보 전부 생성"]
F1 --> F2{"합법 수가 있는가?"}
F2 -- 아니오 --> P["패스"]
F2 -- 예 --> F3["정렬: 광대 적게 →<br/>약한 rank → 작은 card ID"]
F3 --> F4["첫 번째 후보 제출"]
style S fill:#FFD700,color:#000000
style L2 fill:#90EE90,color:#000000
style L3 fill:#90EE90,color:#000000
style F4 fill:#90EE90,color:#000000
style P fill:#FFB6C1,color:#000000
후보 생성부터 보겠습니다. 달무티의 합법 수는 (rank, 광대 사용량) 이라는 두 축으로 완전히 결정되므로, 후보 집합은 아주 작습니다.
type Candidate struct {
Rank Rank
Jesters int
Cards []Card
}
// LegalFollows 는 table 을 이길 수 있는 모든 (rank, 광대 사용량) 조합을 만든다.
// 같은 rank 안에서 어떤 카드를 고를지는 card ID 오름차순으로 고정한다.
func LegalFollows(hand []Card, table Play) []Candidate {
groups := groupByRank(hand) // map[Rank][]Card, 각 슬라이스는 ID 오름차순
jesters := groups[RankJester]
var out []Candidate
for r, cards := range groups {
if r == RankJester || r >= table.Rank {
continue // 광대 단독은 최약이고, 같거나 약한 rank 로는 못 받는다
}
for j := 0; j <= len(jesters); j++ {
natural := table.Count - j
if natural < 1 || natural > len(cards) {
continue // 광대만으로 묶음을 만들 수는 없다
}
picked := append(append([]Card{}, cards[:natural]...), jesters[:j]...)
out = append(out, Candidate{Rank: r, Jesters: j, Cards: picked})
}
}
return out
}
여기서 natural < 1 검사는 광대만으로는 묶음을 만들 수 없다 는 해석을 코드로 굳힌 것입니다. 광대는 어디까지나 다른 숫자에 붙는 와일드카드이므로, 최소 한 장의 진짜 카드가 있어야 그 숫자의 묶음이 됩니다. 이 부분은 모임마다 해석이 갈리는 지점이니, 룰 엔진에서 명시적으로 결정하고 문서화해 두는 편이 좋습니다.
이제 선택 로직입니다.
type EasyPolicy struct{}
func (EasyPolicy) SelectPlay(v GameView) Decision {
if v.Table == nil {
return easyLead(v.Hand)
}
cands := LegalFollows(v.Hand, *v.Table)
if len(cands) == 0 {
return Decision{Pass: true} // 합법 수가 없을 때만 패스한다
}
sort.Slice(cands, func(i, j int) bool { return easyLess(cands[i], cands[j]) })
return Decision{Cards: cands[0].Cards}
}
// easyLess 는 세 단계로 후보를 줄세운다.
// 1. 광대를 덜 쓰는 후보
// 2. 더 약한(숫자가 큰) rank
// 3. 가장 작은 card ID
func easyLess(a, b Candidate) bool {
if a.Jesters != b.Jesters {
return a.Jesters < b.Jesters
}
if a.Rank != b.Rank {
return a.Rank > b.Rank
}
return a.Cards[0].ID < b.Cards[0].ID
}
// easyLead 는 광대를 뺀 가장 약한 rank 묶음을 통째로 낸다.
func easyLead(hand []Card) Decision {
groups := groupByRank(hand)
weakest := Rank(0)
for r := range groups {
if r != RankJester && r > weakest {
weakest = r // 숫자가 클수록 약하다
}
}
if weakest == 0 { // 손에 광대만 남았다
return Decision{Cards: groups[RankJester][:1]}
}
return Decision{Cards: groups[weakest]}
}
정렬 기준의 순서에 의미가 있습니다. 광대 사용량을 rank 보다 먼저 보는 것 이 핵심입니다. 예를 들어 3장 판에서 내 손에 9가 3장, 11이 2장 + 광대 1장이 있다고 해봅시다. “가장 약한 수"만 따지면 11이 더 약하니 11 + 11 + 광대 를 내야 합니다. 하지만 그러면 와일드카드 한 장이 3장짜리 트릭 하나에 소모됩니다. 광대 우선 규칙이 있으면 9 + 9 + 9 를 내고 광대를 지킵니다. easy 봇이 가진 몇 안 되는 “쓸 만한 감각"입니다.
혁명#
혁명 정책은 계급 룩업 테이블 하나로 끝납니다.
- 대혁명 후보(대농노가 광대 2장 보유) 는 항상 선언합니다.
- 첫 판 의 일반 혁명은 선언하지 않습니다.
- 이후 일반 혁명 후보가 소농노 이면 선언합니다.
- 대달무티·소달무티·상인 이면 선언하지 않습니다.
flowchart TD
A["세금 징수 직전"] --> B{"광대 2장을<br/>모두 쥐었는가?"}
B -- 아니오 --> N["선언하지 않음"]
B -- 예 --> C{"내 계급이<br/>대농노인가?"}
C -- 예 --> G["대혁명 선언<br/>(무조건)"]
C -- 아니오 --> D{"첫 판인가?"}
D -- 예 --> N
D -- 아니오 --> E{"내 계급이<br/>소농노인가?"}
E -- 예 --> R["혁명 선언"]
E -- 아니오 --> N
style A fill:#FFD700,color:#000000
style G fill:#FF7F50,color:#000000
style R fill:#90EE90,color:#000000
style N fill:#D3D3D3,color:#000000
func (EasyPolicy) DeclareRevolution(v GameView) bool {
if countJesters(v.Hand) < 2 {
return false
}
me := v.SeatOf(v.Me)
if me.Title == GreaterPeon {
return true // 대혁명. 좌석이 통째로 뒤집히므로 조건 없이 선언한다
}
if v.Round == 1 {
return false // 첫 판은 세금 자체가 없어 취소할 것이 없다
}
return me.Title == LesserPeon
}
단순해 보이지만 이 표에는 나름의 근거가 있습니다. 달무티의 일반 혁명이 하는 일은 세금 징수 취소 하나뿐입니다. 그러므로 “세금으로 손해를 보는 쪽"만 선언할 이유가 있습니다.
| 계급 | 세금으로 인한 손익 | 혁명 선언 |
|---|---|---|
| 대달무티 | 최강 카드 2장을 받는다 (큰 이득) | 안 함 |
| 소달무티 | 최강 카드 1장을 받는다 (이득) | 안 함 |
| 상인 | 자기가 주고받는 것은 없다 | 안 함 (단순화) |
| 소농노 | 최강 카드 1장을 뺏긴다 (손해) | 함 |
| 대농노 | 최강 카드 2장을 뺏긴다 (큰 손해) | 대혁명으로 함 |
첫 판 예외도 같은 논리입니다. 우리는 전제표대로 공식 선택 규칙 First Deal Revolution 을 채택했으므로 첫 판에는 애초에 세금이 없습니다. 그러니 일반 혁명을 선언해봐야 취소할 세금이 없고, 얻는 것 없이 “나에게 광대 2장이 있다"는 사실만 전원에게 공개하는 순손실 행동이 됩니다. easy 봇조차 이 정보 노출 비용은 피하게 해 두는 것이 좋습니다.
반면 대혁명 분기는 첫 판에도 살아 있어야 합니다. 같은 선택 규칙이 “If the Greater Peon is dealt both Jesters, he or she may still call a greater revolution as usual” 이라고 명시하기 때문입니다. 대혁명이 취소하는 것은 세금이 아니라 좌석 그 자체 이므로, 세금이 없는 판에서도 선언할 값어치가 온전히 남습니다. 위 코드에서 GreaterPeon 검사가 Round == 1 검사보다 먼저 오는 것은 그래서입니다. 순서를 바꾸면 첫 판 대혁명이 조용히 사라집니다.
상인 행은 사정이 조금 다릅니다. 상인은 자기가 주고받는 카드가 없으니 손패 관점의 직접 이득이 0 이고, 정보 노출 비용만 남는 것처럼 보입니다. 하지만 혁명이 취소하는 것은 내 세금만이 아니라 판 전체의 세금 입니다. 상인이 혁명을 선언하면 두 달무티가 최강 카드를 받는 것도 함께 막힙니다. 이 “상대 강화 방지” 가치는 상인에게도 분명히 있고, 사실은 농노들에게도 세금 취소 이득과 별개로 존재합니다.
즉 위 표의 상인 행은 easy 봇을 위한 의도적 단순화 입니다. easy 봇은 자기 손패 손익만 보고 상대 손패 변화를 계산하지 않습니다. Part 3 의 hard 봇에서 이 항을 제대로 분리해 다루겠습니다.
세금징수#
세금 정책은 두 가지 의무로 나뉩니다.
- 낮은 계급 봇의
GiveBest의무는 룰이 강제하는 가장 강한 카드 를 제출합니다. - 높은 계급 봇의
ReturnAny의무는 가장 약한 카드부터 제출합니다. - 같은 강도의 카드는 card ID 를 tie-breaker 로 씁니다.
type TaxKind int
const (
GiveBest TaxKind = iota // 농노 → 달무티. 가장 강한 카드를 바쳐야 한다 (룰 강제)
ReturnAny // 달무티 → 농노. 아무 카드나 내려줘도 된다 (선택권 있음)
)
type TaxDuty struct {
Kind TaxKind
Count int
To SeatID
}
func (EasyPolicy) SelectTaxCards(v GameView, duty TaxDuty) []Card {
h := append([]Card(nil), v.Hand...)
switch duty.Kind {
case GiveBest:
sort.Slice(h, func(i, j int) bool {
if h[i].Rank != h[j].Rank {
return h[i].Rank < h[j].Rank // 숫자가 작을수록 강하다
}
return h[i].ID < h[j].ID
})
case ReturnAny:
sort.Slice(h, func(i, j int) bool {
if h[i].Rank != h[j].Rank {
return weakerFirst(h[i].Rank, h[j].Rank)
}
return h[i].ID < h[j].ID
})
}
return h[:duty.Count]
}
// weakerFirst 는 "약한 카드 먼저" 순서를 정한다.
// 광대는 서열상 최약이지만 와일드카드라 실질 가치가 높으므로 맨 뒤로 뺀다.
func weakerFirst(a, b Rank) bool {
if (a == RankJester) != (b == RankJester) {
return b == RankJester
}
return a > b
}
weakerFirst 의 광대 예외를 빼먹으면 조용한 버그가 생깁니다. 광대는 단독 서열로는 13, 즉 최약 이므로, 순수 서열 순으로 정렬하면 대달무티 봇이 세금 반환 카드로 광대를 내려주게 됩니다. 룰 위반은 아니지만 게임적으로는 최악의 수입니다.
광대를 지켜야 하는 이유는 순수한 플레이 가치 에 있습니다. 혁명과는 무관합니다. 앞의 규칙 요약에서 봤듯 혁명 판정은 딜 직후 세금 징수보다 먼저 끝나고 카드는 매 판 다시 딜되므로, 세금으로 넘긴 광대가 그 판의 혁명 재료가 되는 일은 없습니다. 광대는 어떤 묶음에든 붙어 장수를 늘려 주는 유일한 카드이고, Part 2 에서 보겠지만 달무티에서 묶음 한 장 차이는 “그 수를 받을 수 있는 사람이 존재할 확률 19%“와 “2%“를 가릅니다. 최강 카드 2장을 뺏어 오면서 그 대신 상대 묶음을 부풀릴 열쇠를 건네는 셈입니다. 서열과 실질 가치가 어긋나는 유일한 카드이므로 명시적으로 예외 처리해야 합니다.
GiveBest 쪽은 반대로 아무 예외가 필요 없습니다. 규칙이 “가장 강한 카드"를 강제하므로 봇에게 선택의 여지 자체가 없고, 광대는 최약이라 애초에 후보에 오르지 않습니다. 세금은 주는 쪽이 아니라 받는 쪽에서만 의사결정이 일어난다 는 점을 코드가 그대로 보여줍니다.
세금 단계의 실행 순서#
세금 단계는 여러 좌석의 의무가 동시에 걸리기 때문에, 정책 자체보다 처리 순서 가 더 까다롭습니다.
flowchart TD
A["판 시작 · 세금 단계 진입"] --> B["교환 전 손패 스냅샷 기준으로<br/>모든 봇의 의무 선택을 먼저 계산"]
B --> C["2.5초 대기<br/>(사람이 따라올 수 있는 속도로)"]
C --> D["pending 상태인 봇 의무를<br/>한 transaction 으로 일괄 제출"]
D --> E{"사람이 수행할 의무가<br/>남아 있는가?"}
E -- 예 --> F["해당 입력 대기"]
E -- 아니오 --> G["카드 교환 확정 → 첫 턴 시작"]
F --> G
style A fill:#FFD700,color:#000000
style B fill:#87CEEB,color:#000000
style G fill:#90EE90,color:#000000
두 가지 규칙이 중요합니다.
모든 봇 선택은 교환 전 손패를 기준으로 먼저 계산합니다. 봇 A 가 카드를 넘긴 결과가 봇 B 의 손패에 반영된 뒤 봇 B 가 선택하게 되면, 처리 순서에 따라 결과가 달라져 재현이 불가능해집니다. 스냅샷을 떠서 전부 계산한 뒤 한꺼번에 적용하면 순서 의존성이 사라집니다.
pending 인 봇 의무는 한 transaction 에서 일괄 제출합니다. 세금 교환은 여러 좌석의 손패를 동시에 바꾸는 연산이라, 중간에 실패하면 카드가 증발하거나 복제됩니다. 원자적으로 처리해야 합니다.
2.5초 지연 은 알고리즘이 아니라 UX 결정입니다. 봇의 판단에는 사람의 반응 시간만큼이 필요하지 않으므로, 지연이 없으면 사람 눈에는 “화면이 한 번 깜빡였더니 이미 다 끝나 있는” 상태가 됩니다. 무슨 일이 일어났는지 읽을 시간을 주기 위해 인위적으로 넣는 대기입니다. 이 지연은 봇의 턴 제출에도 똑같이 적용하고, 클라이언트 애니메이션과 어긋나지 않도록 bot_action 같은 단일 유형으로 분류해 한 곳에서 관리하는 편이 좋습니다.
easy 봇은 왜 지는가#
easy 봇은 기준선으로서 훌륭하지만, 사람과 몇 판만 붙어 보면 금방 약점이 드러납니다. 그 약점들이 정확히 Part 2 와 Part 3 의 설계 항목이 됩니다.
1. 이미 나온 카드를 보지 않습니다. GameView.Log 를 아예 읽지 않습니다. 달무티는 rank 별 장수가 고정된 덱이라 카드 카운팅의 정확도가 대단히 높은 게임인데, easy 봇은 그 정보를 통째로 버립니다.
2. 낼 수 있으면 무조건 냅니다. 이것이 가장 큰 손실입니다. 재미있게도 공식 룰북이 직접 이 점을 지적합니다.
“Most of the time you should play your worst cards when you can, but don’t be afraid to pass sometimes even when you can play. A savvy player in a lower position often won’t play a good set until several people are out, saving it until he or she can use it to try to take the lead.” — 공식 룰북 The Strategy of Saving Cards 절
easy 봇에는 이 판단이 아예 없습니다. 게다가 달무티는 패스한 뒤에도 다시 낼 수 있는 룰이라 패스의 비용이 원래 낮습니다. “패스=탈락” 변형에서는 참는 것이 “이번 트릭 포기"라는 확정 손실이지만, 공식 룰에서는 그냥 “이번 차례만 거른다"에 불과합니다. 참는 것이 이렇게 싼 게임에서 한 번도 참지 않는 것은 그만큼 큰 손실입니다.
3. 묶음을 지키지 않습니다. 누군가 12 x 3 으로 연 트릭에서 내 손에 11이 일곱 장 있으면, easy 봇은 성실하게 11 x 3 을 냅니다. 그 순간 사실상 아무도 못 받는 7장짜리 리드 재료 가 쪼개져 반쪽이 됩니다. 후보를 “가장 약한 수” 기준으로만 고르기 때문에 벌어지는 일입니다.
4. 광대를 흘립니다. 선공 로직이 광대를 묶음에 붙이지 않으므로, 12 x 4 + 광대 2장 을 쥐고도 4장 리드를 냅니다. 광대를 붙여 6장으로 만들면 사실상 무적인데 말입니다. 게다가 손에 광대만 남으면 한 장씩 흘려 두 턴을 씁니다.
5. 혁명 판단이 손패와 무관합니다. 계급만 보고 결정하므로, 세금을 취소하는 것이 실제로 이득인지 손해인지 계산하지 않습니다.
6. 완전히 예측 가능합니다. 결정적이라는 것은 테스트에는 축복이지만 상대에게는 정보입니다. 사람은 몇 판 만에 “이 봇은 늘 가장 약한 수로 받는다"는 패턴을 읽고, 그 위에서 자기 손패를 짭니다.
여섯 가지를 한 문장으로 줄이면 이렇습니다.
easy 봇은 한 턴 안에서 합법적인 최소 손실 을 계산할 뿐, 판 전체에서 자기 손패를 몇 번 제출해 털지 는 계산하지 않습니다.
마치며#
Part 1 에서는 봇 설계의 뼈대를 잡았습니다. 정리하면 세 가지입니다.
- 공정성을 타입으로 못 박습니다. 봇은
GameView만 봅니다. 상대 손패는 구조체에 존재하지 않고, 남은 장수만 압니다. - 결정성을 확보합니다. 모든 동률은 card ID 로 끊고, 세금 선택은 교환 전 스냅샷 기준으로 일괄 계산합니다. 그래야 봇 로직이 테스트 가능한 코드가 됩니다.
- 의사결정 지점은 세 곳뿐입니다. 제출·혁명·세금. 난이도는 전부 이 세 함수 안에서 만들어집니다.
Part 2 와 Part 3 에서는 이 세 함수를 다시 씁니다. 위에 적은 여섯 가지 약점을 하나씩 되짚으면서, 나온 카드를 세고, 자기 손패를 몇 번에 털지 계산하고, 낼 수 있어도 패스할 줄 아는 hard 봇을 설계하겠습니다.
References#
- The Great Dalmuti (BoardGameGeek)
- The Great Dalmuti Rulebook (Wizards of the Coast, PDF)
- The Great Dalmuti (Wikipedia)
- Climbing Games Index (Pagat)
- Big Two (Pagat) — 패스 후 재참여가 기본 규칙임을 명시
- Dai Fugō / Dai Hin Min (Pagat) — “패스하면 못 낸다"를 변형 룰로 분류
- Lexio Rules (marquand.net) — “Passing is not permanent in the hand”. 발행사 문서가 아닌 정리 자료
- 달무티 CPU 플레이어 만들기 Part 2: hard 봇의 정보 모델과 확실승수 판정
- 달무티 CPU 플레이어 만들기 Part 3: 수 평가와 전략적 패스
- 대부호부터 렉시오까지: 클라이밍 카드게임의 세계와 전략