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


Part 1 에서 계약을 정했습니다. 봇은 자기 자리의 사람이 보는 것만 보고, 규칙을 다시 판단하지 않으며, 한 수만 돌려줍니다. 이번 편은 그 계약 위에 올린 첫 번째 봇, easy 입니다.

easy 는 이 시리즈에서 세 가지 역할을 합니다. 혼자하기를 처음 열었을 때의 상대이고, hard 가 답을 못 내면 대신 두는 최후의 보루 이며, hard 가 정말 세졌는지 재는 기준선 입니다. 그래서 easy 의 첫째 조건은 똑똑함이 아니라 어떤 상황에서도 합법인 수를 낸다 는 것입니다.


1. 시간 초과 자동 수와 같은 함수#

대부호에서 사람이 턴 제한시간(기본 30초) 안에 수를 내지 않으면 서버가 대신 둡니다. 그 자동 수가 곧 easy 봇의 수입니다. 둘은 같은 함수 HandState.Autoplay 입니다.

// Easy 는 «항상 합법이고 재현 가능한 기준선» 입니다.
// 수는 엔진의 자동 수(HandState.Autoplay)와 같습니다 — 시간 초과와 한 함수입니다.
func Easy(_ context.Context, view View) (Decision, error) {
    if view.Exchange != nil {
        // 조공 돌려주기: 가장 약한 카드
        return Decision{Action: ActionExchange, Cards: model.WeakestCards(view.Hand, view.Exchange.Count)}, nil
    }
    // 엔진 상태에는 모든 손패가 들어가지만, 여기서 만드는 상태에는 봇 자신의 손패만 넣습니다.
    // Autoplay 가 읽는 것은 차례인 자리의 손패와 필드·혁명뿐입니다.
    state := &model.HandState{
        Seats: []int{view.SeatNo},
        Hands: map[int][]model.Card{view.SeatNo: view.Hand},
        Turn:  view.SeatNo, Field: view.Field, Revolution: view.Revolution,
    }
    decision := state.Autoplay(view.SeatNo)
    if decision.Action != model.AutoplayPlay {
        return Decision{Action: ActionPass}, nil
    }
    return Decision{Action: ActionPlay, Cards: decision.Play.Cards, Kind: decision.Play.Kind}, nil
}

왜 한 함수여야 하는지는 달무티 때 겪었습니다. 자동 수와 easy 가 따로 있으면 어느 날 한쪽만 고쳐지고, “시간이 지나면 봇보다 이상한 수를 둔다” 는 신고가 들어옵니다. 함수가 하나면 그런 날이 없습니다.

위 코드에서 눈여겨볼 곳은 가운데입니다. 엔진의 HandState 는 원래 모든 자리의 손패 를 들고 있습니다. 봇은 거기에 자기 손패만 넣은 상태를 새로 만들어 넘깁니다. 자동 수 함수가 남의 손패를 읽지 않는다는 것은 코드를 보면 알 수 있지만, 그것을 믿는 대신 읽을 것이 없는 상태 를 넘기는 쪽이 안전합니다. “봇은 남의 손패를 보지 않는다” 는 테스트는 남의 손패를 바꿔도 봇의 수가 같은지로 확인합니다.


2. 규칙 네 줄#

easy 의 판단은 네 줄입니다.

상황규칙
리드1가장 약한 숫자를 통째로 낸다. 다만 가장 약한 계단2 이 그보다 카드를 더 털면 계단을 낸다
받기필드를 이기는 가장 약한 수를 낸다. 조커 없는 수가 있으면 조커 든 수는 보지 않는다
반칙 끝내기3그 수로 손패가 비는데 반칙이면, 다른 수가 있을 때 피한다
조공 돌려주기4가장 약한 카드를 돌려준다. 같은 강함이면 ♠ 부터

그림으로 그리면 이렇습니다.

