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


네 번째 봇#

이 블로그에서 CPU 플레이어 만들기를 다룬 것이 세 번입니다. 달무티, 렉시오, 훌라 였습니다. 이번에는 일본의 국민 카드게임 대부호(大富豪) 입니다. 달무티를 아는 분을 위한 규칙 입문을 먼저 썼고, 이 시리즈는 그 게임에 easy 봇과 hard 봇 을 넣은 이야기입니다.

규칙 입문에서 저는 이렇게 썼습니다.

이 블로그의 달무티 봇 실험은 규칙 세 줄짜리 봇이 꽤 강하다는 것을 보여 줬는데, 대부호는 무늬·계단·8·도락치가 더해져 판단할 것이 훨씬 많습니다.

이 글은 그 문장을 코드로 확인한 기록입니다. 결론부터 말하면, 달무티 봇에서 그대로 가져올 수 있었던 것은 원칙과 검증 순서 뿐이었고, 판단 로직은 전부 새로 썼습니다. 그리고 그렇게 만든 hard 봇은 사전에 정해 둔 시험을 통과해 혼자하기의 기본 봇이 됐습니다.

시리즈는 일곱 편입니다. 이 편은 봇을 짜기 전의 이야기입니다. 대부호가 봇에게 왜 어려운지, 앞선 세 시리즈에서 무엇을 배웠는지, 그리고 봇이 지켜야 할 계약이 무엇인지입니다.


1. 앞선 세 시리즈에서 가져온 것#

봇을 네 번째 만들면 더 쉬워질 것 같지만, 쉬워지는 것은 무엇을 하면 안 되는지 를 안다는 점뿐입니다. 세 시리즈의 실패에서 가져온 원칙은 다섯 가지였습니다.

원칙어디서 배웠나
easy 가 가드레일이다.1 hard 는 easy 의 수를 기본으로 두고, 증거가 문턱을 넘을 때만 바꾼다달무티 — 리드2를 점수에 통째로 맡긴 시도는 전부 졌습니다. 약한 큰 뭉치를 계속 뒤로 미루다 끝내 못 털었습니다
시뮬레이터가 먼저다. hard 를 만들기 전에 봇끼리 재는 장치와, 그 장치가 맞다는 증거부터 둔다렉시오 — hard 가 easy 에게 진 적이 있었습니다. 훌라 — 시뮬레이터가 빈 장부를 넘겨 다른 봇을 재고 있었습니다
운영과 시뮬레이터는 한 경로다. 정책 함수만 같아서는 같은 게임이 아니다훌라 — 시야를 만드는 코드가 달라서 생긴 사고였습니다
잴 수 있는 것만 넣는다. 행동을 바꾸지 않는 항은 0 으로 남기지 않고 지운다훌라 — 계수를 20배로 키워도 수가 안 바뀌는 항이 있었습니다
시험 문제는 미리 정하고, 본 뒤에는 다시 쓰지 않는다세 시리즈 공통. 달무티 v1 은 easy 대비 −0.07 로 기본 봇이 됐다가 숙련자에게 한 달 만에 읽혔습니다

마지막 줄이 이번 시리즈에 가장 큰 영향을 줬습니다. 달무티 hard v1 은 easy 보다 겨우 조금 나은 상태로 기본 봇이 됐고, Part 6 에서 봤듯 사람이 열 판 중 여섯 판을 이겼습니다. 그래서 이번에는 약한 hard 를 기본값으로 올리지 않는다 는 조건을 처음부터 숫자로 못 박았습니다. 그 숫자는 Part 7 에서 나옵니다.


2. 대부호가 봇에게 어려운 열 가지#

달무티 봇의 판단 로직을 대부호에 옮길 수 없었던 이유를 규칙 하나씩 적습니다. 규칙 이름은 규칙 입문의 것과, 이 블로그가 만든 게임 안에서 쓰는 이름을 함께 적었습니다. 이 시리즈에서는 게임 안의 이름을 씁니다.

