훌라 CPU 플레이어 만들기 Part 5: 한 턴을 통째로 그려 보는 hard 봇
이 글은 Claude Fable 5.1 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
Part 4 에서 easy 봇이 보지 못하는 여덟 자리를 정리했습니다. 그중 일곱 개는 자기 차례에 일어나는 일 이었습니다. 한 수만 보고 손패 전체를 깨뜨리는 것, 등록을 멈출 줄 모르는 것, 7 을 무조건 내려놓는 것, 버림패의 카드값만 보는 것, 공개 카드를 기억하지 않는 것, 상대의 남은 장수를 읽지 않는 것, 그리고 스톱을 걸지 않는 것입니다.
이번 편은 그중 앞의 여섯 개를 hard 봇이 어떻게 다루는지 이야기합니다. 스톱과 땡큐는 Part 6 으로 넘깁니다.
미리 말해 두면, hard 봇의 핵심은 두 문장입니다. 본 것을 잊지 않는다. 그리고 한 수가 아니라 한 턴을 통째로 그려 본 뒤 첫 수만 둔다. 나머지는 이 두 문장을 안전하게 만드는 장치들입니다.
0. 만들기 전에 그은 선 세 개#
봇을 짓기 전에 무엇을 하지 않을지부터 정했습니다.
| 하지 않는 것 | 이유 |
|---|---|
| 주사위를 던져 수천 판을 흉내 내 보는 방식 | 같은 판에 같은 수를 둬야 버그를 재현하고 테스트할 수 있습니다 |
| 여러 턴 앞을 내다보는 탐색 | 훌라는 매 턴 카드를 뽑고 남이 끼어드는 게임이라 두 턴 뒤는 이미 다른 판입니다 |
| 사람이 납득 못 할 수 | 계산상 조금 이득이라는 이유로 명백히 위험한 독박 수를 반복하는 봇은 강한 봇이 아니라 이상한 봇입니다 |
그리고 목적 함수는 Part 1 에서 정한 대로 판당 정산 포인트 입니다. 승률도 평균 등수도 보조 지표일 뿐입니다.
봇이 보는 것은 이번 턴 과, 다음에 뽑을 카드 한 장의 가능성까지입니다. 그 너머는 보지 않습니다. 이 제한이 답답해 보일 수 있지만, 뒤에서 보듯 이 범위 안에서도 봇이 할 일은 충분히 많습니다.
1. 본 것을 잊지 않는다 — 공개 카드 장부#
Part 2 에서 봇이 받는 것은 스냅샷과 공개 카드 장부 라고 했습니다. 장부는 단순합니다. 판이 시작된 뒤 지금까지 테이블 위에 보였던 카드를 전부 적어 둔 목록입니다.
flowchart LR
ALL["덱 52장"] --> K["내가 아는 카드"]
ALL --> U["안 보이는 카드"]
K --> K1["내 손패"]
K --> K2["테이블의 조합 전부"]
K --> K3["버림패 더미 전부<br/>맨 위만이 아니라"]
U --> U1["남들의 손패"]
U --> U2["덱에 남은 카드"]
style ALL fill:#87CEEB,color:#000000
style K fill:#90EE90,color:#000000
style K1 fill:#90EE90,color:#000000
style K2 fill:#90EE90,color:#000000
style K3 fill:#90EE90,color:#000000
style U fill:#FF9999,color:#000000
style U1 fill:#FF9999,color:#000000
style U2 fill:#FF9999,color:#000000
안 보이는 카드는 52장에서 아는 카드를 뺀 것 입니다. 그 안에 남들의 손패와 덱이 섞여 있고, 봇은 어느 카드가 어느 쪽에 있는지 모릅니다. 이 경계가 Part 2 의 공정성 경계와 정확히 일치합니다.
장부가 있으면 알 수 있는 것들이 있습니다.
♠8 ♠10을 들고 있는데♠9두 장이 다 버려졌다면, 이 두 장은 영원히 조합이 되지 않습니다. 장부가 없으면 봇은 이 둘을 “곧 될 조합” 으로 오해하고 계속 쥡니다.- 내가 내려놓은 조합에 남들이 붙일 수 있는 카드가 몇 장이나 남아 있는지 셀 수 있습니다.
- 7 이 이미 몇 장 나왔는지 알면, 총통이 나올 수 있는 판인지 압니다.
장부는 봇이 적지 않습니다#
여기서 설계 결정이 하나 있습니다. 봇이 스스로 장부를 적지 않습니다. 서버가 매 결정마다 권위 상태에서 새로 만들어 줍니다.
봇이 자기 메모리에 “지금까지 본 카드” 를 적어 두는 방식이 더 자연스러워 보입니다. 그런데 그 방식은 세 가지에서 깨집니다. 남의 차례에 버려진 카드를 봇이 못 볼 수 있고, 서버가 재시작되면 기억이 통째로 사라지며, 카드를 두 번 적거나 한 번 빠뜨리는 버그가 새로 생깁니다. 이 프로젝트는 “저장하지 않은 상태가 재시작을 못 넘긴” 버그를 앞선 세 번의 작업에서 이미 겪었습니다.
서버가 만들어 주면 이 문제가 전부 사라집니다. 버림패 더미는 서버에 그대로 쌓여 있으니 그것을 읽으면 됩니다. 그리고 한 가지를 더 챙깁니다. 같은 카드가 두 곳에 보이면 장부 만들기가 실패합니다. 조용히 빈 장부로 넘어가지 않습니다. 장부가 틀린 채로 봇이 두면 그 판단은 전부 틀린 전제 위에 서기 때문입니다.
그리고 봇은 아무것도 기억하지 않습니다#
장부 이야기의 뒷면입니다. hard 봇은 턴과 턴 사이에 아무것도 기억하지 않습니다. 매 결정마다 스냅샷과 장부를 새로 받아 처음부터 판단합니다.
Part 2 에서 봇이 한 턴이 아니라 한 수만 돌려주는 이유를 설명했는데, 같은 이유가 여기에도 적용됩니다. 지난 턴에 세운 계획을 메모리에 들고 있으면 서버 재시작과 낡은 일감에 무너집니다. 대신 봇은 매번 새로 그립니다. 그려 보는 비용이 충분히 싸다면 기억은 필요 없습니다. 그 비용이 얼마인지는 §2 의 끝에서 봅니다.
2. 한 턴을 통째로 그려 본다#
Part 4 의 첫 번째 자리를 다시 봅니다. easy 봇은 가장 긴 조합 하나 를 고릅니다. 그런데 정말 알고 싶은 것은 “이 턴이 끝났을 때 손에 무엇이 남는가” 입니다.
그래서 hard 봇은 다르게 묻습니다. 이번 턴에 할 수 있는 모든 행동 순서를 끝까지 그려 보면, 어느 순서가 가장 좋은 손패를 남기는가.
가지 네 개#
봇은 지금 손패에서 시작해 다음 네 가지 가지를 뻗습니다. 가지 끝에 도달한 상태에서 또 네 가지를 뻗고, 이것을 더 뻗을 것이 없을 때까지 반복합니다.
flowchart TD
S["지금 손패와 테이블"] --> A["새 조합 등록<br/>가능한 것 전부"]
S --> B["붙이기<br/>등록한 뒤에만<br/>붙을 수 있는 조합 전부"]
S --> C["여기서 그만두고 버리기<br/>손패의 카드 하나하나"]
S --> D["손패 0장<br/>턴 종료"]
A --> S2["다음 상태에서<br/>다시 네 가지"]
B --> S2
style S fill:#87CEEB,color:#000000
style A fill:#90EE90,color:#000000
style B fill:#90EE90,color:#000000
style C fill:#FFD700,color:#000000
style D fill:#FF9999,color:#000000
style S2 fill:#D3D3D3,color:#000000
세 번째 가지가 이 설계의 핵심입니다. 합법적인 등록이 남아 있어도 “여기서 그만두고 버린다” 는 가지를 반드시 남깁니다.
이 가지 하나가 Part 4 의 세 자리를 후보로 만듭니다. 등록을 멈추는 것, 7 을 쥐고 있는 것, 상대에게 발판을 공개하지 않는 것은 모두 “할 수 있는데 안 한다” 는 선택입니다. easy 봇에는 그 선택지가 코드에 없었습니다. hard 봇에는 있습니다. 다만 후보로 있는 것 과 실제로 고르는 것 은 다릅니다. 이 차이가 Part 7 에서 뜻밖의 결과로 돌아옵니다.
실례 — 두 장 남기느냐, 훌라를 내느냐#
손패가 이렇습니다. 아직 한 번도 등록하지 않았습니다.
2♥ 3♥ 4♥ 4♣ 4♦ 4♠ K♠
easy 봇은 가장 긴 조합을 고릅니다. 4 가 네 장이니 SET 4♥ 4♣ 4♦ 4♠ 입니다. 그러면 2♥ 3♥ K♠ 가 남고, 이 셋으로는 아무것도 못 합니다. K♠ 를 버리고 두 장을 안은 채 턴이 끝납니다.
hard 봇은 순서를 전부 그려 봅니다.
flowchart LR
H["2♥ 3♥ 4♥ 4♣ 4♦ 4♠ K♠"] --> A["SET 4 넉 장 등록"]
H --> B["RUN 2♥ 3♥ 4♥ 등록"]
A --> A2["2♥ 3♥ K♠<br/>더 못 함"]
A2 --> A3["K♠ 버림<br/>2장 남음"]
B --> B2["SET 4♣ 4♦ 4♠ 등록"]
B2 --> B3["K♠ 버림<br/>0장, 훌라 ×4"]
style H fill:#87CEEB,color:#000000
style A fill:#FFD700,color:#000000
style B fill:#FFD700,color:#000000
style A2 fill:#FF9999,color:#000000
style A3 fill:#FF9999,color:#000000
style B2 fill:#90EE90,color:#000000
style B3 fill:#90EE90,color:#000000
RUN 을 먼저 등록하면 4♥ 가 RUN 으로 가고, 남은 4 세 장이 SET 이 되어 손패가 빕니다. 이 턴이 시작될 때 미등록이었으므로 훌라 입니다. 4배가 붙습니다.
easy 봇이 지는 이유는 이 수를 볼 능력이 없어서가 아닙니다. 네 장을 먼저 등록해 4♥ 를 써 버리는 순간, 2♥ 3♥ 를 살릴 길이 스스로 닫히기 때문입니다. 첫 수를 고를 때 턴의 나머지를 함께 보느냐가 유일한 차이입니다.
같은 상태는 한 번만#
행동 순서를 전부 그린다고 하면 경우의 수가 걱정됩니다. 실제로 그 걱정이 한 번 현실이 됐습니다.
“A 조합을 먼저 등록하고 B 를 등록한 상태” 와 “B 를 먼저 하고 A 를 한 상태” 는 손패도 테이블도 똑같습니다. 그런데 처음 구현에서는 등록마다 임시 번호를 붙였고, 그 번호가 순서에 따라 달라져 같은 상태를 다른 상태로 보았습니다. 겹치는 RUN 이 많은 손패에서 살펴본 상태 수가 77 개에서 292 개로 뛰었습니다. 조합을 번호가 아니라 내용 으로 적도록 고치자 다시 77 개로 돌아왔습니다.
같은 상태를 한 번만 살펴보고, 같은 결과에 도달하는 순서가 여럿이면 행동 수가 적은 쪽 만 남깁니다.
얼마나 걸리는가#
상한은 두 개입니다. 살펴볼 상태 수의 상한이 10만 개이고, 생각할 시간의 상한이 Part 2 에서 정한 100ms 입니다.
실제 판에서 재 보니 살펴본 상태 수는 최대 101 개 였습니다. 상한의 천 분의 일입니다. 시간은 백 번 중 아흔아홉 번이 3.2ms 안에 끝났고 가장 오래 걸린 결정이 17ms 였습니다. 손으로 만든 최악의 손패, 즉 같은 문양 여덟 장이 한 줄로 이어지고 붙일 조합이 셋인 경우에도 299 개 상태에 28ms 였습니다.
상한이 하는 일이 거의 없다는 것이 이 숫자의 뜻입니다. 훌라는 손패가 여덟 장을 넘지 않고 등록·붙이기가 카드를 최소 한 장씩 줄이므로, “전부 그려 본다” 가 감당 가능한 게임입니다. 그리고 두 상한이 있으니 감당이 안 되는 손패가 나와도 판은 서지 않습니다. 완성된 계획이 있으면 그중 최선을, 없으면 easy 봇이 대신 둡니다.
3. 남은 손패를 재는 저울#
그려 본 순서마다 끝에 남는 손패와 테이블 이 있습니다. 이제 그것들 중 어느 것이 가장 좋은지 재야 합니다. 저울에 추 일곱 개를 올립니다.
| 추 | 무게 | 무엇을 재는가 |
|---|---|---|
| 남은 카드값 | 1점당 0.3 | 지금 이 순간 판이 끝나면 실점이 되는 카드값 |
| 덮지 못하는 카드 | 1장당 3.0 | 다음 기회에도 조합으로 덮을 수 없는 카드 수 |
| 덮지 못하는 카드의 값 | 1점당 0.15 | 그 카드들의 카드값 합 |
| 열어 준 붙이기 자리 | 1자리당 0.4 | 이번 턴에 내려놓거나 키운 조합에 남이 붙일 수 있는 카드 수 |
| 손에 남긴 7 | 1장당 2.0 | 세븐박 위험 |
| 미등록으로 끝남 | 1.5 | 미등록박 위험 |
| 미등록 + 손에 남긴 7 | 1장당 +1.2 | 미등록을 유지할 때 살아 있는 훌라 가능성 |
앞의 여섯은 벌점이고 마지막 하나만 상점입니다. 무게는 전부 사람이 손으로 정한 첫 값 입니다. 이 값들이 실제로 무엇을 하는지는 Part 7 에서 하나씩 꺼 보며 확인합니다. 미리 말하면 일곱 개 중 넷은 아무 일도 하지 않고 있었습니다.
flowchart LR
H0[" "]:::hdr
H1["벌점"]:::hdr
H2["상점"]:::hdr
R1["언제나"]:::hdr
A["남은 카드값<br/>덮지 못하는 카드와 그 값<br/>열어 준 붙이기 자리<br/>손에 남긴 7"]
B["없음"]
R2["미등록일 때만"]:::hdr
C["미등록으로 끝남"]
D["손에 남긴 7 마다<br/>훌라 가능성"]
H0 ~~~ H1 ~~~ H2
R1 ~~~ A ~~~ B
R2 ~~~ C ~~~ D
classDef hdr fill:none,stroke:none,color:#dddddd
style A fill:#FF9999,color:#000000
style B fill:#D3D3D3,color:#000000
style C fill:#FF9999,color:#000000
style D fill:#90EE90,color:#000000
덮지 못하는 카드#
두 번째 추가 이 저울에서 가장 무겁습니다. “덮지 못하는 카드” 는 러미 계열에서 흔히 데드우드라고 부르는 것으로, 다음 기회에 조합으로 만들 수 없는 카드 를 뜻합니다. 남은 손패 안에서 조합이 되거나, 테이블의 조합에 한 장으로 붙을 수 있으면 덮인 카드입니다. 둘 다 안 되면 덮지 못한 카드입니다.
두 가지를 짚어 둡니다.
- 7 한 장은 덮지 못한 카드가 아닙니다. Part 1 에서 봤듯 언제든 혼자 등록할 수 있기 때문입니다. 7 을 쥐는 위험은 별도의 추(손에 남긴 7)가 셉니다.
- 붙이기는 등록한 뒤에만 셉니다. 아직 미등록이면 테이블의 조합은 내 카드를 덮어 주지 않습니다.
덮는 방법이 여럿이면 가장 많은 카드를 덮는 쪽 을, 같으면 더 비싼 카드를 덮는 쪽 을 고릅니다. 비싼 카드가 조합에 들어가고 싼 카드가 남는 편이 판이 갑자기 끝났을 때 덜 아프기 때문입니다.
열어 준 붙이기 자리#
세 번째 추는 Part 1 의 “7♠ 를 내려놓으면 6♠ 를 붙일 발판이 된다” 는 이야기의 일반형입니다. 이번 턴에 새로 내려놓거나 키운 조합마다, 안 보이는 카드 중 그 조합에 한 장으로 붙을 수 있는 것이 몇 장인지 를 장부로 셉니다. 원래 테이블에 있던 조합의 몫은 빼고 이번 턴에 늘어난 만큼만 셉니다. 내 조합이든 남의 조합이든 구분하지 않습니다.
저울에 없던 추 하나#
일곱 개 중 첫 번째 추인 “남은 카드값” 은 처음 설계에 없었습니다. 첫 측정에서 봇이 이상한 수를 두는 것이 보여서 추가한 것입니다.
봇이 2♥ 3♥ 4♥ 5♥ 6♥ 7♥ 여섯 장을 들고 있었습니다. 그런데 3♥ 부터 7♥ 까지 다섯 장만 등록하고 2♥ 를 손에 쥐고 있었습니다. 왜 그랬는지 보니 저울이 그렇게 시켰습니다. 2♥ 를 쥐고 있으면 안 보이는 카드에서 한 장이 빠져 “열어 준 붙이기 자리” 가 하나 줄고, 2♥ 는 내 RUN 에 붙일 수 있으니 “덮지 못하는 카드” 도 아닙니다. 쥐고 있는 것이 공짜처럼 보인 것입니다.
빠진 것은 단순했습니다. 붙일 수 있든 없든, 손에 있으면 판이 끝날 때 실점입니다. 그 사실을 세는 추가 없었습니다. 첫 번째 추가 그래서 생겼습니다.
4. 저울보다 위에 있는 규칙#
저울이 아무리 정교해도 저울이 이기면 안 되는 자리가 있습니다. 그런 자리는 저울에 올리지 않고 먼저 판정합니다.
flowchart TD
A["합법 후보만 본다"] --> B{"이번 턴에<br/>손패를 비우는<br/>순서가 있는가"}
B -->|"있다"| C["그중 행동 수가<br/>가장 적은 것"]
B -->|"없다"| D["저울로 잰다"]
D --> E{"동률"}
E -->|"예"| F["남은 장수 → 덮지 못한 카드값<br/>→ 열어 준 자리 → 행동 수<br/>→ 정해진 순서"]
E -->|"아니오"| G["저울이 고른 것"]
style A fill:#87CEEB,color:#000000
style B fill:#FFD700,color:#000000
style C fill:#90EE90,color:#000000
style D fill:#87CEEB,color:#000000
style E fill:#FFD700,color:#000000
style F fill:#D3D3D3,color:#000000
style G fill:#90EE90,color:#000000
이번 턴에 손패를 비울 수 있으면 무조건 비웁니다. 저울이 “지금 미등록을 유지하는 편이 훌라 기회를 살린다” 고 속삭여도 듣지 않습니다. 손패를 비우는 것은 그 자체로 승리이고, 승리를 저울에 올리면 언젠가 저울이 승리를 놓칩니다. 달무티 Part 4 에서 hard 봇이 easy 보다 약했던 이유 중 하나가 정확히 “즉시 이득을 점수식에 맡긴 것” 이었습니다.
동률 규칙도 저울 바깥에 있습니다. 저울 값이 같으면 남은 장수가 적은 쪽, 그다음 덮지 못한 카드값이 작은 쪽, 그다음 열어 준 자리가 적은 쪽, 그다음 행동 수가 적은 쪽, 그래도 같으면 정해진 문자열 순서 입니다. Part 3 에서 easy 봇의 동률 규칙은 “아무도 설계하지 않은 열거 순서” 라고 했는데, hard 봇의 동률은 이렇게 설계된 순서입니다. 같은 손패에는 언제나 같은 수가 나옵니다.
5. 버릴 카드는 어떻게 정해지는가#
Part 4 의 다섯 번째 자리였습니다. easy 봇은 가장 비싼 카드를 버립니다.
hard 봇에는 “무엇을 버릴까” 를 정하는 별도의 규칙이 없습니다. §2 의 가지를 다시 보면 “여기서 그만두고 버리기” 가지는 손패의 카드 하나하나 에 대해 뻗습니다. 그러니 버릴 카드마다 “버린 뒤 남는 손패” 가 하나씩 생기고, 저울이 그중 가장 좋은 것을 고릅니다. 버림패는 결정의 대상이 아니라 결정의 결과 입니다.
이 방식이 easy 의 규칙보다 나은 점은 저울이 알아서 챙깁니다. 비싼 카드는 “남은 카드값” 추가 밀어내고, 조합의 조각은 “덮지 못하는 카드” 추가 지킵니다. ♠8 ♠10 을 잇는 ♠9 를 버리는 실수는 ♠9 를 버린 뒤 두 장이 덮지 못한 카드가 되므로 저울에서 크게 집니다. 그리고 장부 덕에 ♠9 가 이미 다 나갔다면 ♠8 ♠10 은 처음부터 덮지 못한 카드라, 봇은 그 둘을 미련 없이 버립니다.
한 가지는 정직하게 적어 둡니다. 버린 카드가 상대에게 땡큐를 주는 위험을 직접 재는 추는 이 저울에 없습니다. 설계 문서에는 “이 카드와 안 보이는 카드 두 장으로 조합이 되는 경우의 수” 를 세는 항이 있었고, 그 옆에 경고가 붙어 있었습니다. 이 수를 확률이라고 부르지 말 것, 그리고 여러 상대가 같은 안 보이는 카드 풀을 나눠 갖는데 좌석마다 확률을 따로 구해 곱하지 말 것. 렉시오 Part 6 에서 봇을 망쳤던 바로 그 계산입니다. 이번 구현은 이 항을 넣지 않았고, 이 자리는 Part 8 의 결산에서 다시 다룹니다.
6. 한 수만 돌려주고, 다시 그린다#
여기까지 그려 보고 재서 고른 것은 행동 순서 하나 입니다. 그런데 Part 2 의 계약은 한 수만 돌려주라고 합니다. 그래서 봇은 고른 순서의 첫 수만 돌려줍니다.
flowchart LR
A["스냅샷 + 장부"] --> B["모든 순서를<br/>그려 본다"]
B --> C["저울로 하나를<br/>고른다"]
C --> D["첫 수만<br/>돌려준다"]
D --> E["서버가 적용"]
E --> F["새 스냅샷 + 장부"]
F --> B
style A fill:#87CEEB,color:#000000
style B fill:#90EE90,color:#000000
style C fill:#90EE90,color:#000000
style D fill:#FFD700,color:#000000
style E fill:#D3D3D3,color:#000000
style F fill:#87CEEB,color:#000000
“한 수만 돌려준다” 는 “한 수만 둔다” 가 아닙니다. 서버가 그 수를 적용하고 봇을 다시 깨우면, 봇은 새 상태에서 처음부터 다시 그립니다. §2 의 실례에서 RUN 등록, SET 등록, K♠ 버리기는 봇이 세 번 깨어나 각각 한 수씩 둔 결과입니다.
그러면 걱정이 하나 생깁니다. 다시 그렸을 때 처음 계획과 다른 길로 새지 않는가. 무작위 손패 200 개로 확인해 보니 재계획이 처음 계획을 벗어난 경우는 없었습니다. 같은 상태에서 같은 저울로 재면 같은 답이 나오니 당연해 보이지만, 이것은 증명이 아니라 관찰입니다. 그래서 테스트로 남겨 두었습니다.
정리#
hard 봇이 자기 차례에 하는 일은 이렇습니다.
- 장부 를 받아 안 보이는 카드가 무엇인지 압니다. 장부는 서버가 매번 새로 만들고, 봇은 턴 사이에 아무것도 기억하지 않습니다.
- 이번 턴에 할 수 있는 모든 행동 순서를 끝까지 그려 봅니다. 합법 등록이 남아 있어도 “여기서 그만둔다” 는 가지를 항상 남깁니다.
- 순서마다 남는 손패를 추 일곱 개짜리 저울 로 잽니다. 첫 값은 전부 사람이 정했습니다.
- 손패를 비울 수 있으면 저울을 보지 않고 무조건 비웁니다.
- 고른 순서의 첫 수만 돌려주고, 다음 깨움에서 다시 그립니다.
Part 4 의 여덟 자리 중 이 편에서 후보로 만든 것은 여섯입니다. 후보로 만들었다는 것과 실제로 잘 고른다는 것은 다른 이야기이고, 그 차이는 Part 7 에서 확인합니다.
Part 6 에서는 남은 두 자리를 다룹니다. 남의 차례에 끼어드는 땡큐 와 판을 접는 스톱 입니다. 둘 다 이 편의 저울을 그대로 쓰되, 하나는 봇을 깨우는 배선을 새로 깔아야 했고 다른 하나는 확률이 한 번 크게 틀렸습니다.
시리즈 목록#
- 훌라 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: 처음 보는 판에서 재다