flowchart TD
    S["내 차례"] --> X{"조공 돌려주기<br/>차례인가?"}
    X -- "예" --> X1["가장 약한 카드를<br/>돌려준다"]
    X -- "아니오" --> L["낼 수 있는 수 목록을<br/>엔진에서 받는다"]
    L --> F["그 수로 손패가 비면서<br/>반칙이 되는 수를 뺀다<br/>(다른 수가 있을 때만)"]
    F --> Q{"필드가<br/>비어 있나?"}
    Q -- "리드" --> R1["가장 약한 숫자 통째로<br/>vs 가장 약한 계단"]
    R1 --> R2{"계단이<br/>더 많이 터나?"}
    R2 -- "예" --> R3["계단을 낸다"]
    R2 -- "아니오" --> R4["숫자 통째로 낸다"]
    Q -- "받기" --> A1{"이기는 수가<br/>있나?"}
    A1 -- "없다" --> A2["패스"]
    A1 -- "있다" --> A3["이기는 가장 약한 수<br/>조커 없는 수 먼저"]
    style S fill:#FFD700,color:#000000
    style X1 fill:#87CEEB,color:#000000
    style L fill:#D3D3D3,color:#000000
    style F fill:#87CEEB,color:#000000
    style R3 fill:#87CEEB,color:#000000
    style R4 fill:#87CEEB,color:#000000
    style A2 fill:#D3D3D3,color:#000000
    style A3 fill:#87CEEB,color:#000000

2.1 리드 — 통째로, 그런데 계단이면#

손패가 이렇다고 합시다. 4인 판 초반, 혁명은 없습니다.

♠3 ♥3 · ♦4 · ♠5 ♠6 ♠7 · ♣9 ♥9 · ♦J · ♠K · ♥2 · 조커

“가장 약한 숫자 통째로” 는 3 두 장(♠3 ♥3)입니다. “가장 약한 계단” 은 ♠5 ♠6 ♠7 입니다. 계단이 세 장을 털고 숫자 통째로는 두 장을 털므로, easy 는 계단을 냅니다.

이 규칙은 처음부터 있던 것이 아닙니다. 기획 단계에서는 “계단은 받기에서만 쓴다” 였습니다. 혼자하기를 만들면서 바꿨습니다. 약한 숫자부터 통째로 털기만 하면 계단으로 묶였을 카드들이 하나씩 흩어져 손패가 늘어지고, 봇이 계단을 아예 내지 않으니 사람도 계단을 볼 일이 없어 게임이 심심해졌기 때문 입니다. “더 많이 터는 쪽” 이라는 기준은 단순하지만, 리드를 두 축(숫자·계단)에서 고르게 한다는 점에서 대부호의 첫 번째 고유 판단입니다.

코드는 두 후보를 각각 정렬해서 맨 앞을 견줍니다.

// EasyLead 는 리드 후보 가운데 «가장 약한 숫자 통째로» 와 «가장 약한 계단» 을 견줍니다.
func EasyLead(candidates []Play) Play {
    // 조커 없는 수가 있으면 조커 든 수는 보지 않습니다.
    if slices.ContainsFunc(candidates, func(p Play) bool { return p.JesterCount() == 0 }) {
        candidates = slices.DeleteFunc(slices.Clone(candidates), func(p Play) bool { return p.JesterCount() > 0 })
    }
    // 약한 것 먼저, 같으면 긴 것 먼저
    weakestLongest := func(left, right Play) int {
        if left.Strength != right.Strength { return left.Strength - right.Strength }
        if left.Count != right.Count       { return right.Count - left.Count }
        return left.JesterCount() - right.JesterCount()
    }
    var groups, runs []Play   // 같은 숫자 묶음 / 계단
    for _, play := range candidates {
        if play.Kind == PlaySequence { runs = append(runs, play) } else { groups = append(groups, play) }
    }
    slices.SortStableFunc(groups, weakestLongest)
    slices.SortStableFunc(runs, weakestLongest)
    switch {
    case len(groups) == 0:                                  return runs[0]
    case len(runs) > 0 && runs[0].Count > groups[0].Count: return runs[0]
    default:                                                return groups[0]
    }
}

