이 글은 Claude Opus 5 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.


Part 1 에서 훌라 룰셋을 확정했습니다. 7 한 장이 조합이 되고, 땡큐는 좌석 순서로 임자를 정하고, 스톱은 네 가지이며, 정산은 등수 사다리 위에 배율과 박이 곱해집니다.

이번 편에서는 그 판 위에 봇을 올리기 전에 계약 을 정합니다. 봇은 무엇을 볼 수 있고, 무엇을 돌려주며, 게임 서버와 어떻게 만나는가 하는 이야기입니다.

코드를 한 줄도 쓰기 전에 이걸 정하는 이유는 단순합니다. 이 계약이 잘못되면 봇을 아무리 똑똑하게 만들어도 게임이 망가집니다. 봇만 둘 수 있는 수가 생기거나, 봇이 사람은 볼 수 없는 정보로 이기거나, 봇이 판단한 사이에 판이 바뀌어 엉뚱한 수가 들어갑니다.


1. 공정성 경계 — 봇은 자기 자리만 본다#

첫 번째 원칙입니다. 봇에게 주는 정보는 그 자리에 앉은 사람이 화면에서 볼 수 있는 것으로 한정합니다.

이것은 난이도 조절 장치가 아니라 설계 전제입니다. 봇이 남의 손패를 보고 두면 그 봇은 강한 봇이 아니라 다른 게임을 하는 봇 입니다. 사람이 아무리 잘해도 이길 수 없고, 무엇보다 그 봇을 상대한 사람은 자기가 왜 졌는지 알 수 없습니다. 진 이유를 납득할 수 없는 게임은 다시 하지 않습니다.

그래서 봇에게 넘기는 것은 두 가지입니다. 하나는 그 좌석 기준으로 만든 화면용 스냅샷 으로, 사람 클라이언트에 내려보내는 것과 같은 자료입니다. 다른 하나는 공개 카드 장부 로, 그 자리의 사람이 판이 시작된 뒤 지금까지 눈으로 보았던 카드의 목록입니다. 지나간 버림패와 테이블에 놓인 조합이 여기 들어갑니다.

봇이 보는 것봇이 못 보는 것
내 손패 전부남의 손패 내용
남들의 남은 장수덱에 남은 카드의 순서
남들의 등록 여부다른 봇이 무엇을 계획 중인지
테이블에 놓인 모든 조합아직 선언되지 않은 남의 땡큐 자격
버림패 맨 위, 그리고 지금까지 버려진 카드 전부남의 스톱 자격
덱에 남은 장수
지금 내가 할 수 있는 행동 목록

장부 쪽은 사람 화면에는 없는 자료라 경계를 넘는 것처럼 보일 수 있습니다. 그렇지 않습니다. 버려진 카드는 버려지는 순간 모두가 보았고, 사람은 그것을 기억하기 귀찮아서 안 할 뿐입니다. 봇에게 장부를 주는 것은 더 많이 보게 하는 것이 아니라 본 것을 잊지 않게 하는 것 입니다. 서버는 이 장부를 매 결정마다 권위 상태에서 새로 만들어 줍니다. 봇이 스스로 카드를 세다가 서버 재시작 때 기억을 잃는 일이 없도록 하기 위해서입니다.

오른쪽 열에서 특히 눈여겨볼 것은 마지막 줄입니다. Part 1 에서 봤듯 땡큐 창은 모두에게 동시에 열립니다. 이때 “저쪽 봇도 땡큐를 부를 자격이 있다” 는 사실을 우리 봇이 알면, 그건 사람에게는 절대 공개되지 않는 정보입니다. 좌석 순서 우선권을 계산해 “어차피 내가 우선이니 안심하고 부른다” 같은 판단을 하게 되는데, 같은 자리의 사람은 그 계산을 할 수 없습니다. 이 한 줄이 새면 봇은 조용히 치팅합니다.

