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


Part 2 에서 계약을 정했습니다. 봇은 자기 좌석 스냅샷 하나를 받아 한 수를 돌려주고, 합법성은 서버가 이미 계산해 스냅샷에 실어 둡니다.

이제 그 계약을 만족하는 첫 번째 봇을 씁니다. 규칙 다섯 줄로 끝납니다.

짧다고 대충 만들어도 되는 것은 아닙니다. Part 2 에서 봤듯 이 봇은 모르는 정책 이름이 들어왔을 때 물러설 자리이기도 하므로, 어떤 상황에서도 합법적인 수를 내야 합니다. 그리고 hard 봇이 정말 강해졌는지 재려면 이 봇이 흔들리지 않는 기준선 이어야 합니다.


1. 결정 순서#

판이 active 가 아니거나 내 차례가 아님  → 빈 수
뽑기 가능                               → 뽑는다
등록 가능                               → 가장 장수가 많은 후보를 등록
붙이기 가능                             → 가장 장수가 많은 후보를 붙임
버리기 가능                             → 카드값이 가장 높은 카드를 버림
그 밖                                   → 빈 수

이게 전부입니다. 코드로 옮기면 이렇습니다.

// NextMove 는 봇의 다음 한 수를 고릅니다.
//
//  1. 카드를 뽑는다
//  2. 등록할 수 있으면 등록한다
//  3. 붙일 수 있으면 붙인다
//  4. 가장 높은 카드를 버린다
func NextMove(snapshot Snapshot) Move {
    if snapshot.Round.Status != "active" ||
        snapshot.Round.TurnSeatNo != snapshot.Viewer.SeatNo {
        return Move{}
    }
    commands := snapshot.Viewer.AvailableCommands

    if has(commands, CommandDraw) {
        return Move{Kind: CommandDraw}
    }

    if has(commands, CommandRegisterMeld) {
        if group := longest(snapshot.Viewer.Playable.Register); len(group) > 0 {
            return Move{Kind: CommandRegisterMeld, CardIDs: group}
        }
    }

    if has(commands, CommandLayOff) {
        best := SnapshotLayOffer{}
        for _, option := range snapshot.Viewer.Playable.LayOff {
            if len(option.Cards) > len(best.Cards) && len(option.MeldIDs) > 0 {
                best = option
            }
        }
        if len(best.Cards) > 0 {
            return Move{
                Kind: CommandLayOff, CardIDs: best.Cards,
                TargetMeldID: best.MeldIDs[0],
            }
        }
    }

    if has(commands, CommandDiscard) {
        if id, found := highestCard(snapshot.Viewer.Hand); found {
            return Move{Kind: CommandDiscard, CardIDs: []int64{id}}
        }
    }

    return Move{}
}

Part 2 의 계약은 View 를 받지만, easy 봇은 그중 스냅샷만 꺼내 씁니다. 장부도 턴 시작 등록 여부도 읽지 않습니다. 그래서 위 함수는 스냅샷 하나만 받는 모양으로 적었고, 실제로는 얇은 껍데기가 View 에서 스냅샷을 꺼내 넘겨 줍니다.

Part 2 에서 정한 대로 한 번에 한 수만 돌려줍니다. 등록을 하나 하고 나면 서버가 상태를 갱신한 뒤 이 함수를 다시 부릅니다. 등록 후보가 남아 있으면 또 등록하고, 후보가 다 떨어진 뒤에야 붙이기로 넘어가며, 붙일 것이 없어지면 버립니다.

“등록 → 붙이기 → 버리기” 라는 턴 안의 순서를 이 함수가 강제하지 않는다는 점 이 중요합니다. 함수는 매번 지금 무엇이 가능한지만 보고 우선순위대로 하나를 고를 뿐입니다. 턴의 진행은 서버가 상태로 표현하고, 봇은 그 상태를 읽습니다.


2. 각 줄의 근거#

뽑기 — 조건 없이 먼저#

훌라에서 카드를 뽑지 않고 할 수 있는 유일한 행동은 스톱 선언입니다. easy 봇은 스톱을 걸지 않으므로 뽑기가 가능하면 뽑는 것 말고 할 일이 없습니다.

여기서 easy 가 이미 하나를 포기합니다. 총통·로우·하이는 조건만 갖추면 확정 승리인데 그 자리를 그냥 지나칩니다. Part 1 의 표를 다시 보면 배율이 8배와 4배입니다. 이 봇은 그 배당을 한 번도 받지 못합니다.

등록 — 가장 많이 터는 쪽#

후보가 여럿이면 카드가 가장 많은 묶음을 고릅니다.

func longest(groups [][]int64) []int64 {
    best := []int64(nil)
    for _, group := range groups {
        if len(group) > len(best) {
            best = group
        }
    }
    return best
}

