이 글은 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 장짜리 묶음을 만들려면, rank r' 카드 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 에서는 이 재료로 실제 결정을 내립니다. 후보를 점수로 환산하는 평가 함수를 만들고, 낼 수 있어도 참는 전략적 패스를 구현하고, 세금과 혁명을 기대값으로 판단한 뒤, 만든 봇이 정말 세졌는지 재는 방법까지 다루겠습니다.


References#