봇이 강해지는 이유는 더 많이 보기 때문이 아니라, 같은 것을 보고 더 잘 세기 때문 이어야 합니다. 달무티와 렉시오 시리즈에서 세웠던 원칙을 그대로 가져옵니다.


2. 봇은 규칙을 다시 판단하지 않는다#

두 번째 원칙이 이 시리즈에서 가장 실용적인 결정입니다.

훌라의 합법성 판정은 만만치 않습니다. Part 1 을 다시 떠올려 보면,

  • SET 은 3장 이상, RUN 은 같은 문양 3장 이상, 그런데 7 은 한 장
  • RUN 에서 A 는 K 다음과 2 앞 양쪽
  • 붙이기는 자기가 등록한 뒤에만
  • 붙이기는 RUN 방향으로만 최소 장수가 풀림
  • 2장짜리 RUN 은 붙이기로는 만들어지지만 등록으로는 못 만듦
  • 2장짜리 SET 은 붙이기로도 못 만듦. SET 은 언제나 3장 이상

봇이 이걸 다시 구현하면 안 됩니다. 서버의 판정과 봇의 판정이 조금이라도 어긋나는 순간 두 가지 사고가 납니다.

  • 봇이 서버가 거절하는 수를 낸다. 거절이 되풀이되면 그 자리에서 판이 섭니다. 혼자하기 모드라면 판을 밀어 줄 다른 사람도 없습니다.
  • 봇이 합법인 수를 못 찾는다. 사람은 낼 수 있는데 봇은 못 내는 조합이 생깁니다. 아무도 버그로 신고하지 않고, 봇이 그냥 조금 멍청해 보일 뿐입니다.

그래서 이렇게 합니다. 서버가 그 좌석의 유효한 선택지를 미리 계산해 스냅샷에 실어 보내고, 봇은 그 목록에서 고르기만 합니다.

// 스냅샷에서 봇이 쓰는 부분만 추린 모습입니다.
type Snapshot struct {
    Round   SnapshotRound   // 판 상태, 현재 차례 좌석
    Viewer  SnapshotViewer  // 이 좌석이 보는 것
    Players []SnapshotPlayer
    Melds   []SnapshotMeld
    Discard *SnapshotCard   // 버림패 맨 위
}

type SnapshotViewer struct {
    SeatNo            int
    Hand              []SnapshotCard
    AvailableCommands []CommandType   // 지금 할 수 있는 행동
    Playable          SnapshotPlayable
}

type SnapshotPlayable struct {
    Register [][]int64          // 등록 가능한 카드 묶음들
    LayOff   []SnapshotLayOffer // 붙이기 가능한 묶음과 대상 조합
}

Playable 이 이 설계의 핵심입니다. 사람 화면의 “내려놓기” 버튼을 켜고 끄는 데 쓰는 바로 그 목록 을 봇도 씁니다. 규칙이 사는 곳이 한 군데뿐이므로, 규칙을 바꾸면 사람과 봇이 함께 바뀝니다.

이 결정에는 대가가 있습니다. Playable 은 “지금 이 상태에서 가능한 것” 만 담습니다. 등록을 하나 한 뒤에 새로 열리는 붙이기 선택지는 거기 없습니다. 그건 등록이 실제로 반영된 다음 스냅샷에 나타납니다. easy 봇에게는 문제가 없지만, 한 턴 전체를 미리 계획하려는 hard 봇에게는 이게 제약이 됩니다. Part 5 이후에 다시 다루겠습니다.


3. 정책 인터페이스 — 판 하나를 받아 수 하나를 낸다#

계약의 모양은 이렇게 잡습니다.

// View 는 «그 봇이 보는 판» 입니다. 스냅샷에 공개 카드 장부를 더한 것입니다.
type View struct {
    Snapshot            Snapshot
    Ledger              PublicLedger // 지금까지 공개된 카드 전부
    TurnStartRegistered bool         // 이 턴이 시작될 때 등록 상태였는가
}

