카드게임 봇이 상대 손패를 추정하게 만들기 — 패스 로그 가중 샘플링, 그리고 조용히 약해진 봇

티츄 봇이 교환한 카드와 패스 기록으로 상대 손패를 추정하고, 셀프플레이 손익분기(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;
}

문제는 사람이 이 게임을 하며 쓰는 정보를 봇이 전혀 안 쓴다는 점이다. 세 가지가 눈에 띄었다.

  1. 내가 준 카드는 어디 있는지 안다. 교환 때 오른쪽·파트너·왼쪽에게 한 장씩 줬으니, 그 카드는 (낼 때까지) 받은 사람 손에 있다. 셔플에선 그 카드가 엉뚱한 사람에게 갈 수 있었다.
  2. 상대 싱글에 패스했다면 그보다 높은 카드는 덜 쥐고 있을 가능성이 크다.
  3. 티츄를 부른 사람은 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 점수 ≥ 10053~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() 가 그것과 한 필드라도 다르면 이 테스트가 깨지도록 만든 계약이었다.

정리

  1. 불완전정보에서 추정은 하드 제약 대신 가중치로. 패스도 티츄 콜도 허세와 보류가 섞여 있다. 완전히 제외하는 순간 틀린 확신이 생긴다.
  2. 추론에 쓰는 로그는 “누구 밑에서 한 행동인지”를 같이 남긴다. 파트너 밑 패스는 정보가 없다.
  3. 봇의 행동 조건은 셀프플레이로 손익분기를 재서 정한다. “좋아 보이는” 조건 중에는 실제로 손익분기 아래인 것이 섞여 있다(위 표의 90대 점수와 라지 나머지 조합).
  4. 같은 패로 좌석을 바꿔 붙이는 쌍대 A/B를 쓴다. 그리고 오차 안의 결과는 효과로 쓰지 않는다.
  5. 복제(clone)와 직렬화(serialize)가 따로 있으면, 둘이 같다는 계약을 테스트로 고정한다. 새 필드를 추가할 때 한쪽만 고치면 에러 없이 약해지는 버그가 된다.
  6. 폴백이 있는 시스템은 폴백이 일어난 횟수를 센다. 에러가 잡혀서 기본값으로 대체되는 코드는 고장이 나도 아무 신호를 내지 않는다.

관련 릴리즈 노트: 티츄온라인 v3.3.1 · 같은 봇을 자가대국으로 디버깅한 기록은 봇이 이상한 수를 둔다에 적었다. 게임 룰 자체가 궁금하다면 티츄 규칙 정리를 먼저 보면 된다.