손패를 비우는 것이 이기는 길이므로 한 번에 많이 터는 쪽을 고른다 는 논리입니다. 틀린 논리는 아닙니다. 다만 이 한 줄이 Part 4 에서 다룰 가장 큰 한계를 만듭니다. 지금은 넘어가겠습니다.

붙이기 — 가장 긴 후보를 첫 조합에#

같은 논리입니다. 카드를 가장 많이 붙일 수 있는 후보를 고릅니다. 한 묶음이 여러 조합에 붙을 수 있으면 MeldIDs[0] 을 씁니다.

여기에 판단이 없다는 점을 기록해 둡니다. 같은 카드를 A 조합에 붙이는 것과 B 조합에 붙이는 것은 결과가 다릅니다. Part 1 에서 봤듯 조합에 카드가 붙으면 그 조합이 어느 방향으로 자랄지가 좁아지고, 남들이 붙일 자리도 달라집니다. easy 봇은 그냥 첫 번째를 씁니다.

버리기 — 가장 비싼 카드#

// highestCard 는 손패에서 가장 비싼 카드를 고릅니다. 판이 덱 소진이나
// 남의 훌라로 끝나면 남은 카드가 곧 실점이라, 비싼 것부터 던지는 것이
// 손해를 줄입니다.
func highestCard(hand []SnapshotCard) (int64, bool) {
    bestID, bestValue, bestCode := int64(0), -1, ""
    for _, card := range hand {
        value := cardValue(card.Code)
        if value > bestValue || (value == bestValue && card.Code < bestCode) {
            bestID, bestValue, bestCode = card.ID, value, card.Code
        }
    }
    return bestID, bestValue >= 0
}

이 규칙의 근거는 Part 1 의 정산 구조에 있습니다. 판은 내가 손패를 비워서만 끝나는 게 아닙니다. 덱이 떨어지거나 남이 먼저 끝내면, 그 순간 내 손에 남은 카드값이 그대로 등수를 결정합니다. 그러니 비싼 것부터 털어 두면 갑작스러운 종료에 덜 다칩니다.

카드값 계산은 봇이 직접 하지 않습니다.

// cardValue 는 카드 한 장의 값입니다. **코드를 읽는 것은 model 이 합니다.**
// 여기서 다시 읽으면 카드값의 뜻이 두 곳에 생기고,
// A 가 1이라는 것 같은 규칙이 한쪽만 고쳐집니다.
func cardValue(code string) int {
    card, err := ParseCardCode(code)
    if err != nil {
        return 0
    }
    return int(card.Rank)
}

Part 1 에서 짚었던 함정이 여기 있습니다. RUN 에서 A 는 K 다음에 설 수 있지만 점수는 언제나 1점 입니다. 이 규칙이 봇 코드에 복사되어 있으면, 나중에 정산 규칙이 바뀔 때 한쪽만 고쳐집니다. 카드값을 아는 곳은 한 군데여야 합니다.


3. 같은 손패에는 같은 수를 둔다#

highestCard 의 비교 조건을 다시 보겠습니다.