규칙 (입문 글의 이름 → 게임 안의 이름)봇에게 뜻하는 것
패스하면 그 필드가 끝날 때까지 못 낸다달무티는 패스해도 차례가 돌아오면 다시 낼 수 있습니다. 대부호의 패스는 이 필드를 포기하는 것 입니다. 모두 패스하면 리드는 패스한 사람이 아니라 마지막에 낸 사람에게 갑니다. 달무티 봇의 “패스하고 나중에 받자” 는 계산을 통째로 버려야 합니다
8 자르기 → 8컷8 이 든 수를 내면 그 자리에서 필드가 정리되고 내가 다시 리드합니다. 받을 때의 8 은 “리드를 사는 카드”, 리드할 때의 8 은 “공짜로 한 번 털기” 입니다
혁명 (같은 숫자 4장 이상)서열이 통째로 뒤집힙니다. 내 손패의 값이 두 서열에서 다르고, 무엇을 먼저 내느냐에 따라 다음 묶음의 서열과 마지막 수의 합법 여부가 바뀝니다
무늬 묶기 → 깔맞춤같은 무늬가 두 번 이어지면 그 무늬만 받을 수 있습니다. 받기 후보가 무늬로 한 번 더 줄어듭니다
스페이드 3 되받기 → ♠3극상단독 조커는 ♠3 한 장으로만 받습니다. ♠3 의 값이 조커가 남았는지에 달렸고, 조커 한 장 리드의 안전도 ♠3 이 남았는지에 달렸습니다
계단 (같은 무늬 연속 3장 이상)같은 카드가 같은 숫자 묶음으로도, 계단으로도 읽힙니다. 손패를 묶음으로 나누는 경우의 수가 달무티보다 훨씬 큽니다
반칙 올라가기 → 반칙패조커·2(혁명 중에는 3)·8 만으로 된 수·♠3 한 장으로 끝내면 그 판 꼴찌입니다. 반칙은 카드에 붙은 꼬리표가 아니라 “그 수와 그때의 혁명 상태” 로 정해집니다. 강한 카드만 남으면 끝낼 수 없습니다
도락치 → 나락지난 판 대부호가 남아 있는데 다른 사람이 먼저 정상으로 끝내면 대부호는 즉시 탈락입니다. 대부호인 봇은 “1등 아니면 벌칙” 이라 목표가 다릅니다
블라인드 (4인 2장, 5인 4장)안 나온 카드가 전부 누군가의 손에 있는 것이 아닙니다
조공아래 계급이 가장 강한 카드를 주므로, 그 사람의 남은 손패에는 상한 이 생깁니다. 위 계급은 받을 카드를 보지 않고 돌려줍니다

달무티 봇이 가장 많이 썼던 두 가지 계산이 여기서 무너집니다. 하나는 “패스한 자리가 다시 받을 위험” 으로, 대부호에서는 패스한 자리의 위험이 정확히 0 입니다. 다른 하나는 “이 카드는 끝내기용” 이라는 꼬리표로, 대부호에서는 혁명 한 번에 2 가 끝내기 카드가 되고 3 이 반칙 카드가 됩니다.

사람에게는 이것이 대부호의 재미입니다. 봇에게는 판단할 축이 둘에서 다섯으로 늘어난 것 입니다.


3. 계약 — 봇이 보는 것과 돌려주는 것#

봇을 짜기 전에 세 시리즈에서 쓴 계약을 그대로 가져옵니다. 봇은 그 자리의 사람이 볼 수 있는 것만 보고, 규칙을 다시 판단하지 않으며, 한 번에 한 수만 돌려줍니다.

3.1 봇이 보는 것#

// View 는 봇 한 자리가 볼 수 있는 것입니다. 남의 손패를 담을 자리가 없습니다.
type View struct {
    SeatNo     int
    Hand       []model.Card  // 내 손패
    Field      *model.Field  // 지금 필드(깔맞춤 포함). nil 이면 리드 차례
    Revolution bool
    Exchange   *Exchange     // 조공 돌려주기 차례면 돌려줄 장수

    // 아래는 사람 화면에 이미 보이는 공개 상태입니다. hard 봇이 장부를 만드는 재료입니다.
    Seats           []SeatState   // 자리마다 남은 장수 · 이번 필드 패스 여부 · 완료 · 계급
    FieldSeatNo     int           // 지금 필드를 낸 자리
    PreviousDaifugo int           // 지난 판 대부호(나락 대상)
    History         []PublicEvent // 이번 판에 나온 카드 전부 (지난 판은 없음)
    Gave, Received  []ExchangeCards // 내가 조공으로 주고받은 카드
}

