이 글은 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 4에 있습니다. 여기서 만드는 정보 모델은 대부분 그대로 살아남았지만, 이 재료들을 Part 3 에서 하나의 점수로 합치는 방식에는 결함이 있었습니다. 특히 아래 Orphans 지표는 사용 범위를 좁혀야 했습니다. 룰셋 전제는 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이 여러 장 소진되었다면 계산 결과가 바뀌어야 합니다. 플랜을 상태로 저장해 두고 따라가면 봇이 낡은 정보로 두게 됩니다.

한 줄을 더 붙여야 합니다. 평가 도구의 점수가 기본 배출 원칙을 자동으로 대체해서는 안 됩니다. 새 필드의 첫 제출은 “가장 약한 자연 rank 묶음을 통째로 낸다"는 guardrail 을 우선하고, 플랜 점수는 후공 후보 비교와 진단에 씁니다. 이 경계를 두지 않고 리드까지 점수식에 맡겼던 것이 최초 hard 봇의 최대 약세 원인이었습니다(Part 4).

고아 카드#

Orphans약한 rank 의 1장짜리 묶음 입니다. 예를 들어 11이 딱 한 장 있는 경우입니다. 이 카드는 세 가지 이유로 짐입니다.

  • 리드로 내면 거의 확실히 받힙니다. 1장짜리 트릭은 rank 1~10 어느 것으로도 받을 수 있습니다.
  • 후공으로 쓰려면 1장짜리 트릭이 열려야 하는데, 그때 11보다 약한 rank 는 12뿐입니다.
  • 광대를 붙여봐야 2장 묶음이 되어 여전히 약합니다.

반대로 강한 rank 의 1장짜리 묶음은 자산입니다. rank 1 한 장은 세상에 하나뿐이라 1장짜리 트릭에서 영구히 무적입니다. 같은 “싱글 카드"라도 rank 에 따라 가치의 부호가 정반대입니다. easy 봇의 weakerFirst 같은 단순 서열 정렬로는 이 구분이 불가능합니다.

Orphans 는 목적 함수가 아니라 설명 지표입니다#

지표를 정의하는 것과 그 지표를 최종 점수에 더하는 것은 다른 일입니다. Orphans 는 후자에서 사고를 냈으므로, 사용 범위를 처음부터 좁혀 두겠습니다.

  • 독립적인 목적 함수로 쓰지 않습니다. 설명 지표이거나, 제한된 tie-break 입니다.
  • 리드에서는 MinPlays 감소와 Orphans 감소를 동시에 보상하지 않습니다. 고아 카드를 하나 없애는 것이 곧 제출 횟수를 하나 줄이는 것이므로, 두 항을 더하면 같은 이득을 두 번 세게 됩니다. 그 결과 봇은 카드 두 장을 버리는 12 x 2 대신 한 장만 버리는 9 x 1 을 리드로 고릅니다.
  • 후공에서는 제한적으로 인정합니다. 제출 장수가 테이블에 고정돼 있어 후보 사이에 배출량 차이가 없으므로, 약한 고아 카드를 털어 내는 추가 가치로 쓸 수 있습니다.

이 구분은 사후에 붙인 것이 아니라 실제로 봇을 약하게 만들고 나서 얻은 것입니다. 자세한 반례와 수정 코드는 Part 4에 있습니다.


부록: 확률표 재현 스크립트#

본문의 두 표를 만든 스크립트입니다. 표준 라이브러리만 씁니다.

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

마지막으로 한 가지를 경계로 남겨 두겠습니다. 이 글의 정보 모델은 실제 구현과 검증에서 거의 그대로 살아남았지만, 그 사실이 봇의 강함을 보장하지는 않았습니다.

더 정확한 장부는 더 좋은 판단 재료일 뿐입니다. 여러 지표를 최종 점수로 합치는 방식이 잘못되면, hard 봇은 잘못된 결정을 easy 봇보다 더 일관되게 내립니다. easy 봇의 실수는 산발적이지만 hard 봇의 실수는 체계적입니다.

이 문장이 왜 필요했는지는 Part 4에서 측정치와 함께 다룹니다.


References#