카드게임 봇이 상대 손패를 추정하게 만들기 — 패스 로그 가중 샘플링, 그리고 조용히 약해진 봇
티츄 봇이 교환한 카드와 패스 기록으로 상대 손패를 추정하고, 셀프플레이 손익분기(50%)를 넘는 조건에서만 티츄를 부르게 만든 과정. 그리고 필드 하나 때문에 승률 탐색이 에러 없이 기본 전략으로 떨어졌던 버그까지 — 티츄온라인(Node.js) 실제 커밋 기록.
TL;DR
- 티츄 봇은 승률 탐색을 할 때 “안 보이는 카드”를 상대 셋에게 무작위로 나눠 가상 손패(determinization)를 만들었다. 여기에 내가 교환으로 준 카드는 받은 사람 손에 고정하고, 패스 기록과 티츄 선언으로 가중치를 줬다.
- 봇이 스스로 티츄를 부르는 조건은 감이 아니라 셀프플레이에서 “그 손으로 1등 나간 비율”이 손익분기(50%)를 넘는 것만 골랐다.
- 효과는 구 봇 대비 라운드당 +3.5 ± 1.1점(4500딜). 티츄 콜은 +0.7로 오차 안이라 “효과 있다”고 쓰지 않았다.
- 이틀 뒤, 새로 추가한
passLog가serialize()에서 빠져 있어서 워커에서 승률 탐색이 에러를 내고 기본 전략으로 떨어지고 있었다. 크래시가 아니라 봇이 조용히 약해지는 버그였다.
상대 손패를 “모른다”는 것의 비용
티츄는 불완전정보 게임이다. 봇이 지금 낼 카드를 고르려면 “상대 셋이 무슨 카드를 쥐고 있나”를 가정해야 하는데, 한 가지 방법이 가상 손패를 여러 번 뽑아서 각각 끝까지 시뮬레이션하고, 평균이 좋은 수를 고르는 것이다. 내 손과 이미 나온 카드를 뺀 나머지 카드를 상대 셋에게 나눠 주기만 하면 된다.
기존 코드는 이 나눠 주기가 그냥 셔플이었다.
const shuffled = shuffleArray(unseenCards);
let cursor = 0;
for (const pid of world.playerIds) {
if (pid === botId) { world.hands[pid] = game.hands[pid].slice(); continue; }
const need = game.hands[pid].length;
world.hands[pid] = shuffled.slice(cursor, cursor + need);
cursor += need;
}
문제는 사람이 이 게임을 하며 쓰는 정보를 봇이 전혀 안 쓴다는 점이다. 세 가지가 눈에 띄었다.
- 내가 준 카드는 어디 있는지 안다. 교환 때 오른쪽·파트너·왼쪽에게 한 장씩 줬으니, 그 카드는 (낼 때까지) 받은 사람 손에 있다. 셔플에선 그 카드가 엉뚱한 사람에게 갈 수 있었다.
- 상대 싱글에 패스했다면 그보다 높은 카드는 덜 쥐고 있을 가능성이 크다.
- 티츄를 부른 사람은 A·봉황·용을 더 쥐고 있을 가능성이 크다.
하드 제약이 아니라 소프트 가중치로
“패스했으니 그 카드는 없다”로 단정하면 안 된다. 일부러 아껴 두려고 패스하는 경우가 있고, 티츄 콜에도 허세가 섞인다. 그래서 어떤 카드도 완전히 제외하지 않고 뽑힐 확률만 조정했다.
const cardWeight = (pid, card) => {
let w = 1;
const v = getCardValue(card);
// 상대 싱글에 패스했던 가장 낮은 값보다 높은 카드는 덜 쥐고 있도록
if (passCeil[pid] !== undefined && v > passCeil[pid] && v <= 14 && card !== 'special_phoenix') w *= 0.25;
if (callers.has(pid)) {
if (v >= 14) w *= 2; // A, 봉황, 용
else if (v === 13) w *= 1.4;
}
return w;
};
패스 기록을 쓸 때 걸러야 하는 경우가 하나 있다. 파트너가 낸 카드 밑으로 패스한 건 아무것도 증명하지 않는다. 파트너를 이기지 않으려고 패스하는 게 정상이기 때문이다. 그래서 이 로그에는 “누가 패스했고, 그 트릭을 누가 쥐고 있었는지(winner)“를 같이 남기고, 파트너 밑 패스는 버렸다.
if (e.winner === partnerOf(e.playerId)) continue; // 파트너 밑 패스는 정보가 없다
나머지 카드는 한 장씩 꺼내 남은 자리 수 × 가중치에 비례해 세 사람 중 하나에게 준다. 각자 손에 들 장수는 이미 정해져 있으니, 자리가 찬 사람에게는 더 가지 않는다.
TichuGame 에는 이 정보를 위해 passLog 를 새로 만들었다.
this.passLog = []; // [{ playerId, winner, type, length, value }] — 봇의 손패 추정 전용
봇이 스스로 티츄를 부르게 하기
원래 봇은 사람 상대로는 티츄를 한 번도 부르지 않았다(어드민 봇방에서 흐름을 시험할 때만 강제로 켰다). 이걸 풀면서 가장 경계한 건 “그럴듯해 보이는 조건”을 직감으로 정하는 것이었다. 티츄는 실패하면 -100, 라지는 -200이라, 손익분기가 정확히 1등으로 나갈 확률 50% 다.
그래서 셀프플레이를 돌려 “이 손으로 부른다면 1등 나간 비율”을 조건별로 쟀다.
| 조건 | 1등 비율 | 판정 |
|---|---|---|
소티츄: 교환 후 14장 evaluateHand 점수 ≥ 100 | 53~59% | 부른다 |
| 소티츄: 90대 | 47% | 안 부른다 |
| 라지: 첫 8장에 용 + 봉황 + A 모두 | 54% | 부른다 |
| 라지: 그 외 | 45% 이하 | 안 부른다 |
임계값은 이 표에서 그대로 나왔다. 규칙은 한 줄씩이다.
// 라지: 첫 8장이 용 + 봉황 + A 를 다 가질 때만
return cards.includes('special_dragon') && cards.includes('special_phoenix') && aces >= 1;
// 소티츄: 파트너가 이미 불렀으면 부르지 않고, 점수 100 이상일 때만
return evaluateHand(cards).score >= 100;
환경변수 BOT_TICHU=0 으로 이 자연 콜만 끌 수 있게 했다. 문제가 생기면 배포 없이 환경변수로 되돌릴 길을 먼저 만들어 두었다.
잘 됐는지를 어떻게 알았나
봇끼리 한 판 붙여서 이긴 횟수를 세는 건 약하다. 카드 운이 승패를 거의 다 정하기 때문이다. 그래서 sim_tichu_ab.js 는 같은 패(같은 시드)로 신 봇과 구 봇의 좌석을 바꿔 두 번 돌린다. 덱 운이 양쪽에 똑같이 걸리므로 상쇄되고, 남는 차이가 봇의 차이다. 결과는 라운드 점수 차의 평균과 표준오차로 본다.
| 변경 | 라운드당 점수 차 | 판단 |
|---|---|---|
| 손패 추정 | +3.5 ± 1.1 (4500딜) | 오차의 3배 이상, 효과 있음 |
| 티츄 콜 추가 | +0.7 | 오차 안, 효과를 주장하지 않음 |
티츄 콜은 숫자가 양수라 “좋아졌다”고 쓰고 싶었지만, 오차 안이라서 있다고도 없다고도 말할 수 없다고 기록했다. 대신 손익분기 위의 조건만 불렀으니 손해는 아닐 것이라는 근거로만 켜 두었다.
이틀 뒤: 필드 하나가 조용히 빠져 있었다
passLog 는 clone() 이 복사한다. 승률 탐색은 게임을 복제해서 시뮬레이션하기 때문이다. 그런데 이 탐색은 메인 스레드가 아니라 워커 풀에서 돈다. 그래서 게임 상태는 이렇게 건너간다.
메인: game.serialize() → structuredClone → 워커: Game.reconstruct(...) → clone() 로 시뮬레이션
clone() 에는 passLog 를 넣었는데 serialize() 에는 넣지 않았다. 워커에서 복원된 게임은 passLog 가 undefined 였고, clone() 이 this.passLog.map(...) 을 부르는 순간 undefined.map 으로 터졌다.
// clone()
c.passLog = this.passLog.map((e) => ({ ...e }));
이 에러는 봇 의사결정 쪽에서 잡혀서 기본 전략으로 폴백했다. 크래시도 멈춤도 없이 봇이 기본 전략으로 계속 두는 것뿐이라, 겉으로는 드러나지 않는 종류의 버그다. 방금 만든 손패 추정이 정작 서비스에서는 쓰이지 않고 있었다.
고친 건 한 줄이다.
// serialize()
passLog: this.passLog,
한 줄보다 중요한 건 이 버그를 막을 테스트가 이미 있었다는 점이다. test_serialize_roundtrip.js 는 reconstruct(structuredClone(serialize(g))) 가 g.clone() 과 깊은 비교로 같아야 한다고 요구한다. 수정 전에 돌리면 실패하고, 수정 후에는 통과하는 것을 확인했다. clone() 이 “봇 탐색에 필요한 상태”를 정의하므로, serialize() 가 그것과 한 필드라도 다르면 이 테스트가 깨지도록 만든 계약이었다.
정리
- 불완전정보에서 추정은 하드 제약 대신 가중치로. 패스도 티츄 콜도 허세와 보류가 섞여 있다. 완전히 제외하는 순간 틀린 확신이 생긴다.
- 추론에 쓰는 로그는 “누구 밑에서 한 행동인지”를 같이 남긴다. 파트너 밑 패스는 정보가 없다.
- 봇의 행동 조건은 셀프플레이로 손익분기를 재서 정한다. “좋아 보이는” 조건 중에는 실제로 손익분기 아래인 것이 섞여 있다(위 표의 90대 점수와 라지 나머지 조합).
- 같은 패로 좌석을 바꿔 붙이는 쌍대 A/B를 쓴다. 그리고 오차 안의 결과는 효과로 쓰지 않는다.
- 복제(
clone)와 직렬화(serialize)가 따로 있으면, 둘이 같다는 계약을 테스트로 고정한다. 새 필드를 추가할 때 한쪽만 고치면 에러 없이 약해지는 버그가 된다. - 폴백이 있는 시스템은 폴백이 일어난 횟수를 센다. 에러가 잡혀서 기본값으로 대체되는 코드는 고장이 나도 아무 신호를 내지 않는다.
관련 릴리즈 노트: 티츄온라인 v3.3.1 · 같은 봇을 자가대국으로 디버깅한 기록은 봇이 이상한 수를 둔다에 적었다. 게임 룰 자체가 궁금하다면 티츄 규칙 정리를 먼저 보면 된다.