여기서 두 줄에 주석을 더 답니다.

History 는 이번 판 것만입니다. 처음 구현은 매치 전체 이력을 넘길 뻔했습니다. 그러면 지난 판에 나온 카드를 이번 판에서 또 빼게 되고, 세트3마다 자리가 바뀌니 엉뚱한 사람의 카드로 셉니다. 사람이 기억하는 것도 이번 판에 나온 카드까지이므로 봇도 거기까지만 봅니다.

Received 는 돌려주기를 마친 뒤부터 채워집니다. 대부호의 조공은 “받을 카드를 보지 않고 돌려준다” 는 규칙이 있습니다. 사람 화면은 돌려주기 전까지 받은 카드를 가립니다. 봇도 같은 순간부터 봅니다. 이 한 줄을 어기면 봇은 사람이 할 수 없는 계산 으로 돌려줄 카드를 고르게 됩니다.

3.2 규칙은 한 벌#

봇은 합법성을 다시 판단하지 않습니다. 엔진이 LegalPlays(손패, 필드, 혁명) 로 낼 수 있는 수 목록을 만들고, 봇은 그 목록에서 고릅니다. 반칙 종료 판정도 엔진의 IsForbiddenFinish(수, 그때의 혁명 상태) 하나입니다. 봇이 “2 는 끝내기용이 아니다” 같은 규칙을 따로 들고 있으면, 혁명이 일어난 판에서 봇과 서버가 서로 다른 게임을 하게 됩니다.

그리고 봇이 낸 수는 서버가 한 번 더 봅니다.

// ValidateDecision 은 정책이 낸 수가 이 시야에서 합법인지 봅니다.
// 내 카드로 성립하는 묶음인지, 받기면 필드를 이기는지, 조공이면 장수가 맞는지.
func ValidateDecision(view View, decision Decision) error

3.3 한 수, 그리고 물러설 자리#

정책의 모양은 세 시리즈와 같습니다. 판 하나를 받아 수 하나를 돌려줍니다.

type Policy func(ctx context.Context, view View) (Decision, error)

// 한 수에 쓸 수 있는 시간입니다. 넘기면 답을 냈더라도 버립니다.
const DecisionBudget = 100 * time.Millisecond

그리고 물러설 자리를 둡니다. 모르는 정책 이름, 정책의 오류, 시간 초과, 합법이 아닌 수 — 넷 중 무엇이 일어나도 판은 멈추지 않고 easy 가 대신 둡니다.

func DecideWithin(ctx context.Context, key string, view View, budget time.Duration) (Decision, string, error) {
    policy, ok := policies[key]
    if !ok {
        decision, err := Easy(ctx, view)
        return decision, FallbackUnknownPolicy, err   // 운영 사건
    }
    budgeted, cancel := context.WithTimeout(ctx, budget)
    decision, err := policy(budgeted, view)
    expired := budgeted.Err() != nil
    cancel()

    var fallback string
    switch {
    case err != nil && expired:
        fallback = FallbackTimeout                    // 시계의 사건
    case err != nil:
        fallback = FallbackHardError                  // 봇의 잘못
    case ValidateDecision(view, decision) != nil:
        fallback = FallbackInvalidMove                // 봇의 잘못
    default:
        return decision, "", nil
    }
    decision, err = Easy(ctx, view)
    return decision, fallback, err
}

네 가지 사유를 따로 세는 이유는 훌라 시리즈에서 적었던 그대로입니다. 한 통에 넣으면 서버가 바빴던 날의 기록이 봇의 실수로 남습니다. 이번 시리즈에서는 여기에 하나를 더했습니다. 이 사유가 봇이 둔 수의 기록에 남습니다. 어느 판에서 hard 가 두었고 어느 판에서 easy 가 대신 두었는지를 나중에 가를 수 있어야, 사람 상대 성적을 셀 때 “hard 의 성적” 과 “운영 봇 전체의 성적” 을 구분할 수 있습니다.

이 fallback4 때문에 easy 봇은 시리즈가 끝날 때까지 지워지지 않습니다. 최후의 보루가 되려면 easy 는 어떤 상황에서도 합법인 수를 내야 합니다. 그래서 easy 는 시간 초과 때 서버가 대신 두는 자동 수와 같은 함수 입니다. 둘을 따로 두면 한쪽만 고치는 날이 옵니다. Part 2 에서 그 함수를 봅니다.


