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


재료가 다 모였습니다. Part 2 의 easy 가 내는 수, Part 3 의 장부(상대가 받을 수 있는가), Part 4 의 손패 계획(내가 끝낼 수 있는가). 이번 편은 hard 가 이것들을 어떤 순서로 쓰는가 이고, 그 결과 easy 와 hard 가 같은 자리에서 어디서 갈라지는가입니다.

이 시리즈의 가운데입니다. 코드 없이 결론만 보고 싶으시면 1절의 그림과 2절까지 읽으시면 됩니다.


1. 한눈에 — 같은 차례, 두 봇#

flowchart TD
    subgraph EASY["easy"]
        direction TB
        e1["내 차례"] --> e2["낼 수 있는 수 목록"]
        e2 --> e3["손패가 비면서 반칙이 되는<br/>수를 뺀다 (다른 수가 있을 때)"]
        e3 --> e4{"리드 / 받기"}
        e4 -- "리드" --> e5["가장 약한 숫자 통째로<br/>계단이 더 털면 계단"]
        e4 -- "받기" --> e6["이기는 가장 약한 수<br/>없으면 패스"]
    end
    subgraph HARD["hard"]
        direction TB
        h1["내 차례"] --> h0["장부를 만든다<br/>손패 계획 표를 만든다"]
        h0 --> h2["낼 수 있는 수 목록"]
        h2 --> h3{"① 한 수로<br/>정상 종료?"}
        h3 -- "예" --> h3y["그 수"]
        h3 -- "아니오" --> h4{"② 확정 종료 줄이<br/>있나?"}
        h4 -- "예" --> h4y["줄의 첫 수"]
        h4 -- "아니오" --> h5["③ 낸 뒤 정상 종료할<br/>길이 없어지는 수를 뺀다"]
        h5 --> h6{"④ 받기이고,<br/>패스하면 상대가<br/>이 필드로 끝낼 위험 ≥ 0.5?"}
        h6 -- "예" --> h6y["그 위험을 가장<br/>낮추는 받는 수"]
        h6 -- "아니오" --> h7["⑤ 기본 수 = easy 의 수<br/>후보마다 점수<br/>진척 + 통제 − 온존 − 8컷"]
        h7 --> h8{"easy 의 수보다<br/>0.4 이상 나은<br/>후보가 있나?"}
        h8 -- "없다" --> h9["easy 의 수"]
        h8 -- "있다" --> h10["그 후보<br/>(받기면 패스도 후보)"]
    end
    style e1 fill:#FFD700,color:#000000
    style e2 fill:#D3D3D3,color:#000000
    style e3 fill:#87CEEB,color:#000000
    style e4 fill:#D3D3D3,color:#000000
    style e5 fill:#87CEEB,color:#000000
    style e6 fill:#87CEEB,color:#000000
    style h1 fill:#FFD700,color:#000000
    style h0 fill:#90EE90,color:#000000
    style h2 fill:#D3D3D3,color:#000000
    style h3 fill:#90EE90,color:#000000
    style h3y fill:#90EE90,color:#000000
    style h4 fill:#90EE90,color:#000000
    style h4y fill:#90EE90,color:#000000
    style h5 fill:#90EE90,color:#000000
    style h6 fill:#90EE90,color:#000000
    style h6y fill:#90EE90,color:#000000
    style h7 fill:#90EE90,color:#000000
    style h8 fill:#90EE90,color:#000000
    style h9 fill:#87CEEB,color:#000000
    style h10 fill:#90EE90,color:#000000

파란 칸은 easy 의 판단이고, 초록 칸은 hard 가 더한 판단입니다. 그림에서 두 가지를 먼저 보셨으면 합니다.

hard 의 맨 아래에 easy 가 있습니다. ①~④ 를 다 지나고 점수를 다 매긴 뒤에도, hard 의 기본 수는 easy 의 수입니다. 점수가 더 좋은 후보가 있어도 0.4 이상 좋지 않으면 바꾸지 않습니다. 이것이 Part 1 에서 적은 “easy 가 가드레일1” 이라는 원칙의 모양입니다.