// Policy 는 View 하나를 받아 «한 수» 하나를 내놓습니다.
// ctx 는 생각할 시간의 상한을 실어 나릅니다.
type Policy func(ctx context.Context, view View) (Move, error)

// Move 는 봇이 둘 한 수입니다. Kind 가 비어 있으면 둘 것이 없다는 뜻입니다.
type Move struct {
    Kind         CommandType
    CardIDs      []int64
    TargetMeldID int64
    OptionIndex  int // 땡큐를 부를 때 어느 조합인지
}

들어가는 것과 나오는 것이 이 둘뿐입니다. 정책끼리 바꿔 끼우는 데 걸릴 것이 없고, 난이도를 늘리려면 이 모양을 지키는 함수를 하나 더 쓰면 됩니다. easy 봇은 이 중 스냅샷만 읽습니다. 장부와 턴 시작 등록 여부는 Part 5 의 hard 봇이 씁니다.

TurnStartRegistered 는 작은 필드지만 빠뜨리면 큰일이 납니다. Part 1 에서 훌라의 조건은 그 턴이 시작될 때까지 미등록 이었습니다. 등록을 하고 나서 손패를 비우는 계획을 세우는 봇은, 지금 등록 여부가 아니라 턴이 시작될 때의 등록 여부를 알아야 그 계획이 훌라인지 압니다.

왜 한 턴이 아니라 한 수인가#

Part 1 에서 봤듯 훌라의 한 턴은 뽑기 → 등록 여러 번 → 붙이기 여러 번 → 버리기 입니다. 그러면 Policy 가 []Move 를 돌려주게 만들어 한 턴을 통째로 계획하는 편이 자연스러워 보입니다.

그렇게 하면 안 됩니다.

봇의 한 수는 서버 상태를 바꿉니다. 등록을 하면 테이블에 조합이 생기고, 그 조합에 남이 붙일 수 있게 되고, 내 붙이기 자격이 열립니다. 계획을 세운 시점의 판과 세 번째 수를 두는 시점의 판이 같다는 보장이 없습니다. 게다가 서버 재시작이나 배포 중에 깨어난 봇이라면, 손에 든 계획이 이미 지나간 판의 것일 수도 있습니다.

그래서 이렇게 합니다.

flowchart LR
    A["시계가<br/>봇을 깨움"] --> B["현재 상태로<br/>스냅샷 생성"]
    B --> C["Policy 호출<br/>한 수 반환"]
    C --> D["서버가 적용<br/>상태 버전 증가"]
    D --> E{"아직<br/>봇 차례"}
    E -->|"예"| A
    E -->|"아니오"| F["다음 좌석"]
    style A fill:#87CEEB,color:#000000
    style B fill:#87CEEB,color:#000000
    style C fill:#90EE90,color:#000000
    style D fill:#FFD700,color:#000000
    style F fill:#D3D3D3,color:#000000

한 수를 두면 상태 버전이 오르고, 봇은 새 스냅샷으로 처음부터 다시 판단합니다. 계획을 메모리에 들고 이어 쓰지 않습니다. 그래서 재시작에 안전하고, 낡은 일감으로 깨어난 봇도 안전합니다.

깨어난 봇이 가장 먼저 하는 일이 이것입니다.

// 남의 차례이거나 끝난 판에서 수를 두면 서버가 거절하고,
// 거절이 되풀이되면 판이 섭니다.
if snapshot.Round.Status != "active" ||
    snapshot.Round.TurnSeatNo != snapshot.Viewer.SeatNo {
    return Move{}
}

그리고 깨움을 건 쪽에서도 한 번 더 봅니다. 일감에 기대하는 상태 버전 을 실어 두고, 깨어나서 실제 버전과 다르면 두지 않고 물러납니다. 이건 오류가 아니라 낡은 일감 이므로, 오류로 다루면 재시도까지 돌면서 로그만 쌓입니다.


4. 정책을 아는 곳은 한 군데뿐이어야 한다#

