훌라 CPU 플레이어 만들기 Part 8: 처음 보는 판에서 재다
이 글은 Claude Fable 5.1 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
Part 7 에서 추 열한 개를 하나씩 빼 보고, 조정하고, 갈리는 자리를 확인했습니다. 이번 편은 세 가지로 끝맺습니다. 값을 동결 하고, 한 번도 쓰지 않은 판 에서 합격 여부를 재고, Part 4 의 여덟 자리를 결산 합니다.
1. 동결 — 실험이 고른 값을 그대로 쓰지 않은 자리#
Part 7 의 조정은 두 걸음을 옮겼습니다. 땡큐 템포 추를 0 으로, 상대 손패 치우침을 0.25 에서 0.5 로. 동결한 값은 이렇습니다.
| 추 | 조정이 고른 값 | 동결한 값 |
|---|---|---|
| 땡큐 템포 | 0 | 추 자체를 삭제 |
| 상대 손패 치우침 | 0.5 | 0.25 유지 |
| 나머지 아홉 | 처음 값 | 처음 값 |
두 자리 모두 조정 결과와 다릅니다. 이유가 서로 다릅니다.
0 으로 두지 않고 지운다#
템포 추는 빼 보기, 조정, 그림자 저울이 모두 같은 방향을 가리켰습니다. 있으면 조금 나쁘고, 없애면 창 2,300개 중 26개만 바뀝니다. 그러면 값을 0 으로 두면 될 것 같은데, 추 자체를 지웠습니다.
값이 0 인 채로 남겨 두면 “왜 0 인가” 를 아는 사람만 알고, 다음 조정이 그 축을 다시 탐색합니다. 지우면 그런 일이 없습니다. 땡큐 판단은 Part 6 의 두 값을 그대로 비교하는 것으로 남았습니다.
같은 이유로 “덮지 못하는 카드의 값” 추도 지웠습니다. Part 7 에서 16,394번의 결정에서 갈린 자리가 0 이었던 그 추입니다. 지우기 전에 한 번 더 확인했습니다. 무게를 0.15 에서 3.0 으로, 스무 배 키우고 그림자 저울을 돌렸습니다. 16,702번의 결정에서 갈린 자리가 여전히 0 이었습니다. “다른 추에 눌린다” 보다 강한 사실입니다. 이 추가 재는 값은 같은 결정 안의 후보들 사이에서 순서를 바꿀 만큼 갈리지 않습니다. 완전히 관성입니다. 지워도 봇의 행동이 하나도 안 바뀌므로 아래의 검증을 다시 돌릴 필요도 없었습니다.
실험이 고른 값을 일부러 안 쓴다#
치우침은 반대입니다. 실험은 0.5 가 낫다고 했고, Part 7 의 그림자 저울은 그것이 잡음이 아니라 스톱박의 꼬리를 피한 것이라고 확인했습니다. 그런데도 0.25 를 그대로 두었습니다.
이유는 실험 밖에 있습니다. 실제로 플레이해 보니 봇이 일반 스톱을 거의 걸지 않았습니다. 0.25 에서도 손패가 한 장 남았을 때조차 뽑기를 골랐고, 0.5 는 그것을 절반으로 더 줄입니다.
| 치우침 | 스톱 선언율 | 스톱박 비율 | 판당 성적 |
|---|---|---|---|
| 0 | 52.7% | 36.3% | +6.5 |
| 0.2 | 24.9% | 19.2% | +10.1 |
| 0.25 | 21.3% | 15.9% | +10.2 |
| 0.3 | 16.3% | 11.9% | +10.3 |
| 0.5 | 10.9% | 3.1% | +10.5 |
0.2 에서 0.5 사이에서 성적 차이는 판당 0.4점 입니다. 그런데 스톱 선언율은 두 배 넘게 갈립니다.
일반 스톱은 이 게임에서 변수를 만드는 장치입니다. 봇이 걸지 않으면 사람은 스톱박이라는 규칙이 있는 줄도 모른 채 판을 끝냅니다. 강한 봇이 재미없는 봇이 되는 자리입니다. 판당 0.4점은 그 재미의 대가로 쌉니다.
flowchart LR
A["조정이 보는 것<br/>판당 정산 포인트 하나"] --> B["늘 스톱을 덜 거는<br/>쪽을 고른다"]
C["조정이 못 보는 것<br/>재미"] --> D["사람이 값을<br/>눌러 둔다"]
B --> E["0.5"]
D --> F["0.25"]
style A fill:#87CEEB,color:#000000
style B fill:#FFD700,color:#000000
style C fill:#FF9999,color:#000000
style D fill:#FFD700,color:#000000
style E fill:#D3D3D3,color:#000000
style F fill:#90EE90,color:#000000
이것은 실험을 무시한 것이 아니라 목적 함수의 한계 를 메운 것입니다. 조정이 보는 것은 판당 포인트 하나뿐이라 재미를 볼 수 없고, 그래서 늘 스톱을 덜 거는 쪽을 고릅니다. 값을 사람이 눌러 둔 것은 그 눈먼 자리를 메운 것입니다. 이 판단은 코드 주석과 동결 문서 두 곳에 같은 표와 함께 적어 두었고, 동결한 값은 테스트가 고정합니다. 바꾸려면 근거 문서를 함께 고쳐야 합니다.
동결한 뒤 조정용 seed 에서 다시 재니 판당 +10.27 이었습니다. 이 숫자는 아직 합격의 근거가 아닙니다. 시험 문제로 공부한 값이기 때문입니다.
2. 시험 문제를 열기 전에 합격선을 정한다#
이제 한 번도 쓰지 않은 딜 에서 잽니다. 조정에 쓴 seed 와 절대 겹치지 않는 별도의 이름 공간에서 딜을 뽑습니다.
중요한 것은 순서입니다. 시험 문제를 열기 전에 규모와 합격선을 못 박고, 연 뒤에는 고치지 않습니다. 열고 나서 정하면 결과를 보고 기준을 고른 것이 됩니다.
| 미리 정한 것 | 값 |
|---|---|
| 딜 수 | 256 |
| 칸 | 3·4·5인 × 3·5·10판 = 9칸 |
| 스톱 빈도 | 높음 (제품 기본값) |
| 칸마다 통과 조건 | 차이의 95% 신뢰구간 하한 이 −2.0 보다 위 |
| 전체 통과 조건 | 차이가 0 보다 크고 신뢰구간 하한도 0 보다 위 |
| 건강 조건 | 정책 오류·시간 초과·상한 도달 전부 0 |
| 실행 횟수 | 한 번. 실패하면 그 딜은 폐기하고 새 이름 공간에서 다시 |
칸마다 −2.0 이라는 한계는 “어느 인원·판 수에서도 easy 보다 판당 2점 이상 나쁘지 않아야 한다” 는 뜻입니다. 렉시오에서는 이 값이 −0.2 였는데 복사하지 않았습니다. 훌라는 등수 사다리에 4배·8배 배율과 박이 곱해져 정산의 크기 자체가 다릅니다. 첫 측정의 분산을 보고 우위 +10 의 20% 로 잡았습니다.
판정은 평균이 아니라 신뢰구간의 하한 으로 합니다. 운 좋게 평균이 높은 것으로는 통과하지 못합니다.
돌리기 전에 병목부터#
256 딜에 9칸이면 11만 판입니다. 돌리기 전에 얼마나 걸릴지 재 보니 이상한 것이 보였습니다. 일꾼을 늘리면 오히려 느려졌습니다. 일꾼 4개에 초당 49.8판, 8개에 40.1판, 14개에 36.9판이었습니다. CPU 는 바쁜데 처리량은 떨어지니 무언가 서로 밟고 있는 것입니다.
메모리 정리 주기를 늦추자 32% 빨라졌고, 그래도 일꾼을 늘리면 느려지길래 프로세스를 셋으로 나누고 각각 일꾼 4개 를 주었습니다. 초당 107.6판으로 1.7배가 됐습니다. 이 설정으로 256 딜이 약 17분입니다. 512 딜은 34분이었지만 어느 한 칸도 10분을 넘지 않는 쪽을 골랐습니다.
그리고 Part 7 의 40분짜리 조정이 중간에 죽어 날아갔던 경험대로, 칸이 끝날 때마다 결과를 저장 합니다.
3. 결과#
통과했습니다. 실행 시간 17.8분, 110,592판, 봇의 결정 3,815,537번에서 건강 지표 전부 0.
| 값 | |
|---|---|
| 전체 차이 (판당) | +10.09 |
| 95% 신뢰구간 | +9.88 ~ +10.31 |
| 가장 낮은 칸의 하한 | +3.36 |
칸별로는 이렇습니다.
| 인원 | 3판 | 5판 | 10판 |
|---|---|---|---|
| 3인 | +3.6 | +3.6 | +3.8 |
| 4인 | +8.2 | +8.2 | +8.3 |
| 5인 | +18.1 | +18.4 | +18.6 |
flowchart LR
A["3인<br/>+3.6"] --> B["4인<br/>+8.2"]
B --> C["5인<br/>+18.6"]
style A fill:#90EE90,color:#000000
style B fill:#FFD700,color:#000000
style C fill:#FF9999,color:#000000
읽을 것이 둘입니다.
인원이 늘수록 hard 가 거의 두 배씩 셉니다. 상대가 많을수록 easy 가 흘리는 카드가 많아지고, 미등록박·세븐박·독박으로 갈 수 있는 자리도 늘어납니다. Part 1 의 등수 사다리가 5인에서 가장 길다는 것도 한몫합니다.
판 길이는 거의 아무것도 바꾸지 않습니다. 4인에서 3판·5판·10판의 차이가 8.20, 8.23, 8.26 입니다. 우위가 판마다 고르게 남고, 판 수는 흩어짐만 줄입니다.
외운 것은 아닌가#
조정용 seed 에서 +10.27, 처음 보는 seed 에서 +10.09. 차이 0.17 입니다. 조정이 자기 seed 를 외웠다면 처음 보는 seed 에서 눈에 띄게 낮아야 하는데 그렇지 않습니다.
스톱 빈도를 표준으로 바꾼 별도 실행에서는 +9.32 로 조금 작았습니다. 기준선이 엄해 스톱 기회가 줄어서입니다. 재미있는 것은 기회가 줄었는데 선언율은 올랐다 는 점입니다. 3인에서 31% 가 36% 로. 문턱이 엄하면 넘기만 하면 걸 값어치가 있는 자리가 대부분이기 때문입니다.
봇이 실제로 한 일#
| 3인 | 4인 | 5인 | |
|---|---|---|---|
| 일반 스톱 선언율 | 31% | 23% | 18% |
| 스톱박 비율 | 7~10% | ||
| 땡큐 부른 비율 | 91~94% | ||
| 결정 시간 (100번 중 99번) | 2.2~2.4ms |
상대가 많을수록 누군가 먼저 끝낼 확률이 커지므로 스톱을 덜 겁니다. 결정 시간은 가장 오래 걸린 한 번이 46ms 로 100ms 예산 안이었습니다.
정직하게 적어 둘 것 둘#
첫째, 규모를 정할 때 계산을 틀렸습니다. 칸마다 신뢰구간의 반폭을 ±6.6 으로 예상했는데 실제는 ±0.15 에서 ±1.3 이었습니다. 첫 측정의 매치 총점 표준편차를 판 수로 나누지 않고 그대로 쓴 착오입니다. 결정은 그대로 옳았습니다. 256 딜은 필요한 것보다 정밀도가 남았을 뿐이고, 남는 정밀도는 버려지지 않습니다. 다음에 규모를 잡을 때는 판당 눈금으로 환산한 뒤 세야 합니다.
둘째, 순서가 뒤집혔습니다. 원래 계획은 이 검증을 통과한 뒤 혼자하기의 기본 봇을 hard 로 올리는 것이었습니다. 실제로는 베타 사용자가 이미 시험하고 있어서 기본 봇 승격과 공개가 이 검증보다 먼저 이루어졌습니다. 그래서 이 검증은 “승격할 것인가” 가 아니라 “이미 배포된 봇이 기준을 만족하는가” 의 사후 확인이 되었습니다. 통과했으니 결과는 같지만, 통과하지 못했다면 되돌리는 배포가 필요했을 것입니다.
4. 여덟 자리 결산#
Part 4 에서 정리한 easy 봇의 여덟 자리입니다. hard 봇이 각각을 어떻게 했는지 결산합니다.
flowchart LR
H0[" "]:::hdr
H1["메웠다"]:::hdr
H2["일부만"]:::hdr
H3["후보로만"]:::hdr
R1["자기 차례"]:::hdr
A["① 한 수 greedy<br/>② 등록·붙이기 순서<br/>⑥ 공개 카드 기억"]
B["⑤ 버림패 선택<br/>⑦ 상대 종료 위험"]
C["③ 등록 중단<br/>④ 7 전략 보유"]
R2["선언"]:::hdr
D["⑧ 땡큐·확정 스톱"]
E["⑧ 일반 스톱"]
F[" "]:::hdr
H0 ~~~ H1 ~~~ H2 ~~~ H3
R1 ~~~ A ~~~ B ~~~ C
R2 ~~~ D ~~~ E ~~~ F
classDef hdr fill:none,stroke:none,color:#dddddd
style A fill:#90EE90,color:#000000
style B fill:#FFD700,color:#000000
style C fill:#D3D3D3,color:#000000
style D fill:#90EE90,color:#000000
style E fill:#FFD700,color:#000000
| 자리 | 결과 | 근거 |
|---|---|---|
| ① 한 수 greedy | 메웠다 | 턴 전체를 그려 본다. 2♥ 3♥ 4♥ 4♣ 4♦ 4♠ K♠ 에서 훌라 |
| ② 등록을 붙이기보다 먼저 | 메웠다 | 등록·붙이기·중단이 같은 저울에 오른다 |
| ③ 등록·붙이기 중단 | 후보로만 | “여기서 그만둔다” 가지는 있으나 저울이 고른 기록은 드물다 |
| ④ 7 전략적 보유 | 후보로만 | 추 둘이 5만 결정에서 수를 하나도 안 바꿨다. 사실상 즉시 등록 |
| ⑤ 버림패의 카드값만 | 일부만 | 카드값·조합 조각·죽은 카드는 본다. 땡큐 위험은 안 본다 |
| ⑥ 공개 카드 기억 | 메웠다 | 장부 |
| ⑦ 상대 종료 위험·점수 구조 | 일부만 | 스톱 판단은 등수 사다리와 남은 장수를 본다. 버리기 판단은 안 본다 |
| ⑧ 땡큐·스톱 | 대부분 메웠다 | 땡큐 92~93%, 확정 스톱 무조건, 일반 스톱은 기대값. 곧 끝낼 국면의 스톱은 못 건다 |
여덟 중 셋을 온전히 메웠고, 둘은 일부만, 둘은 후보로만 존재하며, 하나는 대부분 메웠습니다. 그런데도 easy 보다 판당 10점을 더 법니다. Part 4 의 끝에서 “여덟 개를 다 메우면 강한 봇이 되는가” 를 물었는데, 답은 “다 메우지 않아도 강한 봇이 되고, 메운 줄 알았던 것이 실은 아무 일도 안 하고 있을 수 있다” 입니다. 후자는 재 보지 않았으면 몰랐을 것입니다.
5. 남은 것#
봇 단계는 여기서 끝났습니다. 남겨 둔 구멍을 적어 둡니다.
- 버림패의 땡큐 위험. Part 5 §5 에서 적은 대로, 이 봇은 던지는 카드가 상대의 땡큐를 완성시키는지 보지 않습니다. 좌석마다 확률을 따로 구해 합치는 계산이 렉시오에서 봇을 망친 전례가 있어, 정확한 계산 없이는 넣지 않았습니다.
- 곧 끝낼 국면의 일반 스톱. Part 6 §5 의 구조적 한계입니다. 기다림의 값이 한 턴만 봅니다.
- 손잡이 하나가 두 일을 합니다. 상대 손패 치우침이 실제로 하는 일은 스톱박의 꼬리를 피하는 것입니다. 비대칭을 기대값 식이 직접 다루도록 고치는 편이 옳습니다.
- 재미는 목적 함수 밖에 있습니다. §1 에서 사람이 값을 눌러 둔 그 자리입니다. 스톱 빈도를 목적 함수나 제약으로 넣는 것이 다음 조정의 숙제입니다.
마치며#
이 블로그에서 CPU 플레이어 시리즈는 세 번째입니다. 달무티 에서는 hard 봇이 easy 보다 약했고, 렉시오 에서는 hard 봇이 아무것도 하지 않았습니다. 훌라에서는 처음 만든 hard 봇이 손으로 정한 값 그대로 easy 보다 셌습니다.
운이 아니라 앞선 두 번의 실패가 미리 막아 준 것입니다. 즉시 승리를 저울에 올리지 않은 것, 시뮬레이터를 봇보다 먼저 만든 것, 좌석마다 확률을 곱하지 말라고 설계 문서에 경고를 적어 둔 것 모두 앞의 두 시리즈에서 배운 것입니다.
그래도 재 보기 전에는 몰랐던 것이 남았습니다. 추 넷이 놀고 있었고, 스톱 확률이 틀려 있었고, 실험이 고른 값이 재미없는 봇을 만들었습니다. 봇을 만드는 것보다 봇이 무엇을 하고 있는지 알아내는 것이 어렵다는 사실은 세 번째에도 같았습니다.
시리즈 목록#
- 훌라 CPU 플레이어 만들기 Part 1: 룰북이 없는 게임의 룰을 정한다
- 훌라 CPU 플레이어 만들기 Part 2: 봇이 봐도 되는 것
- 훌라 CPU 플레이어 만들기 Part 3: 규칙 다섯 줄짜리 easy 봇
- 훌라 CPU 플레이어 만들기 Part 4: easy 봇이 보지 못하는 여덟 자리
- 훌라 CPU 플레이어 만들기 Part 5: 한 턴을 통째로 그려 보는 hard 봇
- 훌라 CPU 플레이어 만들기 Part 6: 남의 차례에 끼어들고 판을 접는 판단
- 훌라 CPU 플레이어 만들기 Part 7: 저울의 추를 하나씩 빼 보니
- 훌라 CPU 플레이어 만들기 Part 8: 처음 보는 판에서 재다 (이 글)