①~③ 은 점수가 아니라 규칙입니다. 한 수로 정상 종료2할 수 있으면 다른 계산 없이 끝냅니다. 확정 종료 줄3이 있으면 그 첫 수를 냅니다. 낸 뒤 정상 종료할 길이 없어지는 수는 후보에서 빼 버립니다. 이 셋은 가중치가 없고, Part 6 의 실험에서 켜고 끌 대상도 아닙니다. 미리 결과를 하나 말씀드리면, 이 셋만으로 hard 가 easy 보다 나아진 것의 91% 가 설명됩니다.

무엇을 보는지로 다시 정리하면 이렇습니다.

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/><br/>→ 장부 · 위험 점수 · 손패 계획"]
    D["끝낼 수 있으면 끝낸다<br/>끝내지 못하게 되는 수는 버린다<br/>상대의 끝내기를 막는다<br/>easy 수보다 확실히 나을 때만 바꾼다<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

2. 같은 손패, 다른 수 — 네 장면#

그림이 실제로 어떻게 다른 수를 내는지, Part 2 에서 쓴 손패들을 다시 꺼냅니다.

장면 하나 — ♠K · ♥2 · 조커, 리드#

easy 는 가장 약한 숫자 통째로, ♠K 를 냅니다. 남은 2 와 조커로는 끝낼 수 없어 반칙패4입니다.

hard 는 ③ 에서 갈립니다. “♠K 를 낸 뒤 남은 손패(♥2 조커)로 정상 종료할 길이 있는가” 를 Part 4 의 표에서 읽습니다. 없습니다. 그래서 ♠K 는 후보에서 빠집니다. 남은 후보 가운데 “♥2 조커 쌍을 낸 뒤 남은 ♠K” 는 정상 종료 가능이라 살아남고, hard 는 ♥2 조커 쌍 을 냅니다. 다음 차례에 ♠K 로 끝냅니다.

실은 ② 에서 먼저 잡힐 수도 있습니다. 2 두 장(조커 포함)은 덱 구성상 아무도 못 받으므로 장부가 “확정 안전” 을 증명하고, “2·조커 쌍 → ♠K” 는 확정 종료 줄이 됩니다. 어느 쪽이든 수는 같습니다.

장면 둘 — 확정 종료 줄#

내 손패에 8 두 장, 2 석 장, ♣K 가 남았습니다. 리드5 차례입니다.

easy 는 가장 약한 숫자 통째로, 8 두 장을 냅니다. 8컷6으로 필드가 정리되어 다시 내 리드. 다음에도 가장 약한 숫자인 ♣K 를 냅니다. 남은 것은 2 석 장. 어떻게 내도 2 로 끝내는 것은 반칙이고 다른 수가 없으니 그대로 냅니다. 반칙패입니다.

hard 는 ② 에서 세 수짜리 줄을 읽습니다. 8 두 장은 8컷으로 필드를 정리하고, 2 석 장은 덱 구성상 아무도 받을 수 없으며(장부가 증명합니다 — 2 석 장보다 센 석 장은 만들 수 없습니다), 마지막 ♣K 는 정상 종료입니다. 그래서 8 두 장을 내고, 2 석 장을 내고, ♣K 로 끝냅니다. 첫 수는 easy 와 같지만 순서 전체 가 다릅니다. Part 7 의 수동 재생에서 실제로 본 장면입니다. 그 봇은 2 석 장을 끝까지 아끼다가 이 줄에서 썼습니다.

장면 셋 — 받을 수 있는데 패스한다#

필드에 ♣10 한 장. 내 손패는 열 장이고 2 가 두 장(♠2 ♥2) 있습니다. 10 을 이기는 싱글은 2 뿐입니다. easy 는 이기는 가장 약한 수인 2 한 장으로 받습니다.

hard 는 ⑤ 에서 패스도 후보 로 놓고 점수를 매깁니다. 2 한 장으로 받는 수는 쌍을 쪼개므로 계획이 한 수 늘고(진척7 0), 지금 서열 최상위 카드를 손패가 아직 열 장일 때 쓰는 비용(온존8 −)이 큽니다. 2 가 버틸 가능성(통제9 +)을 더해도 합은 패스(점수 0)보다 낮고, 그 차이가 문턱 0.4 를 넘으므로 hard 는 패스 합니다. 2 쌍은 나중에 리드를 가져오는 데 쓰입니다.

실험에서 hard 는 받을 수 있는 자리의 28% 에서 자발적으로 패스했습니다. 대부호에서 패스는 이 필드를 포기하는 것이지만, 그래도 강한 카드를 아끼는 쪽이 더 나은 자리가 그만큼 있었습니다.

장면 넷 — 상대의 끝내기를 막는다#

필드에 7 두 장. 내 다음 자리의 상대는 손패가 두 장 이고 이번 필드에서 패스하지 않았습니다. 그 상대가 7 두 장을 받으면 손패를 비웁니다. hard 는 ④ 에서 장부의 위험 점수로 “그 자리가 7 두 장을 이기는 쌍을 들고 있을 위험” 을 셉니다. 0.5 이상이면 막습니다. 받는 수 가운데 그 위험을 가장 낮추는 것을 고르고, 같으면 easy 처럼 가장 약한 것입니다. easy 는 상대의 장수를 보지 않으므로 이 장면 자체가 없습니다.


3. 규칙 층 — 점수보다 먼저#

코드에서 ①~③ 은 이렇습니다.

func hardPlay(ctx context.Context, view View, weights Weights) (Decision, error) {
    ledger, _  := NewLedger(view)        // Part 3
    planner, _ := NewPlanner(view.Hand)  // Part 4
    turn := hardTurn{view: view, weights: weights, ledger: ledger, planner: planner}
    turn.base = planner.MinPlays(planner.Full(), view.Revolution)   // 지금 손패의 정상 종료 최소 수
    candidates := planner.Candidates(view.Field, view.Revolution)   // 엔진의 합법 수와 같은 목록

    // ① 정상 즉시 탈출
    for _, play := range candidates {
        if play.Count == len(view.Hand) && !model.IsForbiddenFinish(play, view.Revolution) {
            return playDecision(play), nil
        }
    }
    // ② 확정 종료 줄 — 장부의 Holds 가 증명
    if line := planner.FinishLine(view.Field, view.Revolution, turn.holds); len(line) > 0 {
        return playDecision(line[0]), nil
    }
    // ③ 정상 종료 가드
    candidates = turn.guard(candidates)
    if len(candidates) == 0 {
        return Decision{Action: ActionPass}, nil
    }
    return turn.choose(candidates)   // ④ ⑤
}

③ 의 가드에는 두 가지 예외가 있습니다.

// guard 는 낸 뒤 남은 손패로 정상 종료할 길이 없어지는 수를, 그렇지 않은 후보가 있으면 뺍니다.
func (t hardTurn) guard(candidates []model.Play) []model.Play {
    if t.raw {               // 지금도 정상 종료할 수 없는 손패면 가드는 아무것도 빼지 않는다
        return candidates
    }
    kept := slices.DeleteFunc(slices.Clone(candidates), func(play model.Play) bool { return t.after(play) == NoFinish })
    if len(kept) == 0 && t.view.Field == nil {   // 리드인데 다 빠졌으면 그대로 — 리드는 패스할 수 없다
        return candidates
    }
    return kept
}

받기에서 후보가 다 빠지면 패스 합니다. 받기에서 패스는 늘 손패를 지키니까요. 리드에서 다 빠지면 그대로 둡니다. 리드는 패스할 수 없고, 어차피 정상 종료할 길이 없는 손패입니다. 이미 조커 한 장만 남은 손패에서 가드가 모든 수를 막아 버리면 봇은 둘 것이 없어지므로, 그런 손패(raw)에서는 가드를 끕니다.

이 가드가 하는 일을 한 줄로 쓰면 “반칙패를 마지막 한 수가 아니라 처음부터 피한다” 입니다. easy 가 열 판에 한 판 반칙패로 끝나던 것이, hard 는 시뮬레이션 수십만 판에서 0 이 됐습니다.


4. 위협 차단 — 상대의 장수를 본다#

④ 는 받기에서만 있습니다. 조건은 셋입니다. 지금 필드에 받을 자격이 있는 상대 가운데, 남은 장수가 필드 장수와 같은 사람이 있고, 그 사람이 이 필드를 받을 위험(장부)이 0.5 이상 이면, 그 위험을 가장 많이 낮추는 받는 수를 냅니다.

이 규칙은 1차 구현의 알려진 한계 를 하나 안고 있습니다. 상대가 마지막 두 장으로 2 두 장이나 조커 든 쌍을 받으면 그것은 반칙 종료 이고, 그 상대는 꼴찌가 됩니다. 막을 이유가 없습니다. 그런데 장부는 “받을 수 있는가” 만 세고 “받으면 정상 종료인가” 는 세지 않습니다. 그래서 막지 않아도 될 자리에서 막는 때가 있습니다. 이것을 알고 fixture10 로 고정해 두었습니다. Part 6 에서 보겠지만, 이 차단이 실제로 발동하는 일은 드물어서(결정의 0.1%) 성적에는 거의 영향이 없었습니다.


5. 점수 층 — 네 개의 항과 하나의 문턱#

①~④ 를 지나면 후보가 여럿 남습니다. 여기서 점수를 매깁니다. 항은 넷이고, 받기에서는 패스 도 점수 0 인 후보로 들어갑니다.

항뜻방향값
진척 (Progress)계획대로 한 수를 털었는가+1 − (낸 뒤 최소 수 − (지금 최소 수 − 1)). 계획 그대로면 1, 한 수 손해면 0
통제 (Control)이 수가 버텨 다음 리드를 내가 잡을 가능성+1 − 위험 점수. 8컷·♠3극상11처럼 필드를 바로 정리하는 수면 1
온존 (Preserve)강한 카드를 지금 쓰는 비용−지금 서열 상위 네 숫자와 조커의 강함 합 × (남은 장수 ÷ 13)
8컷 (EightCut)128컷을 리드에서 일찍 쓰는 비용−8컷이 일어나는 수면 남은 장수 ÷ 13

점수는 1.0 × 진척 + 0.5 × 통제 − 1.2 × 온존 − 0.4 × 8컷 입니다. 이 가중치는 Part 6 의 실험과 튜닝 뒤에 동결된 값이고, 1차에는 여기에 두 항(위협·♠3)이 더 있었습니다. 실험에서 해롭다고 나와 지웠습니다. 그 이야기는 다음 편에 있습니다.

5.1 진척 — 계획을 거스르는 수에 벌점#

Part 4 의 표에서 바로 읽습니다. 지금 손패의 정상 종료 최소 수가 4 인데, 어떤 수를 낸 뒤 남은 손패의 최소 수가 3 이면 계획 그대로(진척 1)입니다. 쪼개서 냈더니 남은 손패가 4 수라면 한 수 손해(진척 0)입니다. 손해는 3 수까지만 셉니다.

planLoss := math.Min(3, float64(t.after(*play)-(t.base-1)))
terms["Progress"] = 1 - planLoss

이 항이 점수 층의 중심입니다. Part 6 에서 항을 하나씩 껐을 때 이 항을 끄면 성적이 가장 크게 떨어졌고(+0.577), 이 항만 켜도 기본 hard 를 이겼습니다.

5.2 온존 — 강한 카드는 손패가 많을 때 더 비싸다#

달무티 Part 8 의 “초반에 최상위 카드를 아낀다” 가 이 항입니다. 지금 서열 의 상위 네 숫자(평소라면 2·A·K·Q, 혁명13 중이라면 3·4·5·6)와 조커만 비용으로 셉니다. 손패가 13장일 때 2 한 장을 쓰면 비용 1, 손패가 3장이면 0.23 입니다. 종반에는 거의 공짜가 되어, 2 와 조커가 리드를 가져오는 데 쓰입니다.

// topStrength 는 온존 비용의 카드 값입니다 — 지금 서열 상위 네 숫자만 0.25~1, 조커 1, 그 밖은 0.
func topStrength(card model.Card, revolution bool) float64 {
    return math.Max(0, (normalStrength(card, revolution)-8.0/12)/(4.0/12))
}

revolution 을 받는다는 점이 대부호답습니다. 혁명 중에는 3 이 가장 비싼 카드입니다.

5.3 통제 — 가장 비싼 항은 마지막에#

통제는 Part 3 의 위험 점수를 불러야 해서 다른 항보다 수십 배 비쌉니다. 그래서 봇은 싼 항(진척·온존·8컷)을 먼저 더해 상한 을 구하고, 상한이 지금까지의 최고 점수보다 낮은 후보는 위험 점수를 아예 계산하지 않습니다. 통제는 최대 1 이고 가중치가 0.5 이므로 상한은 “싼 항의 합 + 0.5” 입니다. 상한이 낮은 후보는 가장 비싼 계산을 건너뜁니다.

5.4 문턱 — easy 보다 0.4 는 나아야#

chosen := easyIndex
if best != easyIndex && options[best].total-options[easyIndex].total >= t.weights.Margin {
    chosen = best
}

점수가 가장 높은 후보가 easy 의 수보다 0.4 이상 높을 때만 바꿉니다. 이 문턱이 왜 필요한지는 달무티에서 배웠습니다. 리드를 점수에 통째로 맡기면 약한 큰 뭉치를 계속 뒤로 미루다 끝내 못 털었습니다. 점수는 한 수를 보고, easy 의 “약한 것부터 통째로” 는 판 전체의 리듬을 봅니다. 둘이 조금 다를 때는 easy 가 맞을 때가 많고, 크게 다를 때만 점수가 맞습니다.

처음 값은 0.1 이었습니다. Part 6 의 튜닝이 0.4 로 올렸고, 그것이 가장 큰 개선 하나였습니다. 그리고 문턱을 없애면(언제든 점수대로 바꾸면) 성적이 +0.126 나빠졌습니다. 가드레일은 장식이 아니었습니다.


6. 조공 돌려주기#

대부호·부호가 돌려줄 카드를 고르는 자리는 리드·받기와 다른 결정입니다. easy 는 가장 약한 카드를 돌려줍니다. hard 는 가장 약한 k+2 장 안에서 돌려준 뒤 손패의 정상 종료 최소 수가 가장 작은 조합을 고릅니다. 두 장을 돌려줘야 하면 가장 약한 네 장 가운데 두 장을 고르는 식입니다.

pool := model.WeakestCards(view.Hand, min(len(view.Hand), count+2))
for _, picked := range combinationsOf(pool, count) {
    if plays := planner.MinPlays(planner.Full()^mask(picked), false); plays < bestPlays {
        best, bestPlays = picked, plays
    }
}

범위를 k+2 로 좁힌 이유는 달무티의 실패입니다. 계획 손상을 재는 탐색으로 돌려줄 카드를 고르게 했더니 오히려 졌습니다. 손상만 세고, 약한 뭉치가 줄어드는 이득은 못 셌기 때문입니다. 그래서 이번에는 “약한 카드 가운데서 고른다” 는 easy 의 뼈대를 두고, 그 안에서만 계획을 봅니다. 3 두 장과 4 한 장, 5 한 장 가운데 두 장을 돌려줘야 한다면, easy 는 3 두 장을 돌려주고 쌍을 깨지만 hard 는 4 와 5 를 돌려주고 3 쌍을 지킵니다. 받을 카드는 보지 않습니다. 시야에 없습니다.


7. 그 밖의 계약#

  • 결정적입니다.14 난수가 없습니다. 같은 시야면 같은 수입니다. 동점은 안정 정렬15과 카드 순서로 가릅니다. 이것이 Part 6 의 모든 실험이 성립하는 바탕입니다.
  • 시간 예산 안에서 돕니다. 한 수에 100ms 입니다. 넘기면 Part 1 의 fallback16 으로 easy 가 둡니다. 확정 종료 줄 탐색과 위험 점수 루프는 중간중간 시계를 봅니다. 실제 결정 시간은 p9917 가 1.5ms 쯤이고, 최악의 손패(같은 무늬 11장 + 조커 둘)가 10ms 안입니다.
  • 왜 그 수를 냈는지 남깁니다. 결정마다 어느 단계(①~⑤)에서 정해졌는지, 점수 층이면 후보별 항의 값이 무엇이었는지를 trace18 로 남깁니다. Part 7 의 수동 재생이 이것을 읽습니다.
  • 같은 시야면 같은 수 — 숨은 정보를 바꿔도. 남의 손패·블라인드19·나락20 카드를 바꿔도 시야가 같으면 결정이 같다는 테스트가 있습니다. 봇이 보지 말아야 할 것을 보지 않는다는 증명입니다.

정리#

easy 와 hard 가 갈라지는 자리는 다섯입니다.

  1. 끝낼 수 있으면 끝낸다. 한 수로 정상 종료할 수 있는 수가 있으면 다른 계산 없이 냅니다.
  2. 확정 종료 줄이 있으면 그 첫 수. 장부가 “아무도 못 받는다” 를 증명한 순서입니다.
  3. 끝내지 못하게 되는 수는 버린다. 반칙패를 마지막 한 수가 아니라 처음부터 피합니다. hard 의 반칙패는 0 입니다.
  4. 상대의 끝내기를 막는다. 장수가 같은 상대가 이 필드로 끝낼 위험이 크면 막습니다.
  5. 점수는 easy 의 수를 기본으로 두고, 0.4 이상 나을 때만 바꾼다. 진척·통제·온존·8컷 네 항이고, 받기에서는 패스도 후보입니다.

이 다섯 가운데 무엇이 실제로 차이를 내는지는 아직 모릅니다. 그것은 켜고 끄고 재어 봐야 압니다. Part 6 입니다.


시리즈 목록#



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

  2. 정상 종료: 반칙패가 되는 카드로 끝내지 않고 손패를 비우는 것입니다. ↩︎

  3. 확정 종료 줄: 첫 수부터 마지막 수까지 아무도 끼어들 수 없다는 것이 증명된, 손패를 합법으로 다 비우는 순서입니다. Part 4 에서 설명합니다. ↩︎

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

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

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

  7. 진척(Progress): 이 수를 냈을 때 손패 계획대로 한 수만큼 줄었는가를 재는 점수 항입니다. 계획을 거스르는 수(예: 쌍을 쪼개 한 장만 내기)는 남은 수가 늘어나므로 벌점을 받습니다. ↩︎

  8. 온존(溫存): 강한 카드를 아껴 두는 것입니다. 이 시리즈에서는 «지금 서열의 상위 카드와 조커를 쓰는 비용» 을 재는 점수 항으로, 손패가 많을수록 비용이 크고 종반에는 거의 0 이 됩니다. ↩︎

  9. 통제(Control): 이 수가 필드에 놓인 뒤 아무도 받지 못해 다음 리드가 다시 내게 올 가능성을 재는 점수 항입니다. 장부의 위험 점수를 1 에서 뺀 값이고, 8컷처럼 필드를 바로 정리하는 수는 1 입니다. ↩︎

  10. fixture(기준 결정 고정 파일): 미리 정해 둔 상황 60개에서 봇이 낸 수를 적어 둔 파일입니다. 코드를 고쳐 수가 달라지면 테스트가 실패해 «행동이 바뀌었다» 를 알려 줍니다. ↩︎

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

  12. 8컷 항: 8컷은 8 이 든 수를 내면 그 자리에서 필드가 정리되고 다시 내가 리드하는 규칙입니다. 점수 항으로서의 8컷은 «리드를 되찾는 데 값진 8 을 너무 일찍 쓰는 비용» 입니다. ↩︎

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

  14. 결정적: 난수를 쓰지 않아 같은 입력에는 언제나 같은 결과가 나온다는 뜻입니다. 그래야 같은 판을 다시 돌려 봇을 비교하고, 문제가 난 수를 재현할 수 있습니다. ↩︎

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

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

  17. p99: 측정값을 작은 순으로 줄 세웠을 때 99% 지점의 값입니다. «결정 시간 p99 1.5ms» 는 결정 100번 중 99번이 1.5ms 안에 끝났다는 뜻으로, 평균보다 «느린 경우» 를 보는 데 씁니다. ↩︎

  18. trace: 봇이 한 결정의 과정(어느 단계에서 정해졌는지, 후보별 점수가 얼마였는지)을 남긴 기록입니다. ↩︎

  19. 블라인드: 카드를 나눌 때 아무에게도 주지 않고 엎어 두는 카드입니다. 4인은 2장, 5인은 4장이고 조커는 블라인드에 가지 않습니다. ↩︎

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