달무티 CPU 플레이어 만들기 Part 2: hard 봇의 정보 모델과 확실승수 판정
이 글은 Claude Opus 5 를 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
hard 봇이 지켜야 할 세 가지#
Part 1에서는 달무티 CPU 플레이어의 뼈대를 잡고, 결정적 규칙만으로 움직이는 easy 봇을 만들었습니다. easy 봇은 합법적이고 예측 가능하지만, “낼 수 있으면 무조건 낸다"는 정책 때문에 사람에게 쉽게 집니다.
이번 편부터 hard 봇을 설계합니다. 설계 제약은 세 가지입니다.
1. 상대의 손패는 절대 볼 수 없습니다. 봇은 Part 1 에서 정의한 GameView 만 받습니다. 난이도를 올리기 위해 정보를 더 주는 것은 설계가 아니라 반칙입니다. 사람이 “저 봇 손패 보는 거 아니야?“라고 의심하는 순간 게임의 신뢰가 무너집니다.
2. 이미 제출된 카드를 셉니다. easy 봇이 통째로 버리던 공개 행동 로그를 전부 활용합니다. 이것이 두 난이도를 가르는 가장 큰 차이입니다.
3. 여러 합법 수 중에서 전략적으로 고르고, 낼 수 있어도 패스할 줄 압니다. 공식 룰북조차 The Strategy of Saving Cards 절에서 “don’t be afraid to pass sometimes even when you can play” 라고 조언하는 판단을, 명시적인 기대값 계산으로 옮깁니다.
분량이 있어 hard 봇을 두 편으로 나눕니다. Part 2(이 글)는 봇이 무엇을 아는가, 즉 판단의 재료를 만듭니다. Part 3 은 그 재료로 무엇을 결정하는가, 즉 수 평가 함수와 전략적 패스, 세금과 혁명 판단, 그리고 검증 방법을 다룹니다.
이 글도 설계안입니다#
Part 1 과 마찬가지로 이 글은 특정 제품의 구현 보고서가 아니라 설계안이고, 코드는 논지를 보이기 위한 발췌입니다. 룰셋 전제는 Part 1 의 전제표를 그대로 따릅니다. 그중 hard 봇 설계에 결정적인 두 가지만 다시 적어 두겠습니다.
- 패스해도 자기 차례가 다시 오면 낼 수 있습니다. 달무티의 패스는 트릭 포기가 아니라 “이번 차례만 거른다"입니다. (Part 1 에서 확인했듯 렉시오·빅투·대부호의 대표 규칙 자료도 마찬가지이고, “패스=탈락"이 오히려 소수 변형 룰입니다.)
- 살아 있는 전원이 연속으로 패스해야 트릭이 끝나고, 마지막에 낸 사람이 다음 트릭을 리드합니다.
이 두 줄이 Part 3 의 리드권 계산과 패스 판단을 전부 규정합니다.
1. 공개 정보 모델: 달무티는 카운팅이 쉬운 게임이다#
오차가 0인 카운팅#
달무티는 카드 카운팅 관점에서 비정상적으로 유리한 게임 입니다. 이유가 세 가지 있습니다.
- 덱 구성이 완전히 알려져 있습니다. rank r 은 정확히 r 장, 광대는 2장. 트럼프처럼 무늬를 따질 필요도 없습니다.
- 80장을 전부 나눠줍니다. 뒷면으로 남는 더미가 없습니다. 그래서 “내가 못 본 카드"는 곧 “상대 손패에 있는 카드” 와 정확히 일치합니다. 추정이 아니라 확정입니다.
- rank 별 장수가 비대칭입니다. rank 3은 세상에 3장뿐이라, 3장이 다 나온 순간 “이제 3으로는 아무도 받을 수 없다"가 확정됩니다. 낮은 rank 일수록 빨리 소진되고, 소진되는 즉시 강력한 확정 정보가 됩니다.
// Ledger 는 공개 정보만으로 만들어지는 카운팅 장부다.
type Ledger struct {
unseen [RankJester + 1]int // unseen[r] = 내가 못 본 rank r 카드 수
seats []SeatModel
me SeatID
}
func NewLedger(v GameView) *Ledger {
l := &Ledger{me: v.Me}
for r := RankHighest; r <= RankLowest; r++ {
l.unseen[r] = int(r) // rank r 은 r 장
}
l.unseen[RankJester] = 2
for _, c := range v.Hand { // 내 손패는 이미 본 것이다
l.unseen[c.Rank]--
}
for _, a := range v.Log { // 이번 판에 테이블에 나온 모든 카드
if a.Pass {
continue
}
for _, c := range a.Play.Cards {
l.unseen[c.Rank]--
}
}
l.seats = buildSeatModels(v)
return l
}
func (l *Ledger) TotalUnseen() int {
sum := 0
for _, n := range l.unseen {
sum += n
}
return sum
}
포커의 아웃츠 계산과 비슷해 보이지만 성격이 다릅니다. 포커는 “덱에 남은 카드"를 세는 것이라 그 카드가 누구 손에 있는지 알 수 없지만, 달무티는 남은 카드가 전부 누군가의 손에 있음이 보장 됩니다. 그래서 카운팅 결과는 곧바로 “이 수를 받을 조합이 원리적으로 존재할 수 있는가” 라는 질문의 답이 됩니다.
다만 여기서 한 걸음 더 나가면 안 됩니다. 장부가 알려주는 것은 rank 별 총량 이지 분포 가 아닙니다. rank 9 가 다섯 장 남아 있다는 사실은 알아도, 그 다섯 장이 한 사람 손에 몰려 있는지 다섯 명에게 한 장씩 흩어져 있는지는 알 수 없습니다. 정확히 말하면 이렇습니다.
카운팅만으로 확정할 수 있는 것은 “아무도 받을 수 없다"는 부정 판정뿐 입니다. “받을 사람이 실제로 존재한다"는 긍정 판정에는 좌석별 분포 모델이 따로 필요합니다.
다음 절에서 판정을 구조적 판정과 확률적 판정으로 나누는 것이 바로 이 때문입니다. 앞쪽은 카운팅만으로 끝나고, 뒤쪽은 확률이 필요합니다.
패스는 정보이되, 확정 정보가 아니다#
여기서 한 걸음 더 나갈 수 있습니다. 손패를 훔쳐보지 않고도 상대 손패를 추정할 단서가 있습니다. 바로 패스 입니다.
어떤 좌석이 (rank R, n장) 트릭에서 패스했다면, 그 좌석에 대해 무언가를 알게 된 것은 분명합니다. 문제는 무엇을 알게 되었는가 입니다. 관찰된 패스는 두 경우를 구분하지 못합니다.
- 낼 카드가 없었다.
- 낼 수 있지만 아꼈다.
그리고 두 번째가 바로 이 글이 hard 봇의 핵심 능력으로 권장하는 행동입니다. 패스를 “R 보다 강한 rank 로 n장을 못 만든다"는 확정 배제(hard exclusion) 로 쓰면, 상대가 강할수록 장부가 더 심하게 오염됩니다. 자기가 하는 짓을 상대는 안 한다고 가정하는 셈이니까요.
게다가 달무티는 패스한 뒤에도 다시 낼 수 있는 룰이라, 패스의 정보량이 구조적으로 낮습니다. 한 번 패스하면 그 트릭에서 빠지는 변형 룰에서는 패스가 되돌릴 수 없는 선언이라 그나마 무겁지만, 공식 룰의 패스는 “지금은 안 낸다"에 불과합니다. 되돌릴 수 있는 선택일수록 신호로서의 값어치가 떨어집니다.
그래서 패스는 확정 배제가 아니라 확률적 증거(soft evidence) 로 다룹니다.
type SeatModel struct {
Seat SeatID
HandCount int
Finished bool
// PassEvidence 는 이 좌석이 패스한 트릭들의 기록이다.
// 확정 배제가 아니라 우도(likelihood) 계산의 재료로만 쓴다.
PassEvidence []PassObservation
}
type PassObservation struct {
Rank Rank // 그때 받아야 했던 수
Count int
}
// adjustByPasses 는 "좌석 s 가 (rank, n) 을 받을 수 있다" 는 base 값을
// 관측된 패스들에 비추어 낮춘 위험 점수다.
//
// 베이즈 갱신의 형태를 빌린다. 우도는 다음과 같다.
//
// P(패스 | 받을 수 없음) = 1 ... 패스 말고 선택지가 없다
// P(패스 | 받을 수 있음) = hold ... "낼 수 있는데도 참을 확률"
//
// 그러므로 관측 하나마다 승산(odds)에 hold 를 곱한다.
// hold 가 0 이면 확정 배제와 같아지고, 1 이면 패스에서 아무 정보도 얻지 않는다.
func (m *SeatModel) adjustByPasses(base float64, rank Rank, n int, hold float64) float64 {
if base <= 0 {
return 0
}
// base 가 정확히 1 이면 승산이 무한대가 되어 갱신이 죽는다.
// base 는 Boole 상한이라 1 로 포화되기 쉬우므로 반드시 1 미만으로 눌러 둔다.
base = math.Min(base, 1-1e-6)
odds := base / (1 - base)
for _, ev := range m.PassEvidence {
if ev.Count != n || ev.Rank <= rank {
continue // 이번 판단과 무관한 관측이다
}
odds *= hold
}
return odds / (1 + odds)
}
정규화가 반드시 필요합니다. 입력값에 hold 를 그냥 곱하면 베이즈 정리의 분자만 계산한 값이 됩니다. 올바른 형태는 이렇습니다.
P(받을 수 있음 | 패스) = hold·p / (hold·p + 1·(1-p))
p = 0.5, hold = 0.3 이면 정규화한 값은 0.15 / (0.15 + 0.5) = 0.231 인데, 분자만 쓰면 0.15 가 나옵니다. 방향은 같지만 과하게 깎여서 봇이 상대의 응수 능력을 실제보다 낮게 보고 무리한 리드를 던지게 됩니다. 위 코드처럼 승산으로 계산하면 관측이 여러 개여도 곱셈 한 번씩으로 누적되므로 정규화를 빠뜨릴 일도 없습니다.
이 값을 “확률"이라 부르지는 않겠습니다#
식은 베이즈 갱신의 형태를 갖췄지만, 여기에 들어가는 입력이 진짜 사전확률이 아닙니다. 두 가지 이유가 있습니다.
첫째, 입력이 Boole 상한입니다. 다음 절에서 볼 SeatBeatRisk 는 rank 별 확률을 그냥 더한 값이라 실제 확률보다 큽니다. 심하면 1로 포화됩니다. 그리고 값이 정확히 1이면 승산이 무한대가 되어 패스 증거가 아무리 쌓여도 갱신이 죽습니다. 위 코드에서 1 - 1e-6 으로 눌러 두는 것은 이 함정을 막기 위해서입니다. 상한을 확률처럼 쓰기로 한 이상, 이런 경계 처리는 선택이 아니라 필수입니다.
둘째, 관측들이 조건부 독립이 아닙니다. 관측 하나마다 hold 를 반복해서 곱하는 것은 “패스들이 서로 독립적인 증거"라는 가정 위에 서 있습니다. 그런데 같은 손패를 쥔 같은 상대가 결정적 정책 으로 낸 패스들은 강하게 상관되어 있습니다. 세 번 패스했다고 해서 증거가 세 배로 쌓이지는 않습니다.
그래서 이 값은 사후확률이 아니라 “패스 증거로 조정한 보수적 위험 점수” 로 부르는 편이 정직합니다. 후보 간 순서를 매기는 데는 충분하지만, 확률로 표시하거나 다른 확률과 곱해 해석할 값은 아닙니다. 진짜 확률이 필요하다면 광대와 rank 의 결합 분포까지 반영해 좌석별 확률을 먼저 제대로 구한 다음 갱신해야 하는데, 그건 이 글의 범위를 넘습니다.
hold 값을 어떻게 잡느냐가 그대로 봇의 성격이 됩니다. easy 봇들만 있는 방이라면 hold 를 0에 가깝게 두는 것이 정확합니다. easy 봇은 낼 수 있으면 반드시 내니까요. 사람이나 hard 봇이 섞인 방에서는 0.3~0.5 정도가 안전합니다. 상대 모델링을 상수 하나로 노출해 두면, 나중에 “상대 성향을 관측해서 이 값을 갱신"하는 확장이 자연스럽게 붙습니다.
한 가지 더. PassEvidence 는 판이 바뀌면 반드시 초기화 해야 합니다. 세금 교환으로 손패가 바뀌고 다음 판에는 카드가 다시 딜되므로, 이전 판의 관측은 전부 무효입니다.
2. 확실승수 판정: “이 수는 아무도 못 받는다”#
구조적 판정 — 확률이 필요 없는 영역#
달무티에는 다른 클라이밍 게임에 없는 강력한 구조적 성질이 있습니다.
rank
r'로n장짜리 묶음을 만들려면, rankr'카드x장(최소 1장)과 광대j장이 필요하고x + j = n입니다. 광대는 세상에 2장뿐이므로r' ≥ n - 2여야 합니다.
즉 큰 묶음일수록 응수할 수 있는 rank 자체가 사라집니다. 강약이 아니라 장수 때문에 그렇습니다.
| 리드 장수 | 응수 가능한 rank (상대가 광대 2장 보유 시) | 응수 가능한 rank (내가 광대 2장 보유 시) |
|---|---|---|
| 3장 | 1 이상 | 3 이상 |
| 4장 | 2 이상 | 4 이상 |
| 5장 | 3 이상 | 5 이상 |
| 6장 | 4 이상 | 6 이상 |
| 8장 | 6 이상 | 8 이상 |
| 10장 | 8 이상 | 10 이상 |
| 12장 | 10 이상 | 12 이상 |
여기에 “리드한 rank 보다 강해야 한다"는 조건이 겹칩니다. 예를 들어 12 x 8 을 리드하면, 응수 가능한 rank 는 6~11 뿐이고 그 여섯 개 rank 중 하나에서 8장을 한 손에 몰아 쥔 사람 이 있어야 합니다. rank 6은 세상에 6장뿐이라 광대 2장을 다 합쳐야 겨우 8장입니다.
이 판정은 확률이 아니라 참/거짓입니다.
// StructurallySafe 는 미공개 카드 전부를 한 명이 가졌다고 가정해도
// (rank, n) 을 이길 수 없으면 true 를 리턴한다.
func (l *Ledger) StructurallySafe(rank Rank, n int) bool {
jokers := l.unseen[RankJester]
for r := RankHighest; r < rank; r++ {
if l.unseen[r] < 1 {
continue // 자연 카드가 최소 1장은 있어야 그 rank 의 묶음이 된다
}
if l.unseen[r]+min(jokers, n-1) >= n {
return false // 이길 수 있는 조합이 원리적으로 존재한다
}
}
return true
}
min(jokers, n-1) 이 미묘한 부분입니다. 광대가 2장 남아 있어도 자연 카드 1장은 반드시 필요하므로, 광대는 최대 n-1 장까지만 쓸 수 있습니다.
이 함수가 true 를 리턴하면 봇은 리드권 회수가 보장된 경로 를 얻습니다. 아무도 받을 수 없으니 전원이 연속으로 패스할 수밖에 없고, 트릭은 반드시 나에게 돌아옵니다. 뒤에서 보겠지만 달무티에서 리드권 회수는 대단히 어려운 일이라, 이 보장의 값어치가 다른 클라이밍 게임보다 훨씬 큽니다.
확률적 판정 — 실전에서 필요한 회색지대#
구조적으로 안전한 수는 판 후반에나 나옵니다. 판 초반에는 확률로 판단해야 합니다. 문제는 이렇습니다. “이길 수 있는 카드가 남아 있다"와 “그 카드가 한 사람의 손에 몰려 있다"는 전혀 다른 얘기입니다.
이 차이가 얼마나 큰지 보기 위해, 판 시작 시점(아무 정보도 없는 최악의 조건)에서 rank 12 를 n 장 리드했을 때 그 수를 받을 수 있는 사람이 존재할 확률 을 몬테카를로로 뽑아 봤습니다.
| 리드 장수 | 4명 (20장씩) | 6명 (13~14장씩) | 8명 (10장씩) |
|---|---|---|---|
| 3장 | 100.0% | 100.0% | 100.0% |
| 4장 | 100.0% | 96.6% | 78.8% |
| 5장 | 93.6% | 48.2% | 18.8% |
| 6장 | 51.7% | 9.9% | 1.9% |
| 7장 | 14.7% | 1.1% | 0.1% |
| 8장 | 2.7% | 0.1% | 0.0% |
전제와 오차를 명시해 두겠습니다. 재현 스크립트는 글 끝의 부록에 있습니다.
- 셀당 6만 회 시행. 이항 표준오차는 최대 약 0.2%p 이므로, 표의 소수 첫째 자리까지는 신뢰할 만합니다.
- 80장 풀덱 가정 입니다. 공식 룰북은 4~5명에서 stripped deck(5명이면 12를 빼고 68장, 4명이면 12와 11을 빼고 57장)을 선택 규칙으로 제시하는데, 그 경우 표의 4명 열은 성립하지 않습니다. rank 12 자체가 덱에 없기 때문입니다.
- “받을 수 있는 사람이 존재할 확률” 이지 “실제로 받히는 확률"이 아닙니다. 전략적 패스를 하는 상대가 섞이면 실제 값은 이보다 낮습니다. 봇 입장에서는 보수적인 상한이라 오히려 안전합니다.
읽어낼 것이 많은 표입니다.
- 5장과 6장 사이에 절벽이 있습니다. 8명 기준으로 5장 리드는 19%가 받히지만 6장 리드는 2%입니다. 한 장 차이로 위험이 열 배 줄어듭니다.
- 인원이 많을수록 큰 묶음이 강해집니다. 같은 6장 리드가 4명 게임에서는 52% 받히고, 8명 게임에서는 2% 받힙니다. 손패가 얇게 쪼개지면 아무도 큰 묶음을 못 만들기 때문입니다. 인원수는 봇 가중치의 입력 변수여야 합니다.
- 초반의 작은 묶음 리드는 리드권 유지 수단이 아닙니다. 8명 게임의 4장 리드도 79%가 받힐 수 있습니다. 확정은 아니지만 기대하기 어려운 확률이므로, 4장 이하 리드는 “리드권을 지킨다"가 아니라 “짐을 흘려보낸다” 로 계산하는 편이 맞습니다.
봇에서는 이 값을 좌석별 초기하 분포로 근사합니다.
// SeatBeatRisk 는 좌석 s 가 (rank, n) 을 받을 수 있을 확률의 근사다.
func (l *Ledger) SeatBeatRisk(s SeatModel, rank Rank, n int) float64 {
if s.Finished || s.HandCount < n {
return 0 // 장수가 모자라면 애초에 못 받는다
}
total := l.TotalUnseen()
jokers := l.unseen[RankJester]
need := n - min(jokers, n-1) // 필요한 자연 카드 최소 장수
p := 0.0
for r := RankHighest; r < rank; r++ {
p += hyperAtLeast(l.unseen[r], total, s.HandCount, need)
}
p = math.Min(p, 1) // Boole 상한이라 1 을 넘을 수 있다. 갱신 전에 자른다
// 관측된 패스를 우도로 반영해 위험 점수를 낮춘다.
return s.adjustByPasses(p, rank, n, l.holdPropensity(s))
}
// hyperAtLeast 는 크기 total 의 모집단에 k 개의 성공 원소가 있을 때
// draws 장을 뽑아 성공 원소가 want 개 이상 나올 확률이다 (초기하 분포).
func hyperAtLeast(k, total, draws, want int) float64 { /* ... */ }
두 판정을 하나로 묶으면 이런 흐름이 됩니다. 값싼 확정 판정을 먼저 시도하고, 거기서 갈리지 않을 때만 확률 계산으로 내려갑니다.
flowchart TD
A["묶음 (rank r, n장) 을 평가"] --> B{"n - 2 이상이면서<br/>r 보다 강한 rank 가<br/>남아 있는가?"}
B -- 아니오 --> S["구조적 확실승수<br/>리드권 회수 보장"]
B -- 예 --> C["남은 rank 별로<br/>필요 자연 카드 수 계산"]
C --> D["좌석별 초기하 확률<br/>(손패 장수 · 미공개 총수)"]
D --> E["rank 별 확률을 Boole 상한으로<br/>합산 후 1 로 절단"]
E --> F["패스 관측을 우도로 반영<br/>승산에 hold 를 곱해 위험 점수 하향"]
F --> G{"위험이 임계값<br/>미만인가?"}
G -- 예 --> H["사실상 안전<br/>리드 후보로 우대"]
G -- 아니오 --> I["받힐 각오<br/>리드권 있을 때 흘려보낼 후보"]
style A fill:#FFD700,color:#000000
style S fill:#90EE90,color:#000000
style H fill:#87CEEB,color:#000000
style I fill:#FFB6C1,color:#000000
왼쪽 가지가 짧다는 점이 중요합니다. 구조적 판정은 rank 를 한 바퀴 도는 것으로 끝나고 확률 계산이 아예 필요 없습니다. 판이 진행되어 낮은 rank 가 소진될수록 이 가지로 빠지는 경우가 늘어나므로, 후반으로 갈수록 봇의 판단이 더 싸지고 더 정확해집니다.
정확도를 더 올리려면 rank 들 사이의 배타성(같은 카드가 두 rank 에 동시에 속할 수 없음)과 좌석들 사이의 배타성을 반영한 다변량 초기하가 필요합니다. 여기서는 rank 별 확률을 단순 합산하는 상한(Boole 부등식)을 씁니다. 어차피 봇에게 필요한 것은 정확한 확률이 아니라 후보들 사이의 순서 이기 때문입니다. 다만 이 값을 “확률"이라 부르며 1을 넘길 수 있는 값과 곱셈하는 순간 계산이 망가지므로, 반드시 math.Min(p, 1) 으로 잘라야 합니다.
3. 패 분해 플랜: 내 손패는 몇 번 내야 비는가#
최소 제출 횟수의 하한#
지난 글에서 정리한 “고수는 카드의 세기가 아니라 턴의 흐름을 본다"는 원칙을 달무티에 대입하면 아주 깔끔한 형태가 나옵니다. 달무티는 묶음이 같은 rank 로만 만들어지므로, 손패를 나누는 방법에 자유도가 거의 없습니다.
손패를 다 털기 위한 최소 제출 횟수의 하한 = 서로 다른 자연 rank 의 개수 입니다. 광대는 어느 묶음에든 흡수되므로 이 수를 늘리지 않습니다.
표현을 조심해야 합니다. 이것은 “몇 번 성공적으로 카드를 내야 하는가"의 하한 이지, 실제 턴 수가 아닙니다. 실제로는 패스한 차례도 턴이고, 묶음을 쪼개면 제출 횟수가 늘고, 광대만 남은 손패는 광대를 단독으로 흘려야 하므로 예외입니다. 그래도 이 하한이 손패 평가의 좋은 기준인 이유는, 줄일 수는 없고 늘어나기만 하는 값 이기 때문입니다.
무작위 손패를 30만 번 돌려 서로 다른 자연 rank 수를 세어 보면 이렇습니다.
| 인원 | 손패 장수 | 평균 서로 다른 자연 rank 수 |
|---|---|---|
| 4명 | 20장 | 9.20 |
| 6명 | 13장 | 7.61 |
| 8명 | 10장 | 6.57 |
8명 게임에서 평균적인 손패는 최소 일곱 번은 성공적으로 카드를 내야 비워집니다. 좋은 손패란 rank 가 뭉쳐 있어 이 숫자가 4~5 로 떨어지는 손패이고, 나쁜 손패는 rank 별로 한두 장씩 흩어져 이 숫자가 8을 넘는 손패입니다.
이 값만으로 등수를 예측할 수는 없다는 점은 분명히 해 두겠습니다. 제출 기회는 내가 리드할 때만 오는 것이 아니라 남이 연 트릭에 얹을 때도 오기 때문입니다. 흩어진 손패라도 남들이 계속 1~2장짜리 트릭을 열어 주면 잘 빠집니다. 이 값은 손패의 난이도 지표 이지 승패 예측치가 아닙니다.
봇의 손패 평가는 여기서 출발합니다.
type Group struct {
Rank Rank
Natural int
Jesters int
}
func (g Group) Count() int { return g.Natural + g.Jesters }
type Plan struct {
Groups []Group
MinPlays int // == len(Groups). 최소 제출 횟수의 하한
SafeLead float64 // 안전하게 리드할 수 있는 기대 묶음 수
Orphans int // 약한 rank 의 1장짜리 묶음 수
}
광대를 어디에 붙일 것인가#
광대는 제출 횟수를 줄이지는 않지만 묶음의 안전도를 바꿉니다. 앞의 표에서 봤듯 5장과 6장 사이에는 절벽이 있으므로, 광대 두 장으로 4장 묶음을 6장으로 만들면 그 묶음은 사실상 무적이 됩니다.
배치 경우의 수는 아주 작습니다. 서로 다른 rank 가 최대 12개이고 광대는 2장이므로, 가능한 배치는 100가지 미만입니다. 완전탐색으로 충분합니다.
// BestPlan 은 광대 배치를 완전탐색해 기대 안전 리드 수가 가장 큰 플랜을 고른다.
func BestPlan(hand []Card, l *Ledger) Plan {
base := groupsWithoutJesters(hand)
jokers := countJesters(hand)
best := Plan{SafeLead: -1}
for _, assign := range jesterAssignments(len(base), jokers) {
p := Plan{Groups: applyJesters(base, assign)}
p.MinPlays = len(p.Groups)
for _, g := range p.Groups {
p.SafeLead += 1 - l.BeatRisk(g.Rank, g.Count())
if g.Count() == 1 && g.Rank >= Rank(9) {
p.Orphans++
}
}
if p.SafeLead > best.SafeLead {
best = p
}
}
return best
}
여기서 짚어 둘 설계 원칙이 하나 있습니다. 플랜은 약속이 아니라 평가 도구입니다. 봇은 매 턴 플랜을 처음부터 다시 계산합니다(재계획). 세 트릭 전에 “광대는 11 묶음에 붙인다"고 정했더라도, 그 사이 11이 여러 장 소진되었다면 계산 결과가 바뀌어야 합니다. 플랜을 상태로 저장해 두고 따라가면 봇이 낡은 정보로 두게 됩니다.
고아 카드#
Orphans 는 약한 rank 의 1장짜리 묶음 입니다. 예를 들어 11이 딱 한 장 있는 경우입니다. 이 카드는 세 가지 이유로 짐입니다.
- 리드로 내면 거의 확실히 받힙니다. 1장짜리 트릭은 rank 1~10 어느 것으로도 받을 수 있습니다.
- 후공으로 쓰려면 1장짜리 트릭이 열려야 하는데, 그때 11보다 약한 rank 는 12뿐입니다.
- 광대를 붙여봐야 2장 묶음이 되어 여전히 약합니다.
반대로 강한 rank 의 1장짜리 묶음은 자산입니다. rank 1 한 장은 세상에 하나뿐이라 1장짜리 트릭에서 영구히 무적입니다. 같은 “싱글 카드"라도 rank 에 따라 가치의 부호가 정반대입니다. easy 봇의 weakerFirst 같은 단순 서열 정렬로는 이 구분이 불가능하고, 여기서 두 난이도의 손패 관리 실력이 갈립니다.
부록: 확률표 재현 스크립트#
본문의 두 표를 만든 스크립트입니다. 표준 라이브러리만 씁니다.
import random
from collections import Counter
def make_deck():
d = []
for r in range(1, 13):
d += [r] * r # rank r 은 r 장
return d + [13, 13] # 광대 2장
def can_beat(hand, lead_rank, n):
"""hand 가 (lead_rank, n장) 을 받을 수 있는가."""
j = hand.get(13, 0)
for rp in range(1, lead_rank):
c = hand.get(rp, 0)
if c >= 1 and c + min(j, n - 1) >= n:
return True
return False
def lead_risk(nplayers, n_lead, lead_rank=12, trials=60000, seed=20260730):
random.seed(seed)
base = make_deck()
total = len(base)
# 80장을 전부 딜한다. 앞쪽 몇 명이 1장 더 받는다.
sizes = [total // nplayers + (1 if i < total % nplayers else 0)
for i in range(nplayers)]
hit = 0
for _ in range(trials):
d = base[:]
for _ in range(n_lead):
d.remove(lead_rank) # 내가 쥔 리드 묶음
random.shuffle(d)
rest = d[sizes[0] - n_lead:] # 내 나머지 카드를 뺀 상대 몫
idx = 0
for k in range(1, nplayers):
h = Counter(rest[idx:idx + sizes[k]])
idx += sizes[k]
if can_beat(h, lead_rank, n_lead):
hit += 1
break
return hit / trials
def distinct_natural_ranks(hand_size, trials=300000, seed=20260730):
random.seed(seed)
d = make_deck()
tot = 0
for _ in range(trials):
random.shuffle(d)
tot += len({x for x in d[:hand_size] if x != 13}) # 광대는 제외
return tot / trials
def independence_gap(nplayers, n_lead, lead_rank=12, trials=80000, seed=20260730):
"""Part 3 의 '실제 vs 좌석별 독립 곱셈' 비교표를 만든다.
같은 시행에서 (a) 아무도 못 받는 비율과 (b) 좌석별 marginal 을 함께 모은다.
(b) 들을 곱한 값이 좌석 간 독립을 가정했을 때의 리드권 확보 확률이다.
"""
random.seed(seed)
base = make_deck()
total = len(base)
sizes = [total // nplayers + (1 if i < total % nplayers else 0)
for i in range(nplayers)]
none_beat = 0
marginal = [0] * (nplayers - 1) # 좌석별로 "받을 수 있었던" 횟수
for _ in range(trials):
d = base[:]
for _ in range(n_lead):
d.remove(lead_rank)
random.shuffle(d)
rest = d[sizes[0] - n_lead:]
idx, any_beat = 0, False
for k in range(1, nplayers):
h = Counter(rest[idx:idx + sizes[k]])
idx += sizes[k]
if can_beat(h, lead_rank, n_lead):
marginal[k - 1] += 1
any_beat = True
if not any_beat:
none_beat += 1
actual = none_beat / trials
independent = 1.0
for m in marginal:
independent *= 1 - m / trials
return actual, independent
lead_risk(8, 6) 은 8명 게임에서 12 x 6 리드가 받힐 수 있는 확률을, distinct_natural_ranks(10) 은 10장 손패의 평균 자연 rank 수를 돌려줍니다. 시드를 고정했으므로 같은 값이 재현됩니다.
마치며#
Part 2 에서는 hard 봇이 무엇을 아는가 를 다뤘습니다. 정리하면 세 가지입니다.
- 카운팅은 오차가 0입니다. 달무티는 덱 구성이 완전히 알려져 있고 80장을 전부 나눠주므로, “내가 못 본 카드"가 곧 “상대 손패에 있는 카드"입니다. 추정이 아니라 확정입니다.
- 패스는 정보이되 확정 정보가 아닙니다. 전략적 패스를 권장하는 봇이 상대의 패스를 확정 배제로 쓰면 자기모순이고, 상대가 강할수록 장부가 더 심하게 오염됩니다. 확률적 증거로 다루고, “낼 수 있는데도 참을 확률"을 파라미터로 노출해야 합니다.
- 묶음의 장수가 강약보다 중요합니다. 광대가 2장뿐이라는 사실 하나에서
r' ≥ n - 2라는 구조적 상한이 나오고, 8명 게임에서 5장 리드와 6장 리드 사이에는 응수 가능한 사람이 존재할 확률 19%와 2%의 절벽이 있습니다.
여기까지는 전부 판단의 재료 입니다. Part 3 에서는 이 재료로 실제 결정을 내립니다. 후보를 점수로 환산하는 평가 함수를 만들고, 낼 수 있어도 참는 전략적 패스를 구현하고, 세금과 혁명을 기대값으로 판단한 뒤, 만든 봇이 정말 세졌는지 재는 방법까지 다루겠습니다.