if value > bestValue || (value == bestValue && card.Code < bestCode) {

값이 같으면 카드 코드 문자열이 앞서는 쪽 을 고릅니다. K♠ 와 K♥ 중 어느 쪽을 버려도 점수는 같은데, 굳이 순서를 정해 둡니다.

이유는 전략이 아니라 재현성 입니다.

  • 같은 스냅샷에 늘 같은 수가 나오면 버그를 재현할 수 있습니다. “봇이 이상한 수를 뒀다” 는 신고를 받았을 때 그 상태를 되살려 같은 수를 다시 뽑아낼 수 있습니다.
  • 테스트가 안정적입니다. 손패 입력 순서를 바꿔도 결과가 흔들리지 않습니다.
  • 나중에 hard 봇과 비교 실험을 할 때, 두 정책의 차이가 정책의 차이 인지 우연 인지 가릴 수 있습니다.

마지막 항목이 특히 중요합니다. 봇 두 개를 붙여 놓고 수천 판을 돌려 승률을 재려면, 같은 조건에서 같은 결과가 나와야 그 차이를 정책 탓으로 돌릴 수 있습니다. 봇에 무작위성을 넣는 순간 필요한 표본 수가 몇 배로 뜁니다.

비슷한 수 사이에 랜덤을 넣어 “사람 같아 보이게” 하고 싶은 유혹은 나중으로 미룹니다. 강한 봇을 먼저 만들고, 자연스러움은 그 위에 얹는 것이 순서입니다.

한 가지 인정하고 갈 것은 있습니다. 등록과 붙이기의 동률 규칙은 전략적 판정이 아닙니다. 장수가 같은 후보가 여럿이면 서버가 만들어 준 목록에서 먼저 나온 것이 이깁니다. 그 순서는 손패 입력 순서와 부분집합 열거 순서에 달려 있습니다. 결정적이기는 하지만 어떤 근거로 결정적인지는 아무도 설계하지 않았습니다. Part 4 에서 이 자리가 문제가 됩니다.


4. 무엇을 일부러 뺐는가#

easy 봇은 땡큐와 스톱을 걸지 않습니다. 빠뜨린 게 아니라 뺀 것입니다.

두 행동은 성격이 같습니다. “해도 되고 안 해도 되는” 선언 이고, 잘 판단하려면 규칙 전체가 필요합니다.

  • 땡큐를 부를지 말지는 그 카드를 가져와 강제로 등록했을 때 손패 구조가 어떻게 되는지, 미등록 상태를 잃는 대가가 얼마인지에 달려 있습니다.
  • 일반 스톱을 걸지 말지는 Part 1 에서 봤듯 실패 시 혼자 전부 물어야 하므로, 남들의 카드합을 추정할 수 있어야 계산이 됩니다.

둘 다 빼도 판은 성립합니다. 그래서 뺐습니다. 봇을 처음 만들 때 판단 기준은 “이걸 빼면 판이 안 돌아가는가” 이지 “이걸 넣으면 봇이 강해지는가” 가 아닙니다. 판이 도는 봇을 먼저 만들고 나면, 그 다음부터는 언제든 정확한 기준선과 비교하면서 기능을 더할 수 있습니다.


5. 이 봇이 우연히 잘하는 것#

easy 봇은 단순하지만 버리면 안 되는 기준선 을 이미 갖고 있습니다. 여섯 가지입니다.

  1. 서버가 계산한 합법 선택지에서만 고르므로 봇만 되는 수를 만들지 않습니다.
  2. 같은 스냅샷에는 같은 수를 둡니다.
  3. 등록과 붙이기로 실제로 손패를 줄여 판을 끝낼 수 있습니다. 이 봇은 이깁니다.
  4. 미등록박을 거의 맞지 않습니다. 등록할 수 있으면 반드시 등록하기 때문입니다.
  5. 판이 갑자기 끝났을 때의 손해가 적습니다. 비싼 카드를 먼저 버려 두었기 때문입니다.
  6. 정책 오류나 배포 롤백이 있어도 판이 계속 굴러갑니다.

4번과 5번은 조금 더 볼 만합니다. Part 1 에서 미등록박은 최하위로 자리를 옮긴 뒤 2배 라고 했습니다. 훌라에서 가장 큰 손실이 나는 자리입니다. 그런데 easy 봇은 “등록할 수 있으면 무조건 등록” 이라는 규칙 하나로 이 함정을 거의 완전히 피합니다. 전략을 세워서 피하는 게 아니라, 전략이 없어서 피합니다.

세븐박도 비슷합니다. 7 한 장은 언제나 등록 후보이므로 easy 봇은 7 이 손에 들어오면 사실상 즉시 내려놓습니다. Part 1 에서 봤듯 7 한 장을 내려놓으면 미등록박과 세븐박을 한 번에 벗습니다. 이 봇은 그 사실을 모르는 채로 매번 그렇게 합니다.

훌라에서 가장 크게 지는 두 가지 방법을 규칙 두 줄이 막고 있는 셈입니다. hard 봇을 만들 때 이걸 잊으면 안 됩니다. 복잡한 평가식이 “지금은 등록하지 않는 편이 좋다” 는 판단을 자주 내리기 시작하면, 그 봇은 easy 보다 정교하면서 easy 보다 크게 집니다.


6. 그런데 얼마나 잘 두는가#

여기까지가 easy 봇이 잘하는 부분입니다. 그런데 이 봇을 상대로 몇 판만 해 보면 금방 눈에 띄는 장면들이 있습니다.

  • 손에 든 일곱 장으로 여섯 장을 털 수 있는데 세 장만 털고 턴을 끝냅니다.
  • 남이 던진 카드로 조합이 완성되는데 가만히 있습니다.
  • 손패 합이 8인데 상대들은 카드를 대여섯 장씩 들고 있습니다. 그런데도 계속 뽑고 버리기를 반복합니다. 스톱을 걸면 이기는 자리입니다.
  • 상대가 카드 두 장만 남긴 상황에서 그 상대가 기다리던 카드를 던집니다.
  • 7♠ 를 내려놓아 상대에게 6♠ 를 붙일 자리를 만들어 줍니다.

이 다섯 장면은 모두 easy 봇이 원리적으로 볼 수 없는 것 에서 나옵니다. 규칙을 몇 개 더 붙여 고칠 수 있는 종류가 아닙니다.

Part 4 에서는 이 한계들을 여덟 자리로 정리하겠습니다. 그게 hard 봇의 요구사항 명세가 됩니다.


시리즈 목록#