SortStableFunc 를 쓰는 이유는 같은 손패에는 같은 수를 두기 위해서입니다. 안정 정렬5이 아니면 같은 강함·같은 장수의 후보 순서가 실행마다 바뀔 수 있고, 그러면 같은 판을 다시 돌렸을 때 결과가 달라집니다. 봇을 재는 일(Part 6)은 “같은 조건이면 같은 결과” 위에서만 성립합니다.

2.2 받기 — 딱 이길 만큼만#

필드에 ♣10 한 장이 놓였습니다. 위 손패에서 이기는 한 장은 J, K, 2, 조커입니다. easy 는 ♦J 를 냅니다. 이기는 것 가운데 가장 약한 것이고, 조커는 조커 없는 수가 있는 한 보지도 않습니다.

이 한 줄은 달무티 Part 7 에서 봇을 이긴 사람의 세 번째 습관이기도 했습니다. “받을 때 딱 이길 만큼만 쓴다.” easy 는 그것을 처음부터 합니다. 받기만 놓고 보면 easy 는 생각보다 사람과 비슷합니다.

2.3 반칙 끝내기 회피 — 마지막 한 수만#

대부호에는 끝내면 안 되는 카드 가 있습니다. 조커가 든 수, 2 가 든 수(혁명6 중에는 3), 8 만으로 된 수, ♠3 한 장. 이것으로 손패를 비우면 그 판 꼴찌입니다.

easy 는 이것을 피합니다. 단, 마지막 한 수에서만 피합니다.

candidates := LegalPlays(s.Hands[seat], s.Field, s.Revolution)
// 그 수로 손패가 비면서 반칙이 되는 수를 뺍니다
safe := slices.DeleteFunc(slices.Clone(candidates), func(play Play) bool {
    return s.FinishForbidden(seat, play)   // 장수가 손패와 같고 && IsForbiddenFinish(play, 혁명)
})
switch {
case len(safe) > 0:
    candidates = safe                       // 다른 수가 있으면 반칙 수는 버린다
case s.Field != nil || len(candidates) == 0:
    return AutoplayDecision{Action: AutoplayPass}   // 받기라면 패스
}
// 리드인데 반칙 수밖에 없으면 그대로 낸다 — 리드는 패스할 수 없다

판정은 엔진의 IsForbiddenFinish(수, 그때의 혁명 상태) 하나입니다. Part 1 에서 적었듯 봇이 “2 는 끝내기용이 아니다” 를 따로 외우면 혁명 판에서 서버와 어긋납니다.

그런데 “마지막 한 수에서만” 이라는 조건이 easy 의 가장 큰 약점입니다. 손패가 이렇게 세 장 남았다고 합시다.

♠K · ♥2 · 조커

리드 차례입니다. easy 의 리드 규칙은 “가장 약한 숫자 통째로” 이니 ♠K 를 냅니다. 남은 것은 ♥2 와 조커. 다음 리드에서 ♥2 를 내면(손패가 비지 않으니 반칙이 아닙니다) 조커 한 장이 남고, 그 조커는 어떻게 내도 반칙 끝내기입니다. 다른 수가 없으니 easy 는 그대로 냅니다. 꼴찌입니다.

같은 손패를 사람은 이렇게 풉니다. ♥2 와 조커를 쌍으로 먼저 내고(조커는 와일드라 2 두 장이 됩니다), ♠K 로 끝냅니다. 세 수가 아니라 두 수이고, 반칙도 아닙니다. easy 가 이것을 못 하는 이유는 한 수 앞만 보기 때문 입니다. 이 자리를 메우는 것이 Part 4 의 손패 계획입니다.

2.4 조공 — 가장 약한 카드부터#

대부호와 부호는 판을 시작할 때 아래 계급에게서 가장 강한 카드를 받고, 자기 손패에서 같은 장수를 돌려줍니다. easy 는 가장 약한 카드 를 돌려줍니다. 달무티에서 “계획을 덜 망가뜨리는 카드를 고르는” 탐색을 넣었다가 오히려 졌고, “가장 약한 카드부터” 가 더 나았던 경험을 그대로 가져왔습니다.

