훌라 CPU 플레이어 만들기 Part 6: 남의 차례에 끼어들고 판을 접는 판단
이 글은 Claude Fable 5.1 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
Part 5 에서 hard 봇이 자기 차례에 한 턴을 통째로 그려 보고 저울로 재는 방법을 다뤘습니다. 이번 편은 Part 4 의 마지막 자리, 땡큐와 스톱 입니다.
둘은 성격이 같습니다. 해도 되고 안 해도 되는 선언 이고, 잘못하면 값이 큽니다. 땡큐는 미등록 상태를 잃고, 스톱은 실패하면 남들 몫까지 혼자 뭅니다. 그런데 구현의 성격은 정반대였습니다. 땡큐는 판단은 쉬웠는데 봇을 깨우는 배선 이 문제였고, 스톱은 배선은 이미 있었는데 확률 이 문제였습니다.
1. 땡큐 — 가져갈 것인가#
땡큐를 부를지 정하려면 두 가지를 견줘야 합니다. 가져갔을 때와 안 가져갔을 때입니다.
가져갔을 때는 쉽습니다. 버림패를 손에 넣고, 그 카드가 들어간 조합을 등록한 상태에서 Part 5 의 계획을 그대로 돌리면 됩니다. 새 탐색기가 필요 없습니다. 땡큐로 시작한 턴도 한 턴이니, 그 턴을 끝까지 그려 보고 남는 손패를 저울로 재면 그것이 “가져갔을 때의 값” 입니다.
함정 — 무엇과 견주는가#
문제는 “안 가져갔을 때” 를 무엇으로 재느냐입니다.
가장 먼저 떠오르는 것은 지금 이 손패 그대로 입니다. 그런데 그렇게 하면 땡큐가 언제나 이깁니다. 가져간 쪽은 한 턴을 진행해 손패가 줄어든 상태이고, 안 가져간 쪽은 아무것도 안 한 상태이기 때문입니다. 정지한 사람과 한 걸음 걸은 사람의 위치를 비교하면 걸은 쪽이 늘 앞섭니다.
이것은 Part 4 에서 경계했던 “합법이면 무조건 땡큐” 가 값 비교의 모양을 하고 돌아온 것입니다.
flowchart LR
H0[" "]:::hdr
H1["잘못된 비교"]:::hdr
H2["올바른 비교"]:::hdr
R1["가져간다"]:::hdr
A["땡큐 턴을 끝까지<br/>두고 남는 손패"]
B["땡큐 턴을 끝까지<br/>두고 남는 손패"]
R2["안 가져간다"]:::hdr
C["지금 손패 그대로<br/><br/>언제나 진다"]
D["지금 손패로 내 차례를<br/>끝까지 두고 남는 손패"]
H0 ~~~ H1 ~~~ H2
R1 ~~~ A ~~~ B
R2 ~~~ C ~~~ D
classDef hdr fill:none,stroke:none,color:#dddddd
style A fill:#D3D3D3,color:#000000
style B fill:#90EE90,color:#000000
style C fill:#FF9999,color:#000000
style D fill:#90EE90,color:#000000
그래서 안 가져갔을 때도 “지금 손패로 내 차례를 끝까지 두는 것” 으로 잽니다. 그러면 양쪽 다 한 턴을 진행한 상태가 되어 같은 자로 잴 수 있습니다. 내 차례에는 카드를 한 장 뽑지만 무엇을 뽑을지 모르므로 뽑기는 세지 않습니다. 가져가는 쪽도 카드 한 장을 얻는 것이니 한쪽으로만 기울지는 않습니다.
결정 규칙#
이제 규칙은 짧습니다.
- 가져가서 손패가 비는 선택지가 있으면 값을 보지 않고 가져갑니다. Part 5 의 “손패를 비울 수 있으면 무조건” 이 여기에도 적용됩니다.
- 아니면 가져갔을 때의 값이 안 가져갔을 때의 값보다 클 때만 가져갑니다. 같으면 안 가져갑니다.
땡큐로 미등록 상태를 잃는 대가는 따로 세지 않습니다. Part 5 의 저울에 “미등록으로 끝남” 과 “미등록 + 7 보유의 훌라 가능성” 이 이미 있기 때문입니다. 가져가는 쪽은 등록 상태라 그 두 추가 사라지고, 안 가져가는 쪽은 그 추가 남습니다. 같은 이득을 두 번 세지 않습니다.
시뮬레이터로 재 보면 hard 봇은 자격이 생긴 창의 92~93% 에서 땡큐를 부릅니다. “합법이면 무조건” 은 아니지만 매우 높습니다. 훌라에서 카드를 한 장 더 갖고 차례까지 가져오는 값이 실제로 그만큼 큰 것입니다. 남은 7~8% 의 거절이 판단인지 우연인지는 Part 7 에서 따로 확인합니다.
처음에는 여기에 추를 하나 더 얹었습니다. 버린 사람과 나 사이의 자리를 건너뛰고 내 차례를 앞당기는 이득을 “템포” 라는 이름의 상수로 가져가는 쪽에 더한 것입니다. 이 추는 Part 7 에서 제거됩니다. 있으나 없으나 결정이 거의 안 바뀌었고, 바뀐 몇 번은 없는 쪽이 나았습니다.
2. 배선 — 봇을 남의 차례에 깨운다#
판단을 만들고 시뮬레이터를 돌리자 봇은 창의 98% 를 가져갔습니다. 그리고 실제 서버에 올렸더니 한 번도 부르지 않았습니다.
Part 2 에서 예고한 그 문제입니다. 봇은 자기 차례가 됐을 때 서버가 깨워야 움직입니다. 땡큐 창이 열린 동안은 아무의 차례도 아니라 깨우는 일감이 없었습니다. 시뮬레이터는 판을 직접 굴리므로 창이 열릴 때마다 봇에게 물어봤지만, 서버에는 그 길이 없었습니다.
창마다 일감 하나#
배선은 이렇게 깔았습니다.
flowchart TD
A["누군가 버림<br/>땡큐 창 열림"] --> B["0.1초 뒤<br/>훑기 일감 하나"]
B --> C["버린 사람 다음 자리부터<br/>봇에게 순서대로 묻는다"]
C --> D{"이 봇이<br/>부르겠다"}
D -->|"예"| E["서버에 선언 제출"]
D -->|"아니오"| F["판을 다시 읽고<br/>다음 봇"]
F --> C
E --> G{"1순위 자리"}
G -->|"예"| H["창 즉시 닫힘"]
G -->|"아니오"| F
I["창 마감 시계"] -.->|"시간 종료"| J["좌석 우선권으로<br/>임자 확정"]
style A fill:#87CEEB,color:#000000
style B fill:#FFD700,color:#000000
style C fill:#90EE90,color:#000000
style D fill:#FFD700,color:#000000
style E fill:#90EE90,color:#000000
style F fill:#D3D3D3,color:#000000
style G fill:#FFD700,color:#000000
style H fill:#FF9999,color:#000000
style I fill:#D3D3D3,color:#000000
style J fill:#FF9999,color:#000000
결정한 것이 다섯 가지입니다.
- 창마다 일감은 하나입니다. 봇마다 하나씩 걸면 마지막에 건 것이 앞의 것을 덮어씁니다. 방마다 봇 일감 자리가 하나뿐이기 때문입니다.
- 일감의 신원은 창 번호입니다. 판의 상태 버전이 아닙니다. 같은 창에서 사람이 선언하면 버전은 오르지만 창은 닫히지 않으므로, 버전으로 신원을 삼으면 멀쩡한 창을 낡은 것으로 오해합니다.
- 한 자리에 물을 때마다 판을 다시 읽습니다. 앞 봇의 선언으로 자격이 바뀔 수 있습니다.
- 사람 자리에는 묻지 않습니다. 물으면 그 사람 대신 봇이 선언하는 셈이고, 선언은 무를 수 없습니다.
- 창을 닫는 것은 훑기 일감이 아니라 마감 시계입니다. 훑기가 실패해도, 전원이 거절해도, 봇이 없는 방이어도 창은 제때 닫힙니다.
임자는 훑은 순서가 아니라 서버의 좌석 우선권 으로 정합니다. Part 1 의 규칙 그대로입니다. 순위가 낮은 봇이 먼저 선언했더라도 순위가 높은 사람이 창 안에 선언하면 사람이 가져갑니다.
100ms 가 둘#
여기에 100ms 짜리 시간이 두 개 등장합니다. 하나는 창이 열리고 봇이 반응하기까지의 지연 이고, 다른 하나는 Part 2 의 생각할 시간의 상한 입니다. 우연히 값이 같습니다. 이름과 기록을 갈라 두지 않으면 하나를 고치다가 다른 하나를 함께 옮기게 됩니다. 앞의 것은 사람 눈에 자연스러워 보이기 위한 값이고, 뒤의 것은 서버 안전을 위한 값이라 이유가 전혀 다릅니다.
배선을 깔자 드러난 것들#
배선이 없던 동안 잠복해 있던 서버 버그가 둘 나왔습니다. 땡큐를 부를 수 있는 행동 목록이 차례 검사 뒤 에서 만들어져 봇에게는 영영 열리지 않았고, 봇의 행동을 처리하는 곳에 땡큐 예외가 없었습니다. 둘 다 “봇이 땡큐를 걸어 본 적이 없어서” 드러나지 않았던 것입니다. 단위 테스트는 전부 통과하고 있었습니다.
그리고 로컬에서 hard 봇 방을 만들어 Q♣ 를 버렸더니, 봇이 가져가고 판이 멈췄습니다. 이유가 셋이었습니다.
- 1순위 봇이 가져가 창이 닫히자, 그다음 봇에게 물으려던 훑기가 “낡은 창” 을 오류로 돌려보냈고, 오류라서 화면 갱신도 다음 마감도 걸리지 않았습니다. 가져간 뒤의 낡음은 오류가 아니라 할 일이 끝난 것 으로 고쳤습니다.
- Part 1 의 판을 여는 첫 창 에는 훑기가 아예 걸리지 않았습니다. 그 창은 누군가의 버리기가 아니라 딜이 여는 것이라 배선이 닿지 않았습니다.
- 혼자하기 방의 일감이 멀티플레이 일감으로 기록되어, 실패했을 때 판을 끝내는 길로 가지 못했습니다.
셋 다 시뮬레이터에서는 나올 수 없는 종류입니다. 시뮬레이터는 창을 직접 열고 닫으니까요. “봇이 세다” 는 시뮬레이터로 재고, “봇이 판을 망가뜨리지 않는다” 는 서버에서 확인합니다. 둘은 다른 질문입니다.
3. 스톱 — 판을 접을 것인가#
스톱은 자기 차례에, 카드를 뽑기 전에 선언하므로 배선은 이미 있습니다. Part 1 에서 본 네 가지 중 셋은 판단이 필요 없습니다.
총통·로우·하이는 자격이 있으면 무조건 선언합니다. 확정 승리를 저울에 올리지 않는 것은 Part 5 의 “손패를 비울 수 있으면 무조건” 과 같은 종류의 규칙입니다. 자격 판정은 봇이 하지 않고 서버가 스냅샷에 실어 줍니다.
남는 것은 일반 스톱 입니다. 등록한 뒤 카드합이 기준선 이하일 때 걸 수 있고, 성공하면 1배 승리, 실패하면 스톱박입니다. “합법이면 건다” 도 “절대 안 건다” 도 답이 아니니 기대값 을 재야 합니다.
봇이 아는 것과 모르는 것#
| 아는 것 | 모르는 것 |
|---|---|
| 내 카드합, 내 손의 7 장수 | 어느 안 보이는 카드가 어느 상대 손에 있는가 |
| 상대마다 남은 장수와 등록 여부 | |
| 안 보이는 카드의 정확한 목록 (Part 5 의 장부) | |
| 덱에 남은 장수 |
모르는 것은 Part 2 의 공정성 경계 그 자체입니다. 그러니 모든 계산은 “상대의 손패는 안 보이는 카드 중에서 그 장수만큼 무작위로 뽑힌 것” 이라는 가정 위에 섭니다.
저울 양쪽에 올릴 것#
flowchart LR
S["지금 스톱을 건다"] --> S1["성공 확률 ×<br/>상대들이 낼 몫"]
S --> S2["실패 확률 ×<br/>내 몫 + 남들 몫<br/>스톱박"]
W["한 턴 더 기다린다"] --> W1["내가 먼저 끝낼 확률 ×<br/>상대들이 낼 몫"]
W --> W2["상대가 먼저 끝낼 확률 ×<br/>내가 낼 몫"]
S1 --> R{"스톱 쪽이<br/>1점 이상 좋은가"}
S2 --> R
W1 --> R
W2 --> R
R -->|"예"| D["스톱 선언"]
R -->|"아니오"| E["뽑는다"]
style S fill:#FFD700,color:#000000
style W fill:#87CEEB,color:#000000
style S1 fill:#90EE90,color:#000000
style S2 fill:#FF9999,color:#000000
style W1 fill:#90EE90,color:#000000
style W2 fill:#FF9999,color:#000000
style R fill:#FFD700,color:#000000
style D fill:#90EE90,color:#000000
style E fill:#D3D3D3,color:#000000
스톱 쪽. 성공하면 상대들이 Part 1 의 등수 사다리대로 냅니다. 등록한 상대는 예상 카드합이 낮은 순으로 2등부터, 미등록 상대는 맨 아래에서 두 배, 여기에 상대 손에 있을 7 의 기대 배수까지 곱합니다. 실패하면 선언자를 뺀 상대 중 예상 카드합이 가장 낮은 사람이 승자가 되고, 나는 내 몫에 다른 패자들 몫을 더해 혼자 냅니다. 내 몫에는 내 손의 7 배수가 붙습니다.
기다림 쪽. 다음 뽑기로 이번 턴에 손패를 비울 수 있는지를, 안 보이는 카드를 하나하나 손에 넣어 보고 Part 5 의 계획을 돌려 셉니다. 표본이 아니라 전수입니다. 상대가 먼저 끝낼 위험은 상대의 남은 장수로 어림합니다. 한 장 남은 상대는 넉넉히, 여섯 장 남은 상대는 조금만.
성공 확률. 상대 한 명의 카드합이 내 카드합보다 높을 확률을 구합니다. 동점은 실패이므로 “이상” 이 아니라 “초과” 입니다. 안 보이는 카드에서 그 장수만큼 뽑았을 때 합이 얼마가 되는 경우의 수를 정확히 셉니다. 안 보이는 카드가 많아야 45장, 상대 손패가 많아야 13장이라 컴퓨터에게는 순식간입니다. 그리고 상대 전원이 나보다 높을 확률은 상대별 확률을 곱해서 구합니다.
여기서 하나를 짚습니다. 상대들은 같은 안 보이는 카드를 나눠 갖으므로 서로 독립이 아닙니다. 한 명이 낮은 카드를 많이 들면 다른 사람은 그만큼 못 듭니다. 그런데도 곱합니다. 근사라는 것을 코드와 보고서에 명시한 채로 그렇게 합니다. 정확한 결합 분포는 상대 수만큼 차원이 늘어 100ms 안에 못 돌고, 그 정밀도가 결정을 바꾸는 판은 드물다고 봤습니다. 렉시오에서 봇을 망쳤던 실수는 어느 한 손에만 통째로 있을 수 있는 조합의 확률을 좌석마다 따로 구해 합친 것이었고, 그 오차가 최대 0.32 였습니다. 이번 봇은 상대 한 명의 카드합 분포는 정확히 세고 좌석 사이의 곱만 근사로 남깁니다. 그 근사가 얼마나 틀리는지는 직접 재지 않았습니다. 대신 §4 에서 봇이 믿은 성공률과 실제 성공률을 견주는데, 그 대조가 이 근사까지 포함한 전체 오차를 간접적으로 보여 줍니다.
결정. 스톱 쪽 기대값이 기다림 쪽보다 1점 이상 좋으면 선언합니다. 이 1점을 안전 여유라고 부릅니다.
세지 않기로 한 것#
“지금 안 걸어도 다음 턴에 또 걸 수 있다” 는 세지 않습니다. 처음 설계에는 이것이 들어 있었습니다. 그런데 넣어 보니 기다림의 값이 구조적으로 스톱의 값과 같아져, 결정이 “상대가 먼저 끝낼 확률” 하나에 매달렸습니다. 그 결과 상대들이 열 장씩 들고 있어 확실히 이기는 스톱 에서도 봇이 뽑기만 했습니다. 실제로는 판이 진행될수록 상대가 등록·붙이기로 손패를 줄여 성공 확률이 내려가므로, 나중 스톱을 지금과 같게 치는 것은 기다림의 과대평가입니다. 빼고, 그로 인한 스톱 쪽 치우침은 안전 여유가 잡습니다.
4. 확률이 틀렸다#
여기까지 만들어 시뮬레이터를 돌렸습니다. 결과가 이랬습니다.
| 값 | |
|---|---|
| 스톱 기회 | 602 |
| 선언 | 317 |
| 성공 | 202 |
| 스톱박 | 115 (36%) |
| 판당 성적 | 일반 스톱을 안 걸 때보다 2.8점 나쁨 |
건 스톱의 세 번에 한 번이 스톱박이었습니다. 일반 스톱을 아예 안 걸던 봇보다 판당 2.8점을 더 잃었습니다.
첫 가설은 “여유 1점이 너무 작다” 였습니다. 여유를 2, 3, 5, 8 로 올려 봤습니다. 스톱박 비율이 33~36% 에서 움직이지 않았습니다. 여유를 올리면 선언 수는 줄지만 건 것 중 실패하는 비율은 그대로였습니다.
문턱이 아니라 확률이 틀린 것 이었습니다.
봇은 “상대 손패는 안 보이는 카드에서 무작위로 뽑힌 것” 이라고 가정했습니다. 판이 시작될 때는 맞습니다. 그런데 판이 진행되면 상대는 매 턴 높은 카드를 버리고 낮은 카드와 조합 조각을 남깁니다. Part 3 의 easy 봇도 그렇게 하고 사람은 더 그렇게 합니다. 안 보이는 카드에는 상대가 버리지 않은 낮은 카드가 편중되어 있고, 덱에는 반대로 높은 카드가 남습니다. 균등하게 뽑았다고 가정하면 상대 카드합을 높게 보고, 성공 확률을 부풀립니다.
flowchart LR
A["안 보이는 카드<br/>= 남의 손 + 덱"] --> B["균등 가정<br/>어느 카드든 같은 확률"]
A --> C["치우친 가정<br/>낮은 카드일수록<br/>남의 손에 있을 확률 높음"]
B --> B2["상대 카드합을 높게 봄<br/>성공 확률 부풀림<br/>스톱박 36%"]
C --> C2["예측과 실제가 일치"]
style A fill:#87CEEB,color:#000000
style B fill:#FF9999,color:#000000
style B2 fill:#FF9999,color:#000000
style C fill:#90EE90,color:#000000
style C2 fill:#90EE90,color:#000000
고친 방법은 단순합니다. 안 보이는 카드에서 뽑을 때 낮은 카드일수록 상대 손에 있을 무게를 더 줍니다. 얼마나 더 줄지가 손잡이 하나가 됩니다. 0 이면 균등이고, 클수록 낮은 카드에 치우칩니다.
그리고 장치를 하나 더 달았습니다. 봇이 믿은 성공 확률을 기록해 두고, 실제 성공률과 견줍니다. 이것이 없으면 스톱박이 “운이 나빴다” 인지 “확률이 틀렸다” 인지 가릴 수 없습니다.
| 치우침 | 선언 | 스톱박 | 실제 성공률 | 봇이 믿은 성공률 |
|---|---|---|---|---|
| 0 (균등) | 317 | 115 | 0.64 | 0.88 |
| 0.1 | 281 | 79 | 0.72 | 0.84 |
| 0.2 | 224 | 43 | 0.81 | 0.84 |
| 0.25 | 207 | 33 | 0.84 | 0.85 |
| 0.3 | 176 | 21 | 0.88 | 0.85 |
| 0.5 | 131 | 4 | 0.97 | 0.87 |
0.25 에서 예측과 실제가 만납니다. 그 값을 골랐습니다. 0.5 는 스톱박이 거의 없어 성적은 조금 더 좋았지만 예측 0.87 에 실제 0.97 로 이번엔 반대로 틀려 있었습니다. 너무 조심스러워 이길 스톱을 지나치는 것입니다. 이 판단은 Part 7 에서 한 번 더 뒤집힙니다. 지금은 “확률이 맞는가” 만 정하고 성적은 다음 편에서 따로 잽니다.
이렇게 고친 뒤 일반 스톱은 봇의 판당 성적을 0.94점 올렸습니다. 처음 구현이 2.8점을 깎던 것과 같은 기능입니다. 기능은 같고 확률만 다릅니다.
덤으로 잡힌 것#
이 작업 중에 서버 버그가 하나 나왔습니다. 방을 만들 때 스톱 빈도를 “높음” 으로 골라도 판정은 표준 기준선으로 하고 있었습니다. 방 설정은 저장되고 읽히기까지 했는데, 판정하는 곳까지 옮겨지지 않았습니다. Part 1 의 기준선 표에서 기본값이 “높음” 이라고 했는데, 이 버그가 있는 동안은 기본으로 만든 방이 로우 18·하이 80·일반 10 으로 놀고 있었습니다. 저장해 놓고 옮기지 않은 값이 이 프로젝트에서 열한 번째였습니다.
5. 아직 못 하는 스톱#
정직하게 남겨 둘 것이 하나 있습니다. 곧 끝낼 수 있는 국면에서는 이 봇이 일반 스톱을 구조적으로 걸지 못합니다.
손패가 한두 장 남으면 다음 뽑기로 끝낼 확률이 커집니다. 그러면 기다림 쪽의 “내가 먼저 끝낼 확률” 이 1 에 가까워지고, 기다림의 값은 “상대들이 낼 몫” 그 자체가 됩니다. 그런데 그 값은 스톱 쪽이 성공 확률 1 일 때 받는 값과 같습니다. 스톱의 기대값은 그것을 넘을 수 없으니 안전 여유 1점을 이길 방법이 없습니다.
사람이 “여기서 스톱이지” 하는 자리가 정확히 그 자리입니다. 원인은 기다림의 값이 한 턴만 보기 때문입니다. 이번에 못 끝내면 다음 기회가 있다는 것도, 그 사이에 상대가 끝낼 수 있다는 것도 한 턴 너머라 세지 않습니다. 고치려면 치우침 손잡이를 만질 것이 아니라 “내가 먼저 끝낼 확률” 을 세는 방식부터 봐야 합니다. 이 자리는 알려진 구멍으로 기록해 두었습니다.
정리#
Part 4 의 여덟 번째 자리를 두 편에 걸쳐 메웠습니다.
- 땡큐 는 가져갔을 때와 안 가져갔을 때를 둘 다 한 턴을 진행한 상태 로 잽니다. 정지 상태와 비교하면 땡큐가 언제나 이깁니다.
- 땡큐 판단은 Part 5 의 계획과 저울을 그대로 씁니다. 새 탐색기도 새 추도 없습니다.
- 봇을 남의 차례에 깨우는 배선 은 창마다 일감 하나, 신원은 창 번호, 임자는 서버의 좌석 우선권입니다. 배선을 깔자 잠복해 있던 서버 버그 다섯 개가 나왔습니다.
- 확정 승리 스톱 은 무조건 걸고, 일반 스톱 은 스톱과 기다림의 기대값을 견줍니다.
- 첫 구현은 건 스톱의 36% 가 스톱박이었습니다. 여유를 올려도 안 내려갔고, 원인은 확률 이었습니다. 상대 손패가 낮은 쪽으로 치우쳐 있다는 사실을 넣자 예측과 실제가 만났습니다.
이제 hard 봇의 부품은 다 갖췄습니다. 남은 질문은 하나입니다. 정말 easy 보다 강한가. 그리고 저울의 추 일곱 개, 땡큐의 템포 추 하나, 스톱 손잡이 세 개는 전부 사람이 손으로 정한 값인데, 그 열한 개가 실제로 무엇을 하고 있는가. Part 7 에서 하나씩 빼 보며 확인합니다.
시리즈 목록#
- 훌라 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: 처음 보는 판에서 재다