렉시오 CPU 플레이어 만들기 Part 2: 60비트 카운팅과 확실승수 판정
이 글은 Claude Opus 5 를 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
hard 봇이 지켜야 할 세 가지#
Part 1 에서 만든 easy 봇은 이번 턴에 합법적인 가장 싼 수를 계산할 뿐이었습니다. 이번 편부터 hard 봇을 설계합니다. 다만 시작하기 전에 지켜야 할 선을 못 박아 두겠습니다.
- 공정성. 봇은
GameView만 봅니다. 남의 손패는 그 구조체 어디에도 존재하지 않습니다. 봇이 강해지는 이유는 더 많이 보기 때문이 아니라, 같은 것을 보고 더 잘 세기 때문 이어야 합니다. - 결정성. 같은 입력에서는 같은 출력이 나와야 합니다. 그래야 봇 로직이 테스트 가능한 코드가 됩니다. 난수를 쓴다면 시드를 게임 상태에서 유도합니다.
- 실시간성. 봇의 판단은 사람이 기다릴 수 있는 시간 안에 끝나야 합니다. 이 제약이 뒤에서 “몬테카를로를 런타임에 돌리지 않는다"는 설계 결정으로 이어집니다.
이번 편은 hard 봇이 무엇을 아는가 를 다룹니다. 무엇을 결정하는가는 Part 3 과 Part 4 의 몫입니다.
이 글도 설계안입니다#
Part 1 과 마찬가지로 특정 제품의 구현 설명이 아니라 설계안이고, Go 코드는 논지를 보이기 위한 발췌입니다. 전제하는 규칙은 Part 1 의 전제표와 같습니다. 발행사 공식 룰, 5인, 60장, 인당 12장, 5라운드, 패스 후 재참여 허용, 유효 장수는 실제 장수 × 2^(2의 개수) 입니다.
용어도 Part 1 을 그대로 씁니다. 조합의 세기는 Key 라는 정수 하나로 환원되어 있고, 이 값은 (카테고리, 서열에 기여하는 숫자 벡터, 문양) 을 25비트에 담은 것입니다.
1. 미공개 타일: 한 줄로 복원되는 정보 모델#
60장이 전부 유일하다는 것의 의미#
달무티 봇에서는 카운팅이 “숫자별로 몇 장이 남았는가"였습니다. 12가 열두 장 있으니 그중 다섯 장이 나갔으면 일곱 장이 남았다는 식입니다. 렉시오는 다릅니다. 60장이 전부 서로 다른 타일 이므로, 카운팅은 집계가 아니라 집합 연산 이 됩니다.
Part 1 에서 타일을 0~59 정수로 잡아 둔 덕에 이 연산이 한 줄로 끝납니다.
// Unseen 은 내가 보지 못한 타일의 집합이다.
// 전체에서 내 손패와 이미 나온 타일을 빼면 그것이 전부다.
func (v GameView) Unseen() Set {
return AllTiles &^ (v.Hand | v.Played)
}
AllTiles 마스킹을 빼먹으면 안 됩니다. ^(hand | played) 라고 쓰면 uint64 의 60~63번 비트가 켜져 존재하지 않는 타일 네 장이 섞여 듭니다.
이 한 줄이 렉시오 봇의 정보 모델 전부입니다. 추정도, 근사도 없습니다. 미공개 집합은 언제나 정확합니다.
여기서 나오는 불변식이 하나 있는데, 룰 엔진 테스트에 그대로 쓰기 좋습니다.
// 미공개 타일 수는 남들의 남은 장수 합과 정확히 같아야 한다.
func (v GameView) checkInvariant() error {
sum := 0
for _, s := range v.Seats {
if s.Seat != v.Me {
sum += s.TileCount
}
}
if v.Unseen().Len() != sum {
return fmt.Errorf("미공개 %d장 != 남은 장수 합 %d장", v.Unseen().Len(), sum)
}
return nil
}
이 검사가 깨지면 카운팅 버그가 아니라 룰 엔진의 타일 회계 버그 입니다. 타일이 복제되었거나 증발한 것이므로 즉시 잡아야 합니다.
좌석별로는 알 수 없다#
다만 미공개 집합이 정확하다는 것과 “누가 무엇을 갖고 있는지 안다” 는 것은 전혀 다릅니다. 미공개 48장이 상대 네 명에게 12장씩 나뉘어 있다는 것만 알 뿐, 어떤 분할인지는 모릅니다. 가능한 분할의 수는
48! / (12!)^4 ≈ 2.36 × 10^26
입니다. 전부 세는 것은 애초에 불가능합니다. 이 사실이 다음 절의 판정을 구조적 영역 과 확률적 영역 으로 가르는 이유가 됩니다.
2. 확실승수 판정: “이 수는 아무도 못 받는다”#
봇이 가장 자주 물어야 하는 질문은 이것입니다. “내가 지금 이 수를 내면, 이 트릭을 확실히 먹는가?” 이 질문에 예라고 답할 수 있으면 리드권이 보장되고, 리드권이 보장되면 다음 트릭의 모양을 내가 정할 수 있습니다.
구조적 판정 — 확률이 필요 없는 영역#
먼저 미공개 집합 전체를 한 사람의 손패인 것처럼 취급해 봅시다. 그 가상의 손패조차 내 수를 못 받는다면, 실제로는 여러 명에게 흩어져 있으니 더더욱 못 받습니다. 즉 이 판정은 보수적이지만 확실 합니다.
싱글이 가장 극적입니다.
// singleIsNuts 는 이 싱글을 아무도 받을 수 없는지 판정한다.
// t 보다 강한 타일이 미공개 집합에 하나도 없으면 참이다.
func singleIsNuts(unseen Set, t Tile) bool {
return unseen>>(t+1) == 0
}
시프트 한 번입니다. Part 1 에서 타일 값을 곧 서열로 잡아 둔 것이 여기서 값을 합니다. 페어와 트리플도 거의 같습니다.
// groupIsNuts 는 같은 숫자 count 장짜리 조합의 확실승수 여부를 판정한다.
func groupIsNuts(unseen Set, count int, myKey Key) bool {
for ro := uint8(14); ; ro-- { // 강한 숫자부터 본다
group := unseen & rankMaskByOrder(ro)
if n := group.Len(); n >= count {
// 이 숫자로 만들 수 있는 가장 강한 조합의 Key
top, _ := group.Highest()
if makeKey(0, []uint8{ro}, top.Suit()) > myKey {
return false
}
}
if ro == 0 {
return true
}
}
}
5장 메이드는 카테고리 서열이 얽혀 조금 더 깁니다. 원리는 같습니다. 미공개 집합으로 만들 수 있는 가장 강한 조합의 Key 가 내 Key 보다 작으면 확실승수입니다.
// meldIsNuts 는 5장 메이드의 확실승수 여부를 판정한다.
func meldIsNuts(unseen Set, m Meld) bool {
for cat := m.Cat(); cat <= CatStraightFlush; cat++ {
best, ok := bestKey(unseen, cat) // 미공개 집합으로 만들 수 있는 최고 Key
if !ok {
continue // 그 카테고리는 아예 못 만든다
}
if best > m.Key {
return false
}
}
return true
}
내 카테고리보다 낮은 것은 볼 필요가 없으므로 cat 을 내 카테고리에서 시작합니다. bestKey 는 카테고리별로 다르지만 전부 단순한 스캔입니다.
- 플러시: 문양별 비트를 세어 5장 이상인 문양에서 가장 강한 다섯 장을 고릅니다. 공식 룰이 다섯 장의 숫자를 모두 보므로, 가장 강한 다섯 장이 곧 최고
Key입니다. - 스트레이트: 12종 시퀀스를 강한 순서대로 훑으며 다섯 숫자가 모두 남아 있는 첫 시퀀스를 찾고, 최고 수 자리에서 가장 높은 문양을 고릅니다.
- 포카드: 숫자별로 4장이 통째로 남았는지 봅니다.
- 풀하우스: 3장 이상 남은 숫자 중 최고를 찾되, 붙일 페어가 남아 있는지 함께 확인합니다.
미공개 집합이 48장이든 5장이든 비용은 상수에 가깝습니다.
“존재한다"와 “한 사람이 갖고 있다"는 다르다#
문제는 구조적 판정이 지나치게 보수적이라는 데 있습니다. 미공개 48장을 한 손으로 보면 거의 모든 조합이 만들어집니다. 그러니 라운드 초반에 구조적 판정만 쓰면 봇은 “내 수는 거의 다 받힌다” 는 결론만 반복합니다.
실제로 필요한 질문은 이것입니다.
미공개 집합에 나를 이기는 조합이 존재하는가? (구조적) → 그 조합이 한 사람의 손 안에 모여 있는가? (확률적)
5장 메이드는 다섯 장이 같은 손 에 있어야 합니다. 페어는 두 장이 같은 손에 있어야 합니다. 싱글만이 예외입니다. 싱글은 한 장이므로 존재하기만 하면 누군가는 갖고 있습니다. 그래서 싱글의 구조적 판정은 보수적이지 않고 정확합니다.
| 조합 | 구조적 판정의 성격 |
|---|---|
| 싱글 | 정확하다. 존재 = 누군가 보유 |
| 페어·트리플 | 약간 보수적. 2~3장이 흩어질 수 있다 |
| 5장 메이드 | 매우 보수적. 다섯 장이 한 손에 모여야 한다 |
초기 딜 집계로 감을 잡는다#
그렇다면 “한 손에 모여 있을 확률"은 얼마나 될까요. 아래는 라운드가 막 시작된 시점 을 몬테카를로로 뽑은 집계입니다. 미공개 48장이 상대 네 명에게 12장씩 있는 상황에서, 내가 가진 그 카테고리의 최고 조합을 아무도 받지 못할 확률 입니다.
시드 20260731, 딜 20,000회. 괄호는 95% Wilson 신뢰구간이고, “보유 표본"은 그 조합을 실제로 가진 딜의 수입니다.
| 내가 낸 조합 | 보유 표본 | 아무도 못 받을 확률 |
|---|---|---|
| 싱글 (내 최강) | 20,000 | 19.8% [19.2, 20.3] |
| 페어 (내 최강) | 19,899 | 19.5% [19.0, 20.1] |
| 트리플 (내 최강) | 6,237 | 52.8% [51.6, 54.1] |
| 스트레이트 | 7,054 | 0.7% [0.6, 0.9] |
| 플러시 | 10,255 | 9.6% [9.1, 10.2] |
| 풀하우스 | 5,782 | 51.1% [49.9, 52.4] |
| 포카드+1 | 309 | 95.1% [92.1, 97.0] |
| 스트레이트 플러시 | 105 | 95.2% [89.3, 97.9] |
이 표에서 읽어야 할 것이 넷입니다.
첫째, 싱글의 19.8% 는 검산이 됩니다. 내 최강 싱글이 확실승수라는 것은 곧 내가 해 2를 쥐고 있다 는 뜻입니다. 12장을 60장에서 뽑으니 이론값은 정확히 12/60 = 20% 이고, 시뮬레이션 값의 신뢰구간이 그 값을 품습니다. 모델이 제대로 굴러간다는 신호입니다.
둘째, 스트레이트와 플러시의 격차가 큽니다. 0.7% 대 9.6% 로 신뢰구간이 전혀 겹치지 않습니다. 스트레이트는 5장 메이드 중 가장 낮은 카테고리라 플러시·풀하우스·포카드+1·스트레이트 플러시 아무것에나 받힙니다. 클라이밍 게임 전략 글에서 “스트레이트와 플러시를 동시에 만들 수 있으면 보통 플러시를 남기고 스트레이트를 깬다"고 적었는데, 그 감각과 방향이 맞습니다.
다만 이 두 줄이 말해 주는 것의 범위를 정확히 해 두겠습니다. 이 값은 “내가 가진 그 카테고리의 최강 조합” 끼리의 비교입니다. “임의의 스트레이트가 임의의 플러시보다 열네 배 위험하다"는 뜻이 아닙니다. 실제 후보의 안전도는 그 후보의 Key 가 얼마나 높은지에 따라 크게 달라집니다.
셋째, 스트레이트 플러시조차 100% 가 아닙니다. 95.2% 이고, 표본이 105건뿐이라 신뢰구간이 [89.3, 97.9] 로 넓습니다. 공식 룰에서는 스트레이트 플러시끼리도 숫자 구성으로 비교되므로, 내 것보다 강한 스트레이트 플러시가 남의 손에 있을 수 있습니다. 표본이 작은 칸의 값을 “확정"으로 읽으면 안 됩니다.
넷째, 트리플과 풀하우스가 절반쯤 안전합니다. 각각 52.8%, 51.1% 입니다. 둘 다 “같은 숫자를 세 장 모아야 한다"는 조건 때문에 상대가 갖추기 어려운 조합입니다.
이 표를 런타임 추정기로 쓰면 안 된다#
여기서 중요한 경계를 그어야 합니다. 위 표는 설계 감각을 잡기 위한 초기 딜 집계이지, 봇이 매 턴 참조할 추정기가 아닙니다.
이유는 세 가지입니다.
- 조건이 다릅니다. 표는 “내가 가진 그 카테고리의 최강 조합"에 대한 값입니다. 봇이 실제로 평가하는 것은 임의의 후보이고, 두 번째로 강한 플러시의 안전도는 최강 플러시와 전혀 다릅니다.
- 시점이 다릅니다. 표는 미공개 48장 시점의 값입니다. 라운드 후반에 미공개가 20장으로 줄면 같은 조합의 안전도가 크게 올라갑니다.
- 좌석 상태가 다릅니다. 상대 넷이 12장씩 든 상황과, 한 명이 2장만 남긴 상황은 “다섯 장이 한 손에 모일 확률"이 근본적으로 다릅니다.
그래서 런타임 추정기는 별도의 표 로 만들어야 합니다. 색인에 들어가야 할 것은 최소한 이렇습니다.
// SafetyKey 는 오프라인 확률표의 색인이다.
// 초기 딜 집계와 달리, 후보 자체의 세기와 현재 국면이 모두 들어간다.
type SafetyKey struct {
Cat Category // 후보의 카테고리
KeyBucket uint8 // 후보 Key 를 그 카테고리 안에서 몇 분위인지로 이산화
UnseenLen uint8 // 미공개 장수 (구간으로 뭉갠다)
MaxSeatLen uint8 // 상대 중 가장 많이 든 좌석의 장수
}
표를 만드는 절차는 단순합니다. 오프라인에서 국면을 대량으로 생성해 각 칸의 빈도를 세고, 그 결과를 상수 배열로 구워 바이너리에 넣습니다. 런타임에는 조회만 합니다. 봇이 사람을 기다리게 하지 않으면서 상황에 맞는 추정값을 쓰는 방법이 이것입니다.
그리고 표가 아무리 정교해도 확실승수 판정에는 쓰지 않습니다. 확실승수는 확정이어야 하고, 확정의 근거는 앞 절의 구조적 판정뿐입니다.
판정 파이프라인#
정리하면 이렇게 됩니다.
flowchart TD
A["후보 조합 m"] --> B["구조적 판정<br/>미공개 집합을 한 손으로 보고 검사"]
B --> C{"그래도 못 받는가?"}
C -- 예 --> D["확실승수 확정<br/>(확률 계산 불필요)"]
C -- 아니오 --> E{"싱글인가?"}
E -- 예 --> F["받힌다 확정<br/>(싱글은 구조적 판정이 정확)"]
E -- 아니오 --> G["오프라인 표 조회<br/>SafetyKey 로 색인"]
G --> H["받힐 확률 추정값"]
style A fill:#FFD700,color:#000000
style D fill:#90EE90,color:#000000
style F fill:#FFB6C1,color:#000000
style H fill:#87CEEB,color:#000000
왼쪽 두 가지가 렉시오 봇의 강점입니다. 달무티에서는 확실승수 판정이 “이 숫자보다 강한 카드가 몇 장 남았고, 그게 한 손에 모일 확률은…” 하는 계산이었는데, 렉시오는 상당 부분이 시프트와 마스크 몇 번으로 끝나는 확정 판정 입니다.
3. 패스는 정보다#
렉시오의 패스가 유난히 말이 많은 이유#
미공개 집합은 정확하지만 좌석별 배분은 모릅니다. 그 배분을 좁혀 주는 유일한 공개 정보가 패스 입니다.
렉시오에서 패스의 정보량은 트릭의 조합 형태에 따라 크게 다릅니다.
| 트릭 형태 | 패스가 시사하는 것 | 정보량 |
|---|---|---|
| 싱글 판 | 그 좌석의 모든 타일 이 테이블보다 약하다 | 매우 큼 |
| 5장 판 | 그 좌석이 테이블 Key 를 넘는 메이드를 못 만든다 |
큼 |
| 페어·트리플 판 | 그 좌석이 더 강한 페어(트리플)를 못 만든다 | 작음 |
싱글 판의 패스가 압도적입니다. 싱글 트릭에서는 손에 든 모든 타일 이 후보이므로, 패스했다는 것은 그 좌석의 최강 타일이 테이블보다 약하다는 뜻입니다. 즉 손패 전체에 상한이 걸립니다.
그리고 이 상한은 우리 표현에서 마스크 하나로 표현됩니다.
// SeatModel 은 좌석 하나에 대한 봇의 추정이다.
type SeatModel struct {
Seat SeatID
Count int // 남은 장수. 공개 정보다
Possible Set // 이 좌석이 가질 수 있는 타일의 집합
}
// ObserveSinglePass 는 싱글 판에서의 패스를 제약으로 반영한다.
// 테이블 타일보다 강한 타일은 이 좌석에서 전부 지운다.
func (sm *SeatModel) ObserveSinglePass(tableTile Tile) {
sm.Possible &= Set(1)<<tableTile - 1
}
Possible 은 미공개 집합에서 출발해 관측이 쌓일수록 좁아집니다. 다른 좌석이 타일을 낼 때마다 그 타일도 지워 나갑니다. Possible 의 크기가 Count 에 가까워질수록 추정이 정밀해지고, 라운드 후반에는 거의 특정되기도 합니다.
이 값을 “확정"이라 부르지는 않겠습니다#
주의할 점이 있습니다. 렉시오는 패스 후 재참여를 허용 하므로, 낼 수 있는데도 참는 전략적 패스가 존재합니다. 즉 위 제약은 논리적 필연이 아니라 “보통은 그렇다” 에 가깝습니다.
그래서 제약은 반드시 자기 수정이 가능해야 합니다.
// ObservePlay 는 제출을 반영한다. 제약과 모순되면 그 제약을 버린다.
func (sm *SeatModel) ObservePlay(m Meld, unseen Set) {
if !sm.Possible.HasAll(m.Tiles) {
// 상한을 걸어 두었는데 그보다 강한 것을 냈다.
// 앞선 패스가 전략적이었다는 뜻이므로 제약을 초기화한다.
sm.Possible = unseen
}
for _, t := range m.Tiles {
sm.Possible = sm.Possible.Remove(t)
}
sm.Count -= m.Count
}
모순이 발견되면 제약을 버리는 이 처리가 없으면, 한 번 전략적 패스를 당한 봇은 그 좌석에 대해 끝까지 틀린 모델 을 들고 게임을 마칩니다. 그리고 틀린 모델은 모델이 없는 것보다 나쁩니다.
같은 이유로 이 제약은 확실승수 판정에 섞지 않습니다. 이 경계를 코드에서도 지키는 편이 좋습니다.
// 확실승수 판정에는 언제나 Unseen 을 넘긴다. Possible 을 넘기면 안 된다.
nuts := meldIsNuts(v.Unseen(), cand)
// 위협도 추정에는 Possible 을 쓴다.
risk := threatLevel(seatModels, cand)
좌석별 위협도#
Possible 과 Count 를 합치면 좌석별 위협도를 매길 수 있습니다. 정밀한 확률까지 갈 필요는 없고, 봇이 다음 세 가지를 구분할 수 있으면 충분합니다.
- 못 받는다:
Possible로 그 카테고리의 조합을 아예 못 만든다. - 받을 수도 있다: 만들 수는 있지만 장수가 빠듯하다.
- 거의 확실히 받는다: 여유 있게 만들 수 있고 장수도 많다.
여기서 중요한 것이 하나 더 있습니다. 남은 장수가 적은 좌석은 위협이 아니라 위험입니다. 두 장 남은 상대는 나를 받아 낼 조합은 거의 없지만, 다음 리드권을 잡으면 그대로 라운드를 끝냅니다. 그러니 좌석 평가에는 “받아 낼 능력"과 “나갈 임박도"를 따로 두어야 합니다.
type SeatThreat struct {
BeatChance int // 내 수를 받아 낼 재료가 있을 추정 확률 (0..100)
MinTurns int // 남은 장수로 계산한 최소 제출 횟수 = ceil(TileCount/5)
}
MinTurns 를 ceil(TileCount/5) 로 두는 것이 중요합니다. 남은 장수를 그대로 쓰면 “여섯 장 남았으니 여섯 턴 걸리겠지"라고 안심하게 되는데, 그 여섯 장이 메이드 하나와 싱글 하나면 두 턴에 끝납니다. 위협은 하한으로 재야 합니다. 이 값이 Part 4 의 엔드게임 판단에 그대로 쓰입니다.
4. hard 봇의 정보 계층 정리#
지금까지 만든 것을 한 장으로 정리하면 이렇습니다.
| 계층 | 내용 | 정확도 | 비용 |
|---|---|---|---|
| 미공개 집합 | AllTiles &^ (Hand | Played) |
정확 | 연산 1회 |
| 구조적 확실승수 | 미공개를 한 손으로 보고 판정 | 확정 (보수적) | 상수 |
좌석별 Possible |
패스·제출 관측으로 좁힌 집합 | 추정 (자기 수정) | 관측당 마스크 연산 |
| 받힐 확률 | SafetyKey 로 색인한 오프라인 표 |
근사 | 조회 1회 |
Part 1 의 easy 봇은 이 표의 어느 행도 쓰지 않았습니다. hard 봇은 네 행을 전부 씁니다. 다만 아직 결정 은 하나도 하지 않았습니다. 지금까지 만든 것은 전부 재료입니다.
다음 편에서는 시선을 밖에서 안으로 돌립니다. 상대가 무엇을 가졌는지가 아니라 내 손패가 몇 번에 비는지 를 계산하겠습니다.
부록: 확률표 재현 스크립트#
본문의 확실승수 표는 아래 스크립트로 재현할 수 있습니다. 표준 라이브러리만 씁니다. 손패 12장의 5장 조합이 C(12,5) = 792 가지뿐이라 전수 조사로 돌려도 됩니다. 2만 딜 기준으로 노트북에서 십여 분이면 끝납니다.
"""렉시오 5인 게임 — 조합 보유율과 확실승수 확률 (디다노니아 공식 룰)."""
import math
import random
from collections import defaultdict
from itertools import combinations
SEED, N = 20260731, 20000
# 타일 ID = rank_order * 4 + suit (0..59). 값이 곧 서열이다.
def rank_order(rank): # 3->0, ..., 15->12, 1->13, 2->14
return rank - 3 if rank >= 3 else rank + 12
def ro_of(t): return t >> 2
def suit_of(t): return t & 3
def rank_of(t):
ro = t >> 2
return ro + 3 if ro <= 12 else ro - 12
DECK = list(range(60))
# 유효 스트레이트 12종: 1-2-3-4-5 부터 12-13-14-15-1 까지. 2 는 이어지지 않는다.
STRAIGHTS = [frozenset(((s - 1 + i) % 15) + 1 for i in range(5)) for s in range(1, 13)]
STRAIGHT, FLUSH, FULL, FOUR, SF = range(5)
NAMES = {STRAIGHT: "스트레이트", FLUSH: "플러시", FULL: "풀하우스",
FOUR: "포카드+1", SF: "스트레이트 플러시"}
def make_key(cat, ros, suit):
"""카테고리 3비트 | 숫자벡터 20비트 | 문양 2비트."""
v = 0
for r in sorted(ros, reverse=True):
v = (v << 4) | r
v <<= 4 * (5 - len(ros))
return (cat << 22) | (v << 2) | suit
def classify(tiles):
"""조합을 (장수, Key) 로 환원한다. 무효면 None."""
n = len(tiles)
if n == 1:
t = tiles[0]
return (1, make_key(0, [ro_of(t)], suit_of(t)))
if n in (2, 3):
if len({ro_of(t) for t in tiles}) != 1:
return None
top = max(tiles)
return (n, make_key(0, [ro_of(top)], suit_of(top)))
if n != 5:
return None
by_ro = defaultdict(list)
for t in tiles:
by_ro[ro_of(t)].append(t)
counts = sorted((len(v) for v in by_ro.values()), reverse=True)
if counts[0] == 4:
quad = [v for v in by_ro.values() if len(v) == 4][0]
return (5, make_key(FOUR, [ro_of(quad[0])], suit_of(max(quad))))
if counts[0] == 3 and counts[1] == 2:
tri = [v for v in by_ro.values() if len(v) == 3][0]
return (5, make_key(FULL, [ro_of(tri[0])], suit_of(max(tri))))
if counts[0] != 1:
return None
ros = [ro_of(t) for t in tiles]
top = max(tiles)
is_straight = frozenset(rank_of(t) for t in tiles) in STRAIGHTS
is_flush = len({suit_of(t) for t in tiles}) == 1
if is_straight and is_flush:
return (5, make_key(SF, ros, suit_of(top)))
if is_straight:
return (5, make_key(STRAIGHT, ros, suit_of(top)))
if is_flush:
return (5, make_key(FLUSH, ros, suit_of(top)))
return None
def best_keys(hand):
"""카테고리별 최고 Key. 없으면 None."""
out = {c: None for c in NAMES}
for combo in combinations(hand, 5):
c = classify(list(combo))
if c is None:
continue
cat = c[1] >> 22
if out[cat] is None or c[1] > out[cat]:
out[cat] = c[1]
by_ro = defaultdict(list)
for t in hand:
by_ro[ro_of(t)].append(t)
out["single"] = classify([max(hand)])[1]
for name, need in (("pair", 2), ("triple", 3)):
best = None
for ts in by_ro.values():
if len(ts) >= need:
k = classify(sorted(ts)[-need:])[1]
if best is None or k > best:
best = k
out[name] = best
return out
def beats(other, cat, key):
"""other 가 (cat, key) 5장 메이드를 받을 수 있는가."""
for c in NAMES:
k = other[c]
if k is not None and (c > cat or (c == cat and k > key)):
return True
return False
def wilson(k, n, z=1.96):
if n == 0:
return (0.0, 0.0)
p, d = k / n, 1 + z * z / n
c = p + z * z / (2 * n)
h = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
return ((c - h) / d, (c + h) / d)
def main():
rng = random.Random(SEED)
have, safe = defaultdict(int), defaultdict(int)
for _ in range(N):
d = DECK[:]
rng.shuffle(d)
hands = [sorted(d[i * 12:(i + 1) * 12]) for i in range(5)]
keys = [best_keys(h) for h in hands]
me, others = keys[0], keys[1:]
for cat in NAMES:
if me[cat] is None:
continue
have[cat] += 1
if not any(beats(o, cat, me[cat]) for o in others):
safe[cat] += 1
for nm in ("single", "pair", "triple"):
if me[nm] is None:
continue
have[nm] += 1
if not any(o[nm] is not None and o[nm] > me[nm] for o in others):
safe[nm] += 1
print(f"{'조합':<18}{'보유 표본':>10}{'확실승수 (95% CI)':>28}")
for k in ["single", "pair", "triple"] + list(NAMES):
label = NAMES.get(k, k)
n, s = have[k], safe[k]
lo, hi = wilson(s, n)
print(f"{label:<18}{n:>10}"
f"{f'{100*s/n:.1f}% [{100*lo:.1f}, {100*hi:.1f}]':>28}")
if __name__ == "__main__":
main()
마치며#
Part 2 에서는 hard 봇의 정보 계층을 만들었습니다. 정리하면 세 가지입니다.
- 미공개 집합은
uint64연산 한 번으로 정확히 복원됩니다. 렉시오는 60장이 전부 유일해서 카운팅에 오차가 없습니다. 부수적으로 룰 엔진의 타일 회계 버그를 잡는 불변식도 생깁니다. - 확실승수 판정은 확률이 필요 없는 영역이 넓습니다. 싱글은 시프트 한 번으로 정확히 판정되고, 페어·트리플·메이드도 마스크 스캔으로 확정적인 하한을 얻습니다. 확률이 필요한 것은 “다섯 장이 한 손에 모일까"뿐입니다. 그리고 그 확률은 초기 딜 집계가 아니라 국면을 색인에 넣은 별도의 표 로 다뤄야 합니다.
- 패스는 비트마스크 제약입니다. 특히 싱글 판의 패스는 좌석 손패 전체에 상한을 씌웁니다. 다만 재참여가 허용되는 게임이므로 제약은 반드시 자기 수정이 가능해야 하고, 확실승수 판정에는 절대 섞지 않습니다.
수치 중 하나만 기억한다면 이것이 좋겠습니다. 내가 가진 최강 스트레이트가 아무에게도 안 받힐 확률은 0.7%, 최강 플러시는 9.6% 입니다. 같은 5장인데 라운드 초반의 안전도가 자릿수만큼 다릅니다. 이 비대칭이 Part 3 의 분해 플랜과 Part 4 의 수 평가 양쪽에서 계속 등장합니다.
Part 3 에서는 상대가 아니라 내 손패를 봅니다. 12장을 유효 조합으로 나누는 최소 분할을 비트마스크 DP 로 구하고, 왜 탐욕법으로는 다섯 번에 한 번꼴로 최적을 놓치는지 보겠습니다.
References#
- 렉시오 공식 게임방법 (디다노니아)
- LEXIO (BoardGameGeek)
- Binomial proportion confidence interval — Wilson score interval (Wikipedia)
- 렉시오 CPU 플레이어 만들기 Part 1: 타일 하나를 정수 하나로
- 렉시오 CPU 플레이어 만들기 Part 3: 손패 분해 플랜
- 렉시오 CPU 플레이어 만들기 Part 4: 수 평가와 정산 기대값
- 달무티 CPU 플레이어 만들기 Part 2: hard 봇의 정보 모델과 확실승수 판정
- 대부호부터 렉시오까지: 클라이밍 카드게임의 세계와 전략