같은 강함이면 ♠ 부터입니다. 그래서 3 이 여러 장이면 ♠3 이 먼저 나갑니다. ♠3 은 조커를 받는 유일한 카드인데, easy 는 그것을 모릅니다. 이것이 좋은 선택인지는 Part 6 의 실험에서 다시 봅니다. 미리 말씀드리면, ♠3 을 아끼게 한 hard 의 항은 실험에서 해롭다 고 나와 지워졌습니다.


3. 일부러 뺀 것#

easy 에는 대부호의 고유 규칙에 대한 판단이 하나도 없습니다.

  • 혁명 을 노리지 않습니다. 약한 숫자 넉 장이 있으면 “가장 약한 숫자 통째로” 규칙에 따라 그냥 냅니다. 그러면 혁명이 일어납니다. 그것이 자기에게 유리한지는 보지 않습니다.
  • 8컷7 을 노리지 않습니다. 8 이 가장 약한 숫자가 되는 순간 냅니다.
  • 깔맞춤8 을 걸지도, 피하지도 않습니다.
  • ♠3극상9 은 “조커를 이기는 가장 약한 수” 가 ♠3 뿐이므로 저절로 합니다.
  • 나락10 을 모릅니다. 자기가 지난 판 대부호라는 것도 모릅니다.
  • 나온 카드를 기억하지 않습니다. 장부11가 없습니다.

빼는 이유는 훌라 Part 3 과 같습니다. 기준선은 단순해야 hard 의 효과를 잴 수 있고, 최후의 보루는 틀릴 여지가 없어야 합니다. 혁명 판단 같은 것을 easy 에 넣으면 그 판단이 틀렸을 때 물러설 곳이 없습니다.


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

그래도 easy 는 생각보다 덜 어리석습니다. 규칙 몇 개가 대부호의 구조와 우연히 맞아떨어지기 때문입니다.

  • 8컷이 저절로 일어납니다. 8 은 서열의 한가운데입니다. 약한 것부터 털다 보면 8 이 “가장 약한 숫자” 가 되는 때가 오고, 받기에서 “이기는 가장 약한 수” 가 8 인 때도 옵니다. 그때마다 필드가 정리되어 리드를 가져옵니다. 노리지 않아도 됩니다.
  • ♠3극상을 놓치지 않습니다. 위에서 적은 대로입니다.
  • 강한 카드를 끝까지 들고 있습니다. “가장 약한 것부터” 는 결과적으로 2 와 조커를 종반까지 남깁니다. 종반에 그 카드들로 리드를 가져오는 것은 대부호에서 좋은 습관입니다. 문제는 그 뒤에 그것들로 끝낼 수 없다 는 것을 모른다는 점이고, 그것이 다음 절의 숫자입니다.

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

봇을 재는 장치는 Part 6 에서 자세히 다루고, 여기서는 easy 에 관한 숫자 셋만 적습니다. 모두 4·5·6인 × 4·8·12판 아홉 칸에서 seed12 32개씩 돌린 결과입니다.

easy 끼리는 정확히 0. 같은 봇 넷이 앉으면 자리를 돌려 가며 잰 순위 차이가 모든 관측에서 정확히 0 이었습니다. 이것은 성적이 아니라 장치 검사 입니다. 같은 봇끼리 차이가 나면 재는 장치가 틀린 것입니다.

한 장씩만 내는 봇보다는 확실히 낫습니다. 실험용으로 “무엇이든 한 장씩만 내는” 약한 봇을 만들어 easy 와 견줬더니, 약한 봇의 판당 평균 순위가 공정할 때보다 0.349 등 나빴습니다(4인이면 공정한 평균은 2.5등입니다). easy 의 “통째로 털기” 는 그만큼의 값어치가 있습니다.

