봇이 이상한 수를 둔다 — 크래시도, 실패하는 테스트도 없는 버그 잡기
"봇이 판돈도 없는 트릭에 마이티를 버려요." 크래시가 아니라 판단 품질이라 스택트레이스도 실패하는 테스트도 없다. 이상한 수를 봇 자신의 판정 함수로 세는 스캐너, dev vs main 한 테이블 A/B, A/A 널 검증까지 — 티츄온라인 마이티 봇을 자가대국으로 디버깅한 기록. 사람 눈에 낭비로 보이던 수가 실제로는 옳아서 되돌린 이야기도.
TL;DR
- 유저 리포트는 “봇이 판돈도 없는 첫 트릭에 마이티(최강 카드)를 버려요”였다. 크래시가 아니라 판단 품질이라 스택트레이스도, 실패하는 테스트도 없다.
- 방법은 하나뿐이었다. 이상해 보이는 수를 정의하고, 자가대국을 돌려 몇 번 나오는지 센다. 판정에는 봇 자신의 함수를 재사용한다 — 새로 짜면 그 판정이 또 틀린다.
- 그렇게 세니 숫자가 나왔다. 마이티 낭비 1000라운드에 5회, 점수 카드 헌납 600라운드에 87회. 고친 뒤 각각 0회 / 12회.
- 고쳤으면 세지 말고 붙여봐야 한다.
git archive main으로 main 봇을 꺼내 dev 봇과 한 테이블에 앉혔다. 2500딜 ×2 미러에서 주공 성공률 65.1% vs 63.5%. - 제일 많이 배운 건 되돌린 것들이다. 사람 눈에 명백히 낭비로 보이던 수 두 개를 “고쳤더니” 점수가 -0.108 떨어졌다. 오라클이 완전정보로 계산한 이득이었고, 내 눈이 틀렸다.
- 측정 함정도 전부 밟았다. A/A가 +0.264로 나오고(캠프 편향), 벤치마크가 +24% 느리다고 나오고(JIT 워밍업), 오탐 7건이 전부 정상 동작이었다.
문제 — 버그인데 버그처럼 안 생겼다
티츄온라인에는 게임이 4종 있고, 그중 마이티 봇이 이런 수를 뒀다.
주공이 첫 트릭에 낮은 카드를 리드했는데, 그 무늬 에이스를 들고 있으면서도 마이티를 낸다.
마이티는 게임 전체에서 가장 센 카드다. 판돈이 0인 첫 트릭에 그걸 버리는 건 사람이라면 절대 안 한다. 그런데 서버는 멀쩡히 돌아가고, 규칙 위반도 아니고, 테스트는 전부 초록불이다.
이런 버그의 곤란한 점은 재현 조건을 모른다는 것이다. 리포트에는 “가끔”이라고 적혀 있다. 손패 조합·비딩·좌석까지 맞아떨어져야 나오는데, 그 조합을 손으로 만들 수는 없다.
1단계 — 이상한 수를 “세는” 도구부터
디버깅 대신 계측을 먼저 만들었다. 자가대국을 돌리면서 “이상해 보이는 수” 유형을 정의하고 횟수를 세는 스캐너다.
sim_mighty_oddities.js 계통 스캐너 — 이상해 보이는 수 5종을 센다
sim_mighty_friendpick.js 친구 지목 분포와 이상 유형
sim_mighty_nt.js NT(노 기루다) 강제 자가대국
sim_mighty_dev_vs_main.js dev 봇 vs main 봇 한 테이블
여기서 중요한 원칙이 하나 있었다. 판정은 봇 자신의 판정 함수로 한다. “이 트릭을 우리가 가져갈 수 있나?”, “이게 실질 최상위인가?” 같은 걸 스캐너에서 새로 구현하면, 그 판정이 틀렸을 때 버그가 아니라 스캐너를 디버깅하게 된다.
첫 결과는 이랬다.
| 유형 | 빈도 |
|---|---|
| 판돈 없는 트릭에 마이티 낭비 | 1000R 중 5회 |
| 상대가 가져갈 트릭에 점수 카드 헌납 | 600R 중 87회 (프렌드 역할만 15회) |
| 아군이 이긴 트릭에 자기 무늬 최상위 버리기 | 300R 중 26회(주공) / 119회(야당) |
“가끔”이 숫자가 됐다. 그리고 마이티 낭비 5건은 전부 트릭1 · 프렌드카드=마이티 · 미공개 형태였다 — 유저가 본 바로 그 장면이다.
오탐부터 걸러야 한다
스캐너를 처음 돌리면 오탐이 진짜보다 많다. 실제로 겪은 것들:
- 친구 지목 이상 7건 → 실제 0건. 마이티는 보통 ♠A인데 기루다가 스페이드면 ♦A로 바뀐다. 이 규칙을 안 넣으면 정상 지목이 전부 “무늬도 없는데 지목했다”로 잡힌다. 7건이 전부 이거였다.
- “점수 카드 리드 헌납” 162회 → 실제 0회. 같은 무늬의 낮은 카드가 있었는지까지 봐야 한다. 다른 무늬 잡패가 있는 건 그 무늬를 여는 선택일 수 있어 실수가 아니다.
- “판돈 0인데 마이티 리드” — 리드는 판돈이 항상 0이다. 판정 자체가 성립하지 않는다.
오탐을 안 걷어내면 “87회 중 80회가 오라클 경로”처럼 원인을 좁히는 숫자를 못 만든다.
2단계 — 원인은 대개 “판정 기준이 너무 엄격”
마이티 낭비의 범인은 _isSafeFriendWinner였다. 이건 뒤에 남은 상대가 한 명이라도 받아칠 수 있으면 불합격으로 본다. 그런데 기루다가 한 장도 안 빠진 첫 트릭에서는 리드 무늬 에이스마저 “안전하지 않다”가 된다. 그래서 “안전한 승리 카드 없음”으로 떨어지고, 마이티가 강제로 나갔다.
고친 방향은 기준 완화가 아니라 대체 경로 추가다.
// 트릭에 깔린 점수가 1점 이하이고 손패가 4장 이상이면
// 마이티 대신 그 무늬 최상위로 받는다.
function topOfSuitInsteadOfMighty(hand, trick, ctx) { /* ... */ }
최상위가 지는 경우는 러프/조커뿐이고 그때 잃는 건 많아야 몇 점이다. 반면 마이티를 남기면 나중에 큰 트릭을 확실히 가져온다. 판돈이 이미 커졌거나 손패가 얼마 안 남아 아낄 이유가 없으면 예전대로 쓴다.
경로가 하나가 아니다
여기서 한 번 더 걸렸다. 봇의 결정 경로가 셋이었다.
- 휴리스틱 (
governmentFollow) - mixoracle 하드룰 (rule 9)
- 오라클 — 모든 손패를 보고 롤아웃으로 고른다
세 군데를 다 막아야 한다. 특히 오라클은 “어차피 잘린다”까지 계산해서 마이티를 골랐다. 오라클은 규칙으로 못 이기니 출력을 검열하는 패턴을 쓴다(_mightyWasteCensorRule).
점수 카드 헌납도 똑같았다 — 87건 중 80건이 오라클 경로였다. 롤아웃 평가가 1점 차이를 자주 놓친다.
그리고 어떤 룰이 그 수를 냈는지 추적할 수 있어야 원인을 짚는다. 하드룰 목록을 배열+이름으로 바꾸고 트레이스를 붙였다(__mightyRuleTrace / __mightyPathTrace). 이게 없으면 “고쳤는데 아직 나온다”에서 다음 수를 못 둔다.
예외를 명시적으로 남긴다
cheapDiscardInsteadOfPoint()를 만들면서 안 건드리는 경우를 주석이 아니라 코드로 박았다.
- 이기려고 내는 카드
- 봇이 아는 한 우리 팀이 가져갈 트릭 — 아군에게 점수를 싣는 건 정상이다
- 값싼 대안이 러퍼 기루다뿐일 때 — 그 러프 한 번이 점수 카드 하나보다 크다
- 엔드게임 솔버 출력 — 정확 계산이라 검열 대상이 아니다
두 번째가 특히 중요하다. 야당은 프렌드가 공개되기 전까지 “주공 말고는 우리편” 으로 본다. 이 정보 게이트를 그대로 따라야 한다 — 봇이 모르는 걸 아는 것처럼 두면 안 된다. 강해지지만 사람과 다른 종류의 존재가 된다.
3단계 — 고쳤으면 붙여본다
횟수가 줄었다고 강해진 건 아니다. 그래서 main 봇을 그대로 꺼내 dev 봇과 한 테이블에 앉혔다.
# main 트리를 통째로 꺼내 별도 모듈 트리로 로드한다
git archive main server/game/mighty | tar -x -C "$dir" --strip-components=3
엔진·딜·규칙은 공유하고 판단 함수만 좌석별로 다른 모듈 트리에서 가져온다. 같은 딜을 두 진영이 똑같이 받는다.
2500딜 ×2 (미러)
dev 주공성공 65.1% 평균점수 +0.006
main 주공성공 63.5% 평균점수 -0.133
차이 +0.140 (널 검증 -0.011)
함정 1 — A/A 가 +0.264 로 나왔다
처음엔 딜당 한 번만 쳤다. 그랬더니 같은 코드끼리 붙인 A/A에서 +0.264가 나왔다. 마이티는 5인이라 캠프가 3석/2석으로 갈리는 편향이 있다.
같은 딜을 캠프를 뒤집어 두 번 치도록 고치니 A/A가 -0.005까지 내려갔다. 이 널(null) 검증을 안 하면 노이즈를 개선으로 읽는다. 이후 모든 A/B에 A/A를 나란히 돌렸다.
함정 2 — 점수로 분해가 안 되는 수정이 있다
마이티 낭비 수정의 A/B는 -0.039점/좌석라운드였다. A/A가 +0.034라 노이즈 폭 안이다. 실패한 걸까?
아니다. 1000라운드에 5회 나오는 상황은 집계 점수로 애초에 분해가 안 된다. 이건 강도 변화가 아니라 행마 품질 수정이다. 같은 이야기가 dumpSafe 수정에서도 나왔다.
주공: 행마 26회 → 4회 (300R). 점수 -0.001 (널 +0.009) = 중립
야당: 행마 119회 → 50회 (300R). 점수 +0.061 (널 -0.010) = 실이득
주공 쪽이 중립인 게 오히려 자연스럽다. 나중에 그 A로 트릭을 가져오면 같은 점수를 다른 트릭에서 먹는다. 선(先)을 쥐는 이점은 총점에 안 잡힌다. 야당만 이득이 난 건, 프렌드 공개 전 “주공 말고는 우리편” 가정 때문에 숨은 프렌드에게 A를 넘겨주던 경우가 섞여 있어서다.
측정 지표를 점수 하나로만 두면 이런 수정은 전부 “효과 없음”이 된다. 행마 횟수와 점수를 같이 봐야 한다.
4단계 — 사람 눈이 틀린 경우 (되돌린 것들)
스캐너가 잡아준 것 중 둘은 고쳤다가 되돌렸다.
- 마지막 순번에 조커 대신 평범한 승리수 쓰기 (12회/300R)
- 아군이 잠근 트릭을 마이티/조커로 덮지 않기 (25회/300R)
둘 다 사람 눈에는 명백한 낭비다. 그런데 켜고 A/B를 돌리니 -0.108(널 +0.023), 주공 성공률 63.7% vs 64.2%. 더 약해졌다.
이유는 오라클이 완전정보로 계산한 이득이었기 때문이다. 선을 누가 쥐느냐, 점수 카드를 언제 현금화하느냐 — 사람이 한눈에 못 세는 부분에서 값을 뽑고 있었다. 코드에 남기지 않았고, 스캐너의 이 두 항목은 “고칠 것”이 아니라 “확인된 정상” 으로 문서에 박아뒀다.
이게 이번 작업에서 제일 중요한 교훈이다. “사람 눈에 이상한 수” 는 가설이지 결론이 아니다. 스캐너는 가설을 만들어주고, 판정은 A/B가 한다.
다만 예외가 있다. 사람이 앉는 판에서만 발생하는 위험은 자가대국으로 값이 안 매겨진다.
사람 주공은 프렌드가 조커를 든 줄 모르고 조커콜을 쏜다. 그러면 우리 조커가 헛되이 끌려나간다. 그래서 프렌드가 먼저 자기 리드에서 조커를 값나가게 쓰도록 했는데, 롤아웃은 주공을 손패를 다 아는 봇으로 놓고 굴리기 때문에 “주공이 모르고 쏠 수 있다”는 위험 자체를 값으로 못 매긴다. 실제로 1500라운드에서 11번 감지 중 2번만 수락했다.
이 경로만 롤아웃 승인을 건너뛰게 했다. 자가대국 A/B는 -0.001(널 +0.004) — 손해 없이 사람 상대 보호를 얻는다.
5단계 — 자가대국으로 검증이 안 되는 영역
노 기루다(NT) 로직을 고쳐야 했는데, 봇이 NT를 아예 안 부른다. 1500라운드에 0판. 자가대국으로는 한 줄도 검증이 안 된다.
그래서 입찰만 가로채 NT를 강제하는 시뮬레이터를 따로 만들었다. 여기에도 함정이 있었다.
손패와 무관하게 아무나 NT를 부르게 하면 주공 성공률이 **26%**로 떨어지고, 결론의 부호까지 뒤집힌다. A/K가 많은 좌석이 부르게 해야 54% 로 현실에 가까워진다.
강제 시뮬레이션은 “그 상황을 만든다”가 아니라 “그 상황을 현실적인 분포로 만든다” 여야 한다. 이걸 맞춘 뒤 측정한 NT 리드 순서 수정은 발동 1967회에 42.9% vs 43.0%(널 -0.10%p) — 점수 중립, 스펙 준수 수정이었다.
그리고 자가대국으로 재현이 안 되는 건 골든 회귀로 박는다. 실제로 나온 국면(또는 손으로 세운 상태)을 저장해두고, legacy 게이트로 돌리면 반드시 실패하는지까지 확인한다. 이 작업 동안 골든이 13개에서 29개로 늘었다.
곁다리 — 벤치마크가 +24% 느리다고 했는데 사실 아니었다
검열 규칙을 휴리스틱 follow의 마지막 관문에 걸었더니, 그게 오라클 롤아웃의 정책이라 한 번의 결정마다 수천 번 호출됐다. 그 안에서 game.getLegalCards()를 다시 계산하고 있었다(호출부가 이미 갖고 있는 값이다).
넘겨받도록 고치고 측정했는데, 처음엔 +24% 가 나왔다. 실제 원인은 다른 데 있었다.
한 프로세스에서 dev → main 순으로 재면 먼저 도는 쪽이 JIT 워밍업 비용을 다 낸다.
워밍업 구간을 따로 돌리고 프로세스를 나눠서 다시 재니 이렇게 됐다.
400라운드 ×2, 프로세스 분리
수정 전 +7 ~ +17%
수정 후 +1.7% / 0%
성능 측정은 코드보다 측정 환경이 먼저 틀린다.
티츄 쪽에도 같은 방법을 썼다
마이티만의 이야기는 아니다. 티츄 봇의 봉황 단독 판단이 고정 임계값이었다.
// before: 10 이상이면 낸다
if (lastValue >= 10) playPhoenixSingle();
10 위에 봉황을 내면 10.5가 된다. 아직 안 나온 J·Q·K·A 아무 장에나 바로 잡히니 선도 못 잡고 -25점 카드만 넘겨주는 자리다.
고정 임계값 대신 남은 상위 카드를 센다. 내 손패·걷힌 트릭·현재 트릭에서 안 보인 카드가 곧 남들 손에 있는 카드다 — 사람도 세는 정보다. 그중 봉황을 넘을 수 있는 게 2장 이하일 때만 선을 지킬 수 있다고 본다.
여기에 조건을 하나 더 얹었다. 선을 지켜도 그 트릭을 우리가 가져가면 봉황의 -25도 같이 먹는다. 그래서 트릭이 25점 이상이거나, 곧 손을 털 수 있거나, 상대가 곧 나가는 상황일 때만 낸다.
정리 — 방법만 남기면
- 디버깅 전에 계측을 만든다. “가끔”을 숫자로 바꾸지 못하면 고쳤는지도 모른다.
- 판정은 봇 자신의 함수를 재사용한다. 새로 짜면 스캐너를 디버깅하게 된다.
- 오탐부터 걷어낸다. 처음 나온 숫자는 대체로 틀렸다.
- A/A 널 검증 없이 A/B를 믿지 않는다. 편향은 +0.264까지도 나온다.
- 점수 하나로만 보지 않는다. 행마 횟수로만 잡히는 품질 수정이 있다.
- 사람 눈에 이상한 수는 가설이다. 두 개는 실제로 내가 틀렸다.
- 자가대국이 못 만드는 상황(NT, 사람 상대)은 강제 시뮬레이터 + 골든으로 따로 지킨다. 강제할 땐 분포까지 현실적으로.
- 성능 측정은 프로세스를 나눈다. JIT 워밍업이 24%를 만든다.
관련 릴리즈 노트: 티츄온라인 v2.8.2 · 같은 배포 사이클의 서버 작업은 무중단 배포 중 매치 이관에 따로 적었다.