4. 일의 순서#

봇 하나를 만드는 데 PR 열다섯 개가 들어갔습니다. 순서는 훌라 시리즈에서 정착한 그대로입니다. 시뮬레이터가 hard 보다 먼저 이고, hard 는 처음에 기본값이 아닌 채로 등록되며, 미리 정한 시험을 통과해야 기본값이 됩니다.

flowchart LR
    A["easy 봇<br/>혼자하기"] --> B["전략 문서<br/>코드 없음"]
    B --> C["시뮬레이터<br/>easy 대 easy = 0"]
    C --> D["시야 확장<br/>장부 · 위험 점수"]
    D --> E["손패 계획<br/>혁명 전이 · 확정 종료 줄"]
    E --> F["hard 1차<br/>기본값은 아직 easy"]
    F --> G["켜기 · 끄기 실험<br/>튜닝 · 동결"]
    G --> H["held-out 시험<br/>통과 → 기본 봇"]
    style A fill:#87CEEB,color:#000000
    style B fill:#D3D3D3,color:#000000
    style C fill:#FFD700,color:#000000
    style D fill:#90EE90,color:#000000
    style E fill:#90EE90,color:#000000
    style F fill:#90EE90,color:#000000
    style G fill:#FFD700,color:#000000
    style H fill:#FFD700,color:#000000

파란 칸이 Part 2, 초록 칸이 Part 3~5, 노란 칸이 Part 6~7 입니다.

  • Part 2 — 규칙 네 줄짜리 easy 봇. 시간 초과 자동 수와 같은 함수이고, 반칙으로 끝나는 일이 판의 10% 쯤 됩니다.
  • Part 3 — hard 가 보는 것. 이번 판에 나온 카드로 54장을 네 칸에 나누는 장부와, “이 수를 내면 누가 받을 수 있는가” 를 재는 위험 점수.
  • Part 4 — 손패 계획. 혁명이 일어나면 서열이 바뀐다는 것을 따라가며 “남은 손패를 몇 수에, 합법으로 끝낼 수 있는가” 를 구합니다.
  • Part 5 — hard 의 결정. easy 와 hard 가 같은 자리에서 무엇을 다르게 보고 다르게 고르는지를 그림 하나로 비교합니다. 이 시리즈의 가운데입니다.
  • Part 6 — 재고 거르기. 시뮬레이터, 항목을 하나씩 껐다 켜 본 실험, 가중치 튜닝.
  • Part 7 — 처음 보는 판에서. 미리 정한 시험, 결과, 그리고 기본 봇이 된 뒤 남은 것.

코드 없이 결론만 보고 싶으시다면 Part 5 의 그림과 Part 7 의 결과 표만 보셔도 됩니다. 두 봇이 각각 무엇을 보고 어떻게 고르는지, 그리고 실제로 얼마나 차이가 났는지가 거기 있습니다.


시리즈 목록#



  1. 가드레일: 도로의 난간처럼, 봇이 점수 계산 때문에 엉뚱한 쪽으로 벗어나지 못하게 막는 기본값입니다. 이 시리즈에서는 «일단 easy 의 수를 두고, 뚜렷이 더 나은 근거가 있을 때만 바꾼다» 는 규칙을 뜻합니다. ↩︎

  2. 리드 / 받기 / 필드: 필드는 테이블에 놓여 있는 지금의 카드(들)입니다. 필드가 비어 있을 때 첫 수를 내는 것이 «리드», 놓인 필드보다 센 수로 이어 내는 것이 «받기» 입니다. 모두가 패스하면 필드가 정리되고 마지막에 낸 사람이 다시 리드합니다. ↩︎

  3. 세트: 4판을 한 묶음으로 보는 단위입니다. 세트 첫 판은 자리를 다시 섞고 계급·조공 없이 시작합니다. ↩︎

  4. fallback(물러서기): 원래 하려던 방법이 실패했을 때 대신 쓰는 예비 방법입니다. 여기서는 hard 봇이 답을 내지 못하면 easy 봇이 대신 두는 것을 가리킵니다. ↩︎