열 판에 한 판은 반칙패로 끝납니다. easy 끼리 4인 판에서 참가자 한 사람당 판당 반칙패율이 10.7~11.7% 였습니다. 2.3 에서 본 그 장면입니다. 다른 수가 있을 때만 반칙을 피하므로, 강한 카드만 남는 상황을 미리 피하지 못합니다. 상대가 더 약한 봇들이면 이 비율은 17~27% 까지 올라갔습니다. 남이 못 받으니 리드를 계속 잡고, 약한 것부터 다 털고 나면 손에는 2 와 조커만 남습니다.

이 마지막 숫자가 hard 의 첫 번째 목표가 됐습니다. 반칙패를 0 으로 만드는 것입니다. 그리고 Part 6 에서 보겠지만, 그 하나만으로도 hard 가 easy 보다 나아진 것의 91% 가 설명됩니다.


정리#

easy 봇은 규칙 네 줄입니다. 리드는 가장 약한 숫자 통째로(계단이 더 털면 계단), 받기는 이기는 가장 약한 수, 반칙 끝내기는 다른 수가 있으면 피하고, 조공은 가장 약한 카드부터. 시간 초과 자동 수와 같은 함수라 언제나 합법이고, 안정 정렬이라 같은 손패에는 같은 수를 둡니다.

못 하는 것은 분명합니다. 한 수 앞만 보고, 나온 카드를 기억하지 않으며, 대부호의 고유 규칙을 하나도 판단하지 않습니다. 그 결과 열 판에 한 판을 반칙패로 끝냅니다.

Part 3 부터는 hard 입니다. 먼저 hard 가 무엇을 더 보는가 부터 시작합니다. 더 많이 보는 것이 아니라, 같은 것을 보고 잊지 않는 장부입니다.


시리즈 목록#



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

  2. 계단: 같은 무늬의 연속한 숫자 3장 이상(예: ♠5 ♠6 ♠7)을 한 수로 내는 조합입니다. 조커가 빈 자리를 채울 수 있습니다. ↩︎

  3. 반칙패: 조커·2(혁명 중에는 3)·8 만으로 된 수·♠3 한 장으로 손패를 비우면 그 판 꼴찌가 되는 규칙입니다. ↩︎

  4. 조공: 판을 시작할 때 아래 계급이 위 계급에게 가장 강한 카드를 주고, 위 계급은 자기 손패에서 같은 장수를 골라 돌려주는 규칙입니다. ↩︎

  5. 안정 정렬: 순서를 정할 때 «같은 값» 끼리는 원래 있던 순서를 그대로 지키는 정렬입니다. 그렇지 않으면 같은 강함의 카드 순서가 실행마다 달라질 수 있습니다. ↩︎

  6. 혁명: 같은 숫자 4장 이상을 한 번에 내면 조커를 뺀 카드 서열이 통째로 뒤집히는 규칙입니다. 다시 4장을 내면 돌아오고, 판이 끝나면 풀립니다. ↩︎

  7. 8컷: 8 이 든 싱글이나 같은 숫자 묶음을 내면 즉시 필드가 정리되고 낸 사람이 다시 리드하는 규칙입니다(계단 속 8 은 예외). ↩︎

  8. 깔맞춤: 같은 무늬의 수가 두 번 이어지면 그 필드에서는 그 무늬로만 받을 수 있게 되는 규칙입니다. ↩︎

  9. ♠3극상: 조커 한 장은 최강의 싱글이지만 오직 ♠3 한 장으로만 받을 수 있고, 받으면 필드가 정리되는 규칙입니다. ↩︎

  10. 나락: 지난 판 대부호가 남아 있는데 다른 사람이 먼저 손패를 비우면 대부호가 그 즉시 탈락해 꼴찌가 되는 규칙입니다. ↩︎

  11. 장부: 이번 판에 나온 카드를 기억해 «아직 안 나온 카드가 어디 있을 수 있는가» 를 정리한 것입니다. Part 3 에서 설명합니다. ↩︎

  12. seed: 무작위로 섞은 덱을 다시 똑같이 만들어 내기 위한 번호입니다. 같은 seed 는 언제 돌려도 같은 순서로 카드가 나뉘므로, «같은 딜에서 봇만 바꿔 앉혀» 비교할 수 있습니다. ↩︎