난이도가 둘 이상이 되면 정책 이름이 여러 곳에서 필요해집니다.

  • 방을 만들 때 이 이름이 유효한가 를 검증해야 합니다
  • 설정 화면이 고를 수 있는 목록 을 그려야 합니다
  • 봇이 깨어났을 때 그 이름으로 실제 함수를 찾아야 합니다

이 셋이 각자 목록을 들면 새 정책을 더할 때 하나를 빠뜨립니다. 그러면 방은 만들어지는데 그 정책이 불리지 않거나, 정책은 있는데 고를 수 없습니다. 둘 다 조용히 잘못 동작하는 종류의 버그입니다.

그래서 표를 하나만 둡니다.

// policies 는 **정책을 아는 유일한 곳** 입니다.
var policies = map[string]Policy{
    PolicyEasy: NextMove,
}

// ValidPolicy 는 등록된 정책인지 봅니다. 방을 만들 때 이것으로 거릅니다.
func ValidPolicy(key string) bool {
    _, found := policies[key]
    return found
}

// PolicyKeys 는 고르는 화면이 쓸 목록입니다.
// **순서를 고정합니다.** 지도를 그대로 돌면 선택지 순서가 새로고침마다 바뀝니다.
func PolicyKeys() []string { /* ... */ }

여기 한 줄을 더하면 검증·목록·실행이 함께 켜집니다.

모르는 이름이 와도 판은 돌아야 한다#

하나 더 있습니다. 새 정책으로 시작한 방이 있는데 배포를 되돌리면, 그 방의 좌석에는 이 서버가 모르는 정책 이름 이 남습니다.

// DecisionBudget 은 정책이 한 수를 내는 데 쓸 수 있는 시간입니다.
const DecisionBudget = 100 * time.Millisecond

func Decide(ctx context.Context, key string, view View) Move {
    policy, found := policies[key]
    if !found {
        return NextMove(view.Snapshot)   // 모르는 이름: 기본 정책으로 물러납니다
    }
    ctx, cancel := context.WithTimeout(ctx, DecisionBudget)
    defer cancel()
    move, err := policy(ctx, view)
    if err != nil || ctx.Err() != nil {
        return NextMove(view.Snapshot)   // 오류나 시간 초과: 역시 기본 정책으로
    }
    return move
}

여기서 아무것도 두지 않으면 그 판은 턴 마감이 밀 때까지 섭니다. 혼자하기 모드에는 판을 밀어 줄 다른 사람이 없으므로, 사람이 몇 초를 멍하니 기다린 끝에 봇이 가장 소극적인 수를 억지로 두는 장면을 봅니다. 조금 약한 봇이 두는 편이 낫습니다.

물러서는 이유는 세 가지로 갈라 셉니다. 모르는 정책 이름은 운영 사건 이고, 정책이 오류를 낸 것은 봇의 잘못 이며, 100ms 안에 답을 못 낸 것은 시계의 사건 입니다. 셋을 한 통에 넣으면 나중에 봇이 약한 이유를 찾을 때 서버가 바빴던 날의 기록이 봇의 실수로 적힙니다. 시간을 넘긴 답은 정책이 답을 냈더라도 버립니다. 시뮬레이터에서만 시간 무제한으로 계산해 고른 정책은 실제 서버에서는 다른 정책이 되기 때문입니다.

이 fallback 은 전략이 아니라 운영 안전장치입니다. 그런데 이것 때문에 easy 봇은 이 시리즈가 끝날 때까지 지워지지 않습니다. 최후의 보루가 되려면 easy 는 어떤 상황에서도 합법적인 수를 내야 합니다.


5. easy 와 hard 는 무엇이 다른가#

두 난이도의 차이를 한 장으로 정리하면 이렇습니다.

