렉시오 CPU 플레이어 만들기 Part 1: 타일 하나를 정수 하나로
이 글은 Claude Opus 5 를 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
사촌 게임, 전혀 다른 봇#
어제 글에서 달무티(The Great Dalmuti)의 CPU 플레이어를 세 편에 걸쳐 설계했습니다. 이번에는 같은 클라이밍 계열의 국산 타일 게임 렉시오(Lexio) 로 넘어갑니다.
두 게임은 클라이밍 게임을 정리한 글에서 봤듯 사촌 사이입니다. 더 센 조합으로 받아 올라가고, 손패를 먼저 비우는 쪽이 이깁니다. 그런데 봇을 짜 보면 알고리즘이 거의 겹치지 않습니다. 달무티 봇 코드를 렉시오로 옮기려 하면 재사용할 수 있는 것은 “공정성 경계를 타입으로 못 박는다” 같은 뼈대뿐이고, 판단 로직은 전부 새로 써야 합니다.
이유는 세 가지입니다.
- 달무티는 조합 공간이 손바닥만 합니다. 낼 수 있는 수가
(숫자, 광대 사용량)두 축으로 완전히 결정됩니다. 렉시오는 5장짜리 포커 족보가 들어와 조합 공간이 통째로 커집니다. - 달무티는 카드가 서로 구별되지 않습니다. 12가 일곱 장 있으면 어느 것을 내든 같습니다. 렉시오는 문양 때문에 60장 전부가 유일 하고, 그 유일성이 전순서를 만듭니다.
- 달무티의 목표는 등수입니다. 렉시오의 목표는 등수가 아니라 정산 금액 입니다. 이 차이가 봇의 목적 함수를 통째로 바꿉니다.
이 글은 네 편으로 나누어 씁니다.
- Part 1 (이 글): 60장을 정수 하나로 압축하는 표현을 잡고, 공식 룰의 비교 규칙을 정수 키 하나로 환원한 뒤, 결정적 규칙만으로 동작하는 easy 봇 을 만듭니다.
- Part 2: hard 봇이 무엇을 아는가. 60비트 카운팅, 확실승수 판정, 패스 로그의 정보량입니다.
- Part 3: hard 봇이 자기 손패를 어떻게 보는가. 비트마스크 DP 로 최소 제출 횟수를 구합니다.
- Part 4: hard 봇이 무엇을 결정하는가. 수 평가 함수, 전략적 패스, 정산 기대값, 그리고 검증입니다.
이 글의 전제#
네 편 모두 특정 제품을 설명하는 글이 아니라, 렉시오를 디지털 게임으로 만든다면 봇을 어떻게 설계할 것인가에 대한 설계안 입니다. 코드는 Go 로 쓰되 논지를 보이기 위한 발췌 이며, 그대로 컴파일되는 완성 패키지가 아닙니다.
규칙은 발행사 디다노니아의 공식 게임방법 문서 를 따릅니다. 봇 로직에 영향을 주는 항목을 먼저 못 박아 두겠습니다.
| 항목 | 이 글의 전제 |
|---|---|
| 인원 | 5인 |
| 타일 | 숫자 1~15 × 문양 4종 = 60장, 인당 12장 |
| 숫자 서열 | 3 < 4 < … < 15 < 1 < 2 |
| 문양 서열 | 구름 < 별 < 달 < 해 |
| 조합 | 1·2·3·5장. 4장은 낼 수 없음 |
| 메이드 서열 | 스트레이트 < 플러시 < 풀하우스 < 포카드+1 < 스트레이트 플러시 |
| 스트레이트 범위 | 연속 5개. 1은 15 다음으로 쓸 수 있으나 2는 이어지지 않음 |
| 패스 후 재참여 | 허용. 패스해도 자기 차례가 다시 오면 낼 수 있음 |
| 트릭 종료 | 나머지 전원이 패스하면 종료. 마지막에 낸 사람이 다음 리드 |
| 첫 리드 | 구름 3 보유자. 구름 3 을 첫 수에 낼 의무는 없음 |
| 정산 | 모든 쌍에 대해 유효 장수 차이를 주고받음 |
| 유효 장수 | 실제 장수 × 2^(손에 남은 2의 개수) |
| 라운드 수 | 5라운드 |
용어도 하나 정리하겠습니다. 한 번의 리드가 시작되어 나머지 전원이 패스할 때까지를 “트릭”, 타일을 새로 나눠서 정산할 때까지를 “라운드” 라고 부르겠습니다.
봇 설계에 필요한 만큼의 렉시오 규칙#
봇 로직에 직접 영향을 주는 규칙만 추려 두겠습니다. 특히 비교 규칙 을 정확히 옮기는 것이 이 글의 절반입니다.
- 타일: 숫자 1~15가 구름·별·달·해 네 문양으로 각각 존재해 60장입니다. 5인 게임에서 전부 사용하고 인당 12장을 받습니다.
- 조합: 1장·2장(페어)·3장(트리플)·5장(메이드)만 낼 수 있습니다. 4장짜리는 존재하지 않습니다. 같은 숫자 4개를 모았더라도 아무 타일 1개를 얹어 5장으로 만들어야 냅니다.
- 제출: 후공은 정확히 같은 장수 로, 더 강한 조합 을 내야 합니다. 못 내거나 안 내면 패스입니다.
- 패스와 트릭 종료: 패스해도 자기 차례가 다시 오면 낼 수 있습니다. 나머지 전원이 패스하면 트릭이 끝나고, 마지막에 낸 사람이 다음 트릭을 리드합니다.
- 정산: 누군가 타일을 다 털면 라운드가 즉시 끝나고, 모든 플레이어 쌍이 유효 장수 차이만큼 주고받습니다.
조합별 비교 규칙은 표로 정리하는 편이 낫습니다.
| 조합 | 무엇으로 비교하는가 |
|---|---|
| 싱글 | 숫자 → 같으면 문양 |
| 페어 | 숫자 → 같으면 더 높은 문양을 포함한 쪽 |
| 트리플 | 숫자 |
| 스트레이트 | 숫자 구성 → 같으면 가장 높은 수의 문양 |
| 플러시 | 가장 높은 숫자 → 같으면 그다음 높은 숫자 → 다섯 개가 모두 같으면 문양 |
| 풀하우스 | 트리플 부분의 숫자만. 페어 부분은 보지 않는다 |
| 포카드+1 | 포카드 부분의 숫자만. 얹은 1장은 보지 않는다 |
| 스트레이트 플러시 | 숫자 구성 → 같으면 문양 |
여기서 봇 설계에 가장 큰 영향을 주는 두 줄은 플러시와 스트레이트 입니다. 이 둘은 최고 타일 한 장으로 판정이 끝나지 않고 여러 숫자를 순서대로 비교합니다. 구현에서 가장 자주 틀리는 지점이기도 합니다.
설계의 뼈대#
1. 타일 하나를 정수 하나로#
렉시오 구현에서 가장 먼저 정할 것은 타일의 표현입니다. 여기서 좋은 선택을 하면 뒤에 나올 모든 코드가 짧아지고, 나쁜 선택을 하면 서열 비교 함수가 코드베이스 전체에 흩어집니다.
핵심 관찰은 이것입니다. 렉시오의 숫자 서열은 회전된 순서일 뿐이고, 문양 서열은 그 아래 붙는 2비트입니다. 그러니 둘을 하나의 정수로 합칠 수 있습니다.
// Tile 은 0..59 의 정수다. 값이 곧 게임의 서열이다.
// t1 < t2 라는 비교가 그대로 "t1 이 t2 보다 약하다" 를 뜻한다.
type Tile uint8
type Rank uint8 // 1..15
type Suit uint8 // 0=구름, 1=별, 2=달, 3=해
// rankOrder 는 게임의 숫자 서열을 0..14 로 정규화한다.
// 3 이 가장 약해 0, 15 가 12, 1 이 13, 2 가 가장 강해 14 다.
func rankOrder(r Rank) uint8 {
if r >= 3 {
return uint8(r) - 3
}
return uint8(r) + 12
}
func NewTile(r Rank, s Suit) Tile { return Tile(rankOrder(r)*4 + uint8(s)) }
func (t Tile) Suit() Suit { return Suit(t & 3) }
func (t Tile) Order() uint8 { return uint8(t) >> 2 } // 정규화된 숫자 서열
func (t Tile) Rank() Rank {
ro := t.Order()
if ro <= 12 {
return Rank(ro + 3)
}
return Rank(ro - 12)
}
이 표현이 주는 것이 셋입니다.
첫째, 서열 비교가 < 하나로 끝납니다. 숫자를 회전시키고 문양을 tie-break 하는 로직이 rankOrder 안에 한 번만 존재합니다. 달무티 봇에서는 동률 후보를 끊기 위해 Card.ID 라는 별도 필드를 두어야 했는데, 렉시오는 타일 자체가 ID 이자 서열 이라 그 장치가 필요 없습니다.
둘째, 그대로 비트마스크 인덱스가 됩니다. 0~59 이므로 uint64 하나에 60장 전체가 들어갑니다. 손패도, 이미 나온 타일도, 미공개 타일도 전부 같은 타입입니다.
// Set 은 타일 집합이다. 손패든, 나온 타일이든, 미공개 타일이든 같은 타입을 쓴다.
type Set uint64
const AllTiles Set = 1<<60 - 1 // 60장 전체. 상위 4비트는 절대 켜지 않는다
func (s Set) Has(t Tile) bool { return s&(1<<t) != 0 }
func (s Set) Add(t Tile) Set { return s | 1<<t }
func (s Set) Sub(o Set) Set { return s &^ o }
func (s Set) Len() int { return bits.OnesCount64(uint64(s)) }
// Highest 는 집합에서 가장 강한 타일을 준다.
// 빈 집합에서는 의미 있는 답이 없으므로 ok 로 구분한다.
func (s Set) Highest() (Tile, bool) {
if s == 0 {
return 0, false
}
return Tile(63 - bits.LeadingZeros64(uint64(s))), true
}
AllTiles 를 상수로 두는 이유가 있습니다. 미공개 타일을 구할 때 ^(hand | played) 라고 쓰면 uint64 의 60~63번 비트까지 켜져 존재하지 않는 타일 네 장이 생깁니다. 반드시 AllTiles &^ (hand | played) 로 마스킹해야 합니다. Len() 이 4씩 부풀어도 게임은 굴러가므로 눈에 잘 띄지 않는 종류의 버그입니다.
Highest 가 (Tile, bool) 을 돌려주는 것도 같은 성격입니다. 빈 집합에 LeadingZeros64 를 먹이면 64가 나와 Tile(-1), 즉 255가 됩니다. 이런 값은 비교에서 조용히 최강 타일처럼 행동합니다.
셋째, 규칙이 표현과 맞아떨어집니다. 첫 리드를 잡는 구름 3 은 rankOrder(3)*4 + 0 = 0, 즉 타일 ID 0 입니다. 가장 강한 해 2 는 14*4 + 3 = 59 입니다.
2. 조합 하나를 정수 하나로#
이제 조합입니다. 앞의 비교 규칙 표를 다시 보면, 서로 다른 여덟 가지 규칙처럼 보이지만 실은 하나의 규칙 입니다.
조합의 세기는 ① 카테고리, ② 서열에 기여하는 숫자들을 강한 것부터 늘어놓은 목록, ③ 마지막 tie-break 문양 으로 결정됩니다.
이 셋을 순서대로 이어 붙이면 정수 하나가 됩니다. 숫자 서열은 0~14 이므로 4비트, 최대 다섯 개면 20비트, 문양 2비트, 카테고리 3비트. 전부 합쳐 25비트 라 uint32 한 칸에 들어갑니다.
type Category uint8
const (
CatStraight Category = iota // 스트레이트
CatFlush // 플러시
CatFullHouse // 풀하우스
CatFourPlusOne // 포카드 + 1
CatStraightFlush // 스트레이트 플러시
)
// Key 는 조합의 세기를 정수 하나로 환원한다. 크면 강하다.
//
// 비트 24..22 카테고리 (5장 조합에서만 의미가 있다)
// 비트 21..2 서열에 기여하는 숫자들을 강한 것부터 늘어놓은 니블 벡터
// 비트 1..0 마지막 tie-break 문양
type Key uint32
// makeKey 는 orders 를 내림차순으로 정렬해 왼쪽 정렬한 뒤 조립한다.
// 왼쪽 정렬이 중요하다. 그래야 "앞자리부터 차례로 비교" 가 정수 비교와 같아진다.
func makeKey(cat Category, orders []uint8, s Suit) Key {
sort.Slice(orders, func(i, j int) bool { return orders[i] > orders[j] })
var vec uint32
for _, o := range orders {
vec = vec<<4 | uint32(o)
}
vec <<= 4 * (5 - len(orders))
return Key(uint32(cat)<<22 | vec<<2 | uint32(s))
}
카테고리별로 orders 와 suit 에 무엇을 넣을지가 곧 규칙 표의 코드화입니다.
| 조합 | orders |
suit |
|---|---|---|
| 싱글 | 그 타일의 숫자 | 그 타일의 문양 |
| 페어·트리플 | 그 숫자 하나 | 구성 타일 중 최고 문양 |
| 스트레이트 | 다섯 장의 숫자 전부 | 가장 강한 타일의 문양 |
| 플러시 | 다섯 장의 숫자 전부 | 그 플러시의 문양 |
| 풀하우스 | 트리플 부분의 숫자 하나 | (동률 불가) |
| 포카드+1 | 포카드 부분의 숫자 하나 | (동률 불가) |
| 스트레이트 플러시 | 다섯 장의 숫자 전부 | 그 플러시의 문양 |
그러면 조합끼리의 비교는 이렇게 끝납니다.
type Meld struct {
Tiles []Tile
Count int // 1, 2, 3, 5 중 하나
Key Key
}
// Cat 은 Key 의 상위 비트를 그대로 읽는다. 별도 필드를 두지 않는다.
func (m Meld) Cat() Category { return Category(m.Key >> 22) }
func (m Meld) Beats(prev Meld) bool {
return m.Count == prev.Count && m.Key > prev.Key
}
풀하우스와 포카드+1 의 orders 가 한 칸뿐인 것이 규칙을 그대로 옮긴 결과입니다. 페어 부분과 얹은 1장은 비트 벡터에 아예 들어가지 않으므로, 코드가 “그 타일들은 서열에 영향이 없다"는 규칙을 구조적으로 보장 합니다. 조건문으로 무시하는 것보다 훨씬 안전합니다.
3. 스트레이트 순위는 규칙에서 유도된다#
공식 문서는 스트레이트 순위의 상위 세 자리를 명시합니다.
| 순위 | 구성 | 근거 |
|---|---|---|
| 1위 | 1, 2, 3, 4, 5 |
가장 높은 2 가 있고, 두 번째로 높은 것이 1 |
| 2위 | 2, 3, 4, 5, 6 |
가장 높은 2 가 있으나, 두 번째로 높은 것이 6 |
| 3위 | 12, 13, 14, 15, 1 |
두 번째로 높은 1 이 최종 숫자로 들어감 |
여기서 놓치기 쉬운 것이 있습니다. 1위와 2위는 둘 다 2 를 포함합니다. 최고 숫자가 같으므로 순위는 두 번째 숫자 에서 갈립니다. 스트레이트를 “가장 높은 타일 한 장"으로 비교하도록 구현하면 이 둘을 구별하지 못합니다.
앞 절의 Key 는 이 순위를 따로 표로 넣지 않아도 재현합니다. 숫자 벡터를 강한 것부터 늘어놓고 정수로 비교하면, 그것이 곧 “가장 높은 수부터 차례로 비교"이기 때문입니다. 유효한 스트레이트 12종을 Key 로 정렬하면 이렇게 나옵니다.
1위 1-2-3-4-5 7위 8-9-10-11-12
2위 2-3-4-5-6 8위 7-8-9-10-11
3위 12-13-14-15-1 9위 6-7-8-9-10
4위 11-12-13-14-15 10위 5-6-7-8-9
5위 10-11-12-13-14 11위 4-5-6-7-8
6위 9-10-11-12-13 12위 3-4-5-6-7
상위 세 자리가 공식 문서와 일치합니다. 구현이 규칙을 제대로 옮겼는지 확인하는 좋은 테스트가 됩니다.
유효한 스트레이트가 12종뿐이라는 점도 짚어 둘 만합니다. 1 은 15 다음으로 쓸 수 있지만 2 는 이어지지 않으므로, 13-14-15-1-2 같은 구성은 존재하지 않습니다.
// straightRanks 는 유효한 스트레이트 12종의 숫자 구성이다.
// 1 은 최저(1-2-3-4-5)와 최고(12-13-14-15-1) 양쪽으로 쓰이지만,
// 2 는 15 뒤에 이어지지 못한다.
var straightRanks = [12][5]Rank{
{1, 2, 3, 4, 5}, {2, 3, 4, 5, 6}, {3, 4, 5, 6, 7}, {4, 5, 6, 7, 8},
{5, 6, 7, 8, 9}, {6, 7, 8, 9, 10}, {7, 8, 9, 10, 11}, {8, 9, 10, 11, 12},
{9, 10, 11, 12, 13}, {10, 11, 12, 13, 14}, {11, 12, 13, 14, 15},
{12, 13, 14, 15, 1},
}
한 가지 더. 2 는 서열상 최강이므로, 2 가 든 스트레이트는 스트레이트끼리의 싸움에서는 대단히 강합니다. 실제로 구름 2-3-4-5-6 은 해 12-13-14-15-1 을 이깁니다. 숫자만 보면 12부터 시작하는 쪽이 커 보이지만, 가장 높은 수가 2 대 1 이라 앞쪽이 이깁니다.
다만 이것은 어디까지나 같은 카테고리 안에서의 이야기입니다. 스트레이트는 5장 메이드 중 가장 낮은 카테고리 이므로, 2 가 들어 있어도 어떤 플러시에게든 집니다. Part 2 에서 이 비대칭을 수치로 보겠습니다.
4. 왜 동률 처리 규칙이 없는가#
Key 가 정수 하나라는 것은 좋은데, 두 조합의 Key 가 같으면 어떻게 될까요? 규칙서는 그 경우를 다루지 않습니다. 다룰 필요가 없기 때문입니다.
Key 의 마지막 결정 요소는 언제나 실제 타일의 문양 입니다. 두 조합의 Key 가 같다는 것은 서열을 정하는 그 타일이 물리적으로 같은 타일이라는 뜻인데, 한 트릭 안에서 이미 나간 타일은 다시 나올 수 없습니다. 풀하우스도 마찬가지입니다. 누군가 7,7,7 + x,x 를 냈다면 같은 Key 의 풀하우스는 7 트리플을 또 만들어야 하는데, 한 숫자에 4장뿐이라 남은 7 은 한 장입니다.
즉 Key 는 한 트릭 안에서 전순서(total order) 이고, 동률 처리 규칙이 존재하지 않아도 게임이 성립합니다.
다만 내 후보 집합 안에서는 같은 Key 가 나옵니다. 예를 들어 12-13-14-15-1 스트레이트에서 1 을 고정하고 나머지 네 자리의 문양을 바꾸면, 전부 다른 조합이지만 Key 는 같습니다. 어느 것을 낼지는 규칙 문제가 아니라 어떤 타일을 소모할 것인가 하는 전략 문제이고, Part 3 의 분해 플랜이 답할 질문입니다.
5. 공정성 경계와 정책 인터페이스#
봇이 볼 수 있는 정보를 타입으로 못 박는 이야기는 달무티 Part 1 에서 자세히 다뤘으므로 반복하지 않겠습니다. 원칙만 옮기면, 서버 상태 객체를 봇에게 그대로 넘기지 않고 봇에게 허용된 정보만 담은 뷰 타입 을 따로 만듭니다. 남의 손패는 그 구조체 어디에도 존재하지 않고, 남은 장수만 들어 있습니다.
// GameView 는 봇에게 노출되는 정보의 전부다.
type GameView struct {
Me SeatID
Hand Set // 내 손패
Table *Meld // 현재 받아야 할 수. nil 이면 내가 리드다
Seats []SeatInfo // 좌석별 남은 장수
Log []Action // 이번 라운드의 공개 행동. 제출과 패스를 모두 담는다
Round int // 1..5
Played Set // 이번 라운드에 지금까지 나온 모든 타일
Money map[SeatID]int // 누적 정산
Rules Ruleset
}
type SeatInfo struct {
Seat SeatID
TileCount int // 남은 "장수" 만 안다. 무엇인지는 모른다
Finished bool
}
// Action 은 공개된 행동 하나다. 패스도 반드시 로그에 남는다.
type Action struct {
Seat SeatID
TrickID int
Pass bool
Meld *Meld // Pass 가 false 일 때만 유효하다
Table *Meld // 그 행동을 할 때 받아야 했던 수. nil 이면 리드였다
}
달무티판과 달라진 곳이 Played Set 하나입니다. 달무티에서는 로그를 훑어 숫자별 잔량을 세야 했지만, 렉시오는 나온 타일 전부가 uint64 한 칸에 들어가므로 아예 필드로 들고 다니는 편이 낫습니다. Part 2 는 이 필드에서 출발합니다.
의사결정 지점은 달무티보다 단출합니다.
type BotPolicy interface {
// SelectPlay 는 자기 차례에 낼 조합을 고르거나 패스한다.
SelectPlay(v GameView) Decision
}
type Decision struct {
Pass bool
Meld Meld
}
함수가 하나뿐입니다. 렉시오에는 신분도, 세금도, 혁명도 없어서 달무티에 있던 DeclareRevolution 과 SelectTaxCards 가 통째로 사라집니다. 대신 남은 하나가 훨씬 깊어집니다.
합법수 생성#
손패 하나가 만들 수 있는 조합은 몇 개인가#
먼저 규모를 재 보겠습니다. 아래 통계는 모두 시드 20260731, 무작위 딜 20,000회 로 얻은 값이며, 괄호는 95% Wilson 신뢰구간입니다.
| 조합 | 평균 조합 수 | 하나 이상 보유할 확률 |
|---|---|---|
| 싱글 | 12.00 | 100.0% |
| 페어 | 3.36 | 99.5% [99.4, 99.6] |
| 트리플 | 0.38 | 31.2% [30.5, 31.8] |
| 스트레이트 | 1.78 | 35.3% [34.6, 35.9] |
| 플러시 | 1.76 | 51.3% [50.6, 52.0] |
| 풀하우스 | 0.72 | 28.9% [28.3, 29.5] |
| 포카드+1 | 0.12 | 1.5% [1.4, 1.7] |
| 스트레이트 플러시 | 0.01 | 0.5% [0.4, 0.6] |
| 5장 메이드 (종류 무관) | 4.39 | 80.2% [79.7, 80.8] |
| 전체 | 20.13 | — |
읽히는 것이 몇 가지 있습니다.
초기 손패의 80% 가 5장 메이드를 하나 이상 갖고 있습니다. 5장 판이 열리기만 하면 대부분의 플레이어가 참여할 수 있다는 뜻입니다. “메이드를 쥐고 있는데 5장 판이 안 열려서 못 냈다"는 흔한 패배 시나리오가 왜 그렇게 자주 나오는지 짐작할 수 있습니다. 희소한 것은 메이드 자체가 아니라 메이드를 낼 기회 입니다.
포카드+1 은 1.5%, 스트레이트 플러시는 0.5%로 사실상 안 나옵니다. 상위 두 족보는 “만들면 좋은 것"이 아니라 “가끔 굴러 들어오는 것"에 가깝습니다. 봇 설계에서 이 둘을 목표로 삼는 로직은 넣을 가치가 없습니다.
압축되는 카테고리와 안 되는 카테고리#
조합의 세기는 Key 로 환원되고, Key 가 같은 조합들은 규칙상 완전히 동등합니다. 그러면 후보를 Key 별로 하나씩만 만들어 줄일 수 있을까요. 카테고리마다 사정이 다릅니다. 같은 표본에서 서로 다른 Key 의 개수를 세면 이렇습니다.
| 조합 | 평균 조합 수 | 평균 서로 다른 Key 수 |
압축비 |
|---|---|---|---|
| 싱글 | 12.00 | 12.00 | 1.00 |
| 페어 | 3.36 | 2.99 | 1.12 |
| 트리플 | 0.38 | 0.35 | 1.09 |
| 스트레이트 | 1.78 | 0.79 | 2.25 |
| 플러시 | 1.76 | 1.76 | 1.00 |
| 풀하우스 | 0.72 | 0.33 | 2.18 |
| 포카드+1 | 0.12 | 0.02 | 6.00 |
| 스트레이트 플러시 | 0.01 | 0.01 | 1.00 |
| 전체 | 20.13 | 18.24 | 1.10 |
굵게 표시한 두 줄이 대조적입니다.
- 스트레이트는 2.25배로 압축됩니다. 숫자 구성이 12종 시퀀스 중 하나로 고정되므로, 같은 시퀀스를 만드는 문양 조합이 여럿이어도
Key는 최고 수의 문양으로만 갈립니다. - 플러시는 전혀 압축되지 않습니다. 다섯 장의 숫자가 전부 서열에 기여하므로, 타일이 하나만 달라도 다른
Key입니다.
포카드+1 의 6배는 얹는 1장이 서열에 안 들어가기 때문이고, 풀하우스의 2.18배는 페어 부분이 안 들어가기 때문입니다. 즉 압축비는 “그 카테고리에서 서열에 기여하지 않는 타일이 몇 장인가"를 그대로 반영합니다.
그래서 후보 생성은 두 단계로 나눕니다.
flowchart LR
A["내 차례"] --> B["1단계: 어떤 Key 로 받을 것인가<br/>(규칙 문제)"]
B --> C["2단계: 그 Key 를 만드는 데<br/>어떤 타일을 쓸 것인가<br/>(전략 문제 · Part 3)"]
C --> D["제출"]
style A fill:#FFD700,color:#000000
style B fill:#87CEEB,color:#000000
style C fill:#DDA0DD,color:#000000
style D fill:#90EE90,color:#000000
여기서 중요한 원칙이 하나 있습니다. 1단계에서 Key 를 줄이더라도, 2단계가 쓸 정보까지 버리면 안 됩니다. 같은 Key 를 만드는 타일 구성이 여럿이면 그 목록을 유지해야 Part 3 의 DP 가 “어느 구성이 남은 손패에 덜 해로운가"를 고를 수 있습니다. 후보를 (Key, 가능한 타일 구성들) 로 들고 다니는 편이 안전합니다.
후보를 하나도 잃지 않는 생성기#
1~3장짜리부터 보겠습니다. 여기서 자연스럽지만 틀린 최적화가 하나 있습니다. “어차피 강한 후보가 이기니 각 숫자마다 가장 센 조합 하나만 만들면 된다"는 생각입니다.
반대입니다. 클라이밍 게임에서 필요한 것은 테이블을 이기는 가장 싼 수 입니다. 같은 숫자의 페어라도 구름+별, 구름+달, 구름+해 는 서로 다른 Key 이고, 테이블이 별 5 페어라면 구름+달 로 충분한데 구름+해 를 내면 해를 낭비합니다.
그래서 각 숫자에 대해 가능한 Key 를 전부 만들어야 합니다. 다행히 수가 적습니다. 어떤 숫자의 타일을 k 장 갖고 있으면 count 장짜리 조합의 Key 는 최대 k - count + 1 가지뿐입니다.
// followsSmall 은 1/2/3 장짜리 후보를 Key 별로 하나씩 만든다.
// 각 Key 에 대해 "가장 약한 나머지 + 그 Key 를 만드는 최고 타일" 을 고른다.
func followsSmall(hand Set, count int, minKey Key) []Meld {
var out []Meld
for ro := uint8(0); ro < 15; ro++ {
tiles := (hand & rankMaskByOrder(ro)).Tiles() // 약한 것부터
if len(tiles) < count {
continue
}
// top 이 될 수 있는 것은 인덱스 count-1 이상의 타일뿐이다.
for i := count - 1; i < len(tiles); i++ {
picked := append(append([]Tile{}, tiles[:count-1]...), tiles[i])
m := Meld{Tiles: picked, Count: count,
Key: makeKey(0, []uint8{ro}, tiles[i].Suit())}
if m.Key > minKey {
out = append(out, m)
}
}
}
return out
}
나머지 count-1 장을 가장 약한 것부터 채우는 것은 이 단계에서 안전한 선택입니다. 같은 Key 안에서는 어느 타일을 쓰든 규칙상 동등하고, 같은 숫자의 타일끼리는 다른 조합에 쓰일 여지도 없기 때문입니다. 5장 메이드에서는 사정이 달라지는데, 그 이야기는 Part 3 에서 하겠습니다.
5장 메이드는 카테고리별로 만듭니다. 스트레이트가 가장 까다롭습니다.
// straightMelds 는 스트레이트와 스트레이트 플러시 후보를 만든다.
func straightMelds(hand Set) []Meld {
var out []Meld
for _, seq := range straightRanks {
var pools [5]Set
ok := true
for i, r := range seq {
pools[i] = hand & rankMask(r)
if pools[i] == 0 {
ok = false
break
}
}
if !ok {
continue
}
// 다섯 자리의 문양 선택을 전부 훑는다.
// pools 각각이 최대 4장이므로 최악이 4^5 = 1024 가지이고,
// 실제로는 손패가 12장뿐이라 훨씬 작다.
forEachCombination(pools, func(tiles []Tile) {
out = append(out, classify(tiles))
})
}
return dedupeKeepAllTilings(out)
}
여기서 문양 선택을 전부 훑는 이유가 있습니다. 만약 “최고 수만 고정하고 나머지는 가장 약한 타일로” 채우면, 다섯 장이 우연히 같은 문양이 되어 스트레이트 플러시로 승격되는 경우 에 같은 숫자 구성의 일반 스트레이트 후보가 사라집니다. 승격이 늘 이득인 것도 아닙니다. 테이블이 스트레이트일 때는 일반 스트레이트로 받는 편이 싸고, 스트레이트 플러시는 아껴 두는 편이 낫습니다. 두 후보가 모두 필요합니다.
나머지 카테고리는 단순합니다.
| 카테고리 | 생성 방법 |
|---|---|
| 플러시 | 문양별로 5장 이상이면 C(n,5) 를 전부 만든다. 압축이 안 되므로 줄일 수 없다 |
| 풀하우스 | 3장 이상인 숫자 × 2장 이상인 다른 숫자. 페어 부분은 Key 에 영향이 없으므로 가장 약한 페어 를 붙인다 |
| 포카드+1 | 4장인 숫자 × 나머지 아무 1장. 얹는 1장은 Key 에 영향이 없으므로 가장 쓸모없는 타일 을 붙인다 |
| 스트레이트 플러시 | 위 스트레이트 생성 결과에서 분류된 것 |
포카드+1 의 마지막 칸이 요긴합니다. 얹는 1장은 서열에 전혀 기여하지 않으므로, 손패에서 가장 쓸모없는 타일을 여기에 버릴 수 있습니다. 포카드+1 은 강한 족보이면서 동시에 쓰레기 처리기 입니다. 풀하우스의 페어 부분도 같은 성격입니다.
easy 봇: 결정적 규칙만으로 만드는 기준선#
easy 봇의 설계 목표는 “약한 봇"이 아니라 “완전히 예측 가능하고 항상 합법적인 봇” 입니다. 확률도, 난수도, 탐색도 없습니다. 같은 상황에서는 언제나 같은 수를 둡니다. 이 봇은 세 가지 역할을 합니다.
- 룰 엔진의 합법성 검사를 수천 라운드 자동으로 두들기는 테스트 하네스
- 사람이 자리를 비웠을 때의 자동 플레이 대체 로직
- hard 봇의 강함을 측정할 기준선
세 번째가 특히 중요합니다. 기준선이 없으면 hard 봇이 정말 나아졌는지 말할 수 없습니다.
리드와 팔로우#
정책은 다음과 같습니다.
- 리드일 때: 손패에서 가장 약한 타일 을 찾고, 그 타일을 포함하는 조합 중 장수가 가장 많은 것 을 냅니다. 장수가 같으면
Key가 작은 것, 그래도 같으면 타일 ID 사전순으로 끊습니다. - 팔로우일 때: 테이블을 이기는 후보 중
Key가 가장 작은 것 을 냅니다.Key가 같으면 쓰는 타일의 ID 합이 작은 것을 고릅니다. - 합법 수가 있으면 전략적으로 패스하지 않고 항상 제출합니다.
- 합법 수가 없을 때만 패스합니다.
리드 규칙을 “가장 약한 조합"이 아니라 “가장 약한 타일을 포함하는 가장 큰 조합"으로 잡은 데 이유가 있습니다. 전자로 하면 봇은 매번 가장 약한 싱글을 한 장씩 흘려 열두 턴을 씁니다. 후자로 하면 약한 타일을 처분하면서도 장수를 최대한 뽑아냅니다. easy 봇이 가진 몇 안 되는 “쓸 만한 감각"입니다.
덤으로 첫 리드 규칙과도 자연스럽게 맞습니다. 첫 트릭의 리드권은 구름 3 보유자에게 가는데, 구름 3 은 타일 ID 0 이라 언제나 그 사람의 “가장 약한 타일"입니다.
flowchart TD
S["내 차례 시작"] --> T{"테이블이 비어 있는가?"}
T -- "예 (리드)" --> L1["가장 약한 타일 t 를 찾는다"]
L1 --> L2["t 를 포함하는 조합을 전부 만든다"]
L2 --> L3["정렬: 장수 많은 순 →<br/>Key 작은 순 → 타일 ID 사전순"]
L3 --> L4["첫 번째 후보 제출"]
T -- "아니오 (팔로우)" --> F1["같은 장수 + 더 큰 Key<br/>후보를 빠짐없이 생성"]
F1 --> F2{"합법 수가 있는가?"}
F2 -- 아니오 --> P["패스"]
F2 -- 예 --> F3["정렬: Key 작은 순 →<br/>타일 ID 합 작은 순"]
F3 --> F4["첫 번째 후보 제출"]
style S fill:#FFD700,color:#000000
style L4 fill:#90EE90,color:#000000
style F4 fill:#90EE90,color:#000000
style P fill:#FFB6C1,color:#000000
type EasyPolicy struct{}
func (EasyPolicy) SelectPlay(v GameView) Decision {
if v.Table == nil {
return Decision{Meld: 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 easyFollowLess(cands[i], cands[j]) })
return Decision{Meld: cands[0]}
}
// easyFollowLess 는 "가장 싸게 받는" 후보를 앞에 세운다.
// 1. Key 가 작은 것 (가장 약한 수로 받는다)
// 2. 쓰는 타일의 ID 합이 작은 것 (약한 타일부터 소모한다)
func easyFollowLess(a, b Meld) bool {
if a.Key != b.Key {
return a.Key < b.Key
}
return tileSum(a.Tiles) < tileSum(b.Tiles)
}
// easyLead 는 가장 약한 타일을 포함하는 가장 큰 조합을 낸다.
func easyLead(hand Set) Meld {
weakest, _ := hand.Lowest() // 리드 시점에 손패는 반드시 비어 있지 않다
cands := MeldsContaining(hand, weakest)
sort.Slice(cands, func(i, j int) bool {
if cands[i].Count != cands[j].Count {
return cands[i].Count > cands[j].Count // 장수가 많을수록 좋다
}
if cands[i].Key != cands[j].Key {
return cands[i].Key < cands[j].Key // 같은 장수면 약하게 연다
}
return tileSum(cands[i].Tiles) < tileSum(cands[j].Tiles)
})
return cands[0] // 싱글은 언제나 후보에 있으므로 빈 슬라이스가 될 수 없다
}
마지막 줄의 주석이 작지만 중요합니다. MeldsContaining 은 최소한 “그 타일 한 장짜리 싱글"을 항상 포함하므로, 후보가 비어 정렬 결과를 인덱싱하다 패닉이 날 일이 없습니다.
결정성은 어디서 오는가#
달무티 봇에서는 “12를 일곱 장 중 어느 다섯 장을 낼 것인가"가 규칙상 무의미해서, 결정성을 위해 card ID 오름차순이라는 인위적 tie-break 를 도입해야 했습니다. 렉시오는 사정이 다릅니다.
Key단계의 동률은 존재하지 않습니다. 앞에서 봤듯Key는 한 트릭 안에서 전순서입니다.- 타일 선택 단계의 동률은 존재합니다. 같은
Key를 만드는 구성이 여럿일 수 있습니다.
easy 봇은 두 번째를 tileSum 이라는 결정적 함수로 끊습니다. 이 선택은 “약한 타일부터 쓴다"는 전략적 의미도 함께 갖습니다. 다만 이것이 늘 옳지는 않습니다. 그 약한 타일이 다른 조합의 부품일 수 있기 때문입니다. Part 3 의 분해 플랜이 이 자리를 대체합니다.
easy 봇은 왜 지는가#
easy 봇은 기준선으로서 훌륭하지만, 사람과 몇 판만 붙어 보면 금방 약점이 드러납니다. 그 약점들이 정확히 Part 2~4 의 설계 항목이 됩니다.
1. 나온 타일을 세지 않습니다. GameView.Played 를 아예 읽지 않습니다. 렉시오는 60장이 전부 유일하고 4장씩 고르게 분포하므로 카운팅 오차가 0인 게임 입니다. 이 정보를 통째로 버리는 것은 달무티에서보다 더 큰 손실입니다. → Part 2
2. 낼 수 있으면 무조건 냅니다. 렉시오는 패스해도 자기 차례가 다시 오면 낼 수 있으므로, 참는 비용이 원래 낮습니다. 참는 것이 싼 게임에서 한 번도 참지 않는 것은 그만큼 큰 손실입니다. → Part 4
3. 조합을 파괴합니다. 이것이 렉시오 특유의 가장 아픈 약점입니다. 누군가 페어를 냈을 때 easy 봇의 손에 해 4, 해 7, 해 9, 해 11, 해 14 라는 플러시 재료가 있고 그중 해 7 이 페어의 한 짝이라면, easy 봇은 성실하게 그 페어를 내고 플러시를 부숴 버립니다. 후보를 Key 기준으로만 고르기 때문에, 그 타일이 다른 조합의 부품이라는 사실을 보지 않습니다. 달무티에서는 같은 숫자끼리만 뭉쳤기 때문에 이런 교차 파괴가 드물었지만, 렉시오는 한 타일이 페어·스트레이트·플러시에 동시에 속하는 것이 기본 입니다. → Part 3
4. 몇 번에 털지 계산하지 않습니다. easy 봇은 이번 턴만 봅니다. “내 손패는 최소 몇 번 제출하면 비는가"라는 질문을 하지 않으므로, 장수는 줄었는데 남은 타일이 전부 고아가 되는 손패로 걸어 들어갑니다. → Part 3
5. 정산 구조를 모릅니다. 이것이 렉시오에서 가장 비쌉니다. easy 봇은 2 타일을 그저 “강한 타일"로만 취급해 끝까지 아낍니다. 그런데 렉시오의 정산은 실제 장수 × 2^(2의 개수) 이므로, 2를 두 장 쥔 채 6장이 남으면 24장을 남긴 것으로 계산됩니다. 강한 타일을 아낀 대가가 정확히 정산표에 찍힙니다. → Part 4
6. 완전히 예측 가능합니다. 결정적이라는 것은 테스트에는 축복이지만 상대에게는 정보입니다. 사람은 몇 판 만에 “이 봇은 늘 가장 약한 수로 받는다"는 패턴을 읽습니다.
여섯 가지를 한 문장으로 줄이면 이렇습니다.
easy 봇은 이번 턴에 합법적인 가장 싼 수 를 계산할 뿐, 내 60장짜리 세계에서 지금 무엇이 남아 있는지도, 내 손패가 몇 번에 비는지도, 라운드가 끝났을 때 얼마를 물어내는지도 계산하지 않습니다.
마치며#
Part 1 에서는 렉시오 봇의 표현 계층을 잡았습니다. 정리하면 세 가지입니다.
- 타일 하나를 정수 하나로 압축합니다.
rankOrder(r)*4 + suit로 만든 0~59 값이 곧 서열이자 비트마스크 인덱스입니다. 서열 비교가<하나로 끝나고, 60장 전체가uint64한 칸에 들어갑니다. - 조합 하나도 정수 하나로 압축합니다.
(카테고리, 서열에 기여하는 숫자 벡터, 문양)을 25비트에 담으면, 공식 룰의 여덟 가지 비교 규칙이 정수 비교 하나로 통합됩니다. 스트레이트 순위표도 이 인코딩에서 그대로 재현됩니다. - 후보는
Key별로 줄이되, 타일 구성 정보는 버리지 않습니다. 압축이 되는 카테고리와 안 되는 카테고리가 갈리고, 특히 플러시는 다섯 장이 모두 서열에 기여해 전혀 압축되지 않습니다.
Part 2 에서는 Played 필드를 처음으로 읽습니다. 미공개 타일 집합을 정확히 복원하고, “이 수는 아무도 못 받는다"를 확률 없이 판정할 수 있는 영역 과 확률이 필요한 영역 으로 나누겠습니다.