flowchart LR
    H0[" "]:::hdr
    H1["무엇을 보는가"]:::hdr
    H2["무엇을 결정하는가"]:::hdr
    R1["easy"]:::hdr
    A["현재 스냅샷 하나<br/>내 손패와 합법 선택지<br/><br/>공개 카드 기억 없음"]
    B["뽑기 · 등록 · 붙이기 · 버리기<br/>고정된 우선순위<br/><br/>땡큐 · 스톱 사용 안 함"]
    R2["hard"]:::hdr
    C["스냅샷 + 공개 카드 장부<br/>판 시작부터 나온 모든 카드<br/><br/>상대 장수와 등록 여부"]
    D["현재 턴 행동열 전체 비교<br/>7 보유 · 등록 중단 포함<br/><br/>땡큐 · 스톱 판단"]
    H0 ~~~ H1 ~~~ H2
    R1 ~~~ A ~~~ B
    R2 ~~~ C ~~~ D
    classDef hdr fill:none,stroke:none,color:#dddddd
    style A fill:#87CEEB,color:#000000
    style B fill:#87CEEB,color:#000000
    style C fill:#90EE90,color:#000000
    style D fill:#90EE90,color:#000000

왼쪽 열이 정보 이고 오른쪽 열이 결정 입니다. 두 열이 독립적이라는 점이 중요합니다. 정보를 늘리지 않고 결정만 정교하게 만들 수도 있고, 그 반대도 가능합니다. 달무티와 렉시오 시리즈에서는 이 둘을 각각 다른 편으로 나눠 다뤘고, 이 시리즈도 그렇게 하겠습니다.

여기서 미리 짚어 둘 것이 하나 있습니다. 오른쪽 아래 칸의 “땡큐” 는 다른 항목들과 성격이 다릅니다.

지금까지 설명한 구조에서 봇은 자기 차례에만 깨어납니다. 시계가 봇 차례에 깨움을 걸고, 봇이 한 수를 두고, 다음 깨움이 걸립니다. 그런데 땡큐는 Part 1 에서 봤듯 남의 차례에 끼어드는 행위 입니다. 버림패가 나온 순간 자격이 있는 봇을 깨우는 길이 하나 더 필요하고, 그 길은 지금 없습니다.

즉 땡큐는 “정책 함수에 분기를 하나 더 넣으면 되는 일” 이 아닙니다. 깨움 배선 자체를 새로 깔아야 하는 일 입니다. 그 배선은 Part 6 에서 깝니다. 반면 스톱은 자기 차례에, 카드를 뽑기 전에 선언하므로 지금 깨움으로 이미 닿는 자리입니다. 정책이 해당 커맨드를 돌려주기만 하면 됩니다.

같은 표의 같은 칸에 있어도 구현 비용이 이렇게 다릅니다. 난이도 설계를 할 때 이런 것을 미리 갈라 두지 않으면, “hard 봇 만들기” 라는 하나의 일감 안에 성격이 전혀 다른 작업이 섞여 들어갑니다.


정리#

Part 2 에서 정한 것은 셋입니다.

  1. 봇은 그 좌석의 사람이 보았던 것만 본다. 남의 손패도, 덱 순서도, 아직 선언되지 않은 남의 땡큐 자격도 보지 않습니다. 지나간 버림패는 본 것이므로 장부로 받습니다.
  2. 봇은 규칙을 다시 판단하지 않는다. 서버가 계산한 합법 선택지에서 고르기만 하므로, 봇만 되는 수도 봇만 안 되는 수도 생기지 않습니다.
  3. 봇은 한 턴이 아니라 한 수를 돌려준다. 매번 서버의 최신 상태로 다시 판단하므로 재시작과 낡은 일감에 안전합니다.

Part 3 에서는 이 계약 위에 첫 번째 봇을 올리겠습니다. 규칙 다섯 줄로 끝나는 easy 봇 입니다. 짧지만 이 봇은 이 시리즈가 끝날 때까지 두 가지 역할을 합니다. 알 수 없는 상황에서 판을 계속 굴리는 최후의 보루, 그리고 hard 봇이 정말 강해졌는지 재는 기준선 입니다.


시리즈 목록#