belote-cli 3.0.2__tar.gz → 3.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. {belote_cli-3.0.2 → belote_cli-3.2.0}/.claude/settings.local.json +2 -1
  2. belote_cli-3.2.0/AGENT.md +12 -0
  3. {belote_cli-3.0.2 → belote_cli-3.2.0}/CHANGELOG.md +120 -0
  4. {belote_cli-3.0.2 → belote_cli-3.2.0}/DEVELOPMENT.md +3 -3
  5. {belote_cli-3.0.2 → belote_cli-3.2.0}/PKG-INFO +30 -6
  6. {belote_cli-3.0.2 → belote_cli-3.2.0}/README.md +29 -5
  7. {belote_cli-3.0.2 → belote_cli-3.2.0}/pyproject.toml +1 -1
  8. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/__init__.py +1 -1
  9. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/ai.py +30 -10
  10. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/core/run_state.py +12 -1
  11. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/core/scoring.py +12 -12
  12. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/engine/modifier_patch.py +20 -22
  13. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/engine/round_driver.py +13 -0
  14. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/jokers/hand_comp.py +7 -9
  15. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/jokers/trick_timing.py +7 -2
  16. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/registry.py +20 -0
  17. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/tarots.py +21 -9
  18. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/main.py +11 -0
  19. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/run/boss.py +23 -23
  20. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/run/shop.py +55 -20
  21. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ui/shop.py +90 -3
  22. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/game.py +20 -29
  23. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/input.py +2 -2
  24. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/replay.py +3 -0
  25. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/scoring.py +33 -24
  26. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/stats.py +6 -5
  27. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/ui/layout.py +3 -1
  28. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/ui/render.py +14 -1
  29. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_belatro.py +100 -4
  30. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_boss_modifiers_integration.py +35 -0
  31. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_dead_flag_fixes.py +41 -5
  32. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_phase1_plumbing.py +106 -0
  33. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_phase2_content.py +104 -0
  34. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_phase3_meta.py +28 -0
  35. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_round_driver.py +6 -4
  36. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_belote.py +24 -0
  37. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_official_rules.py +34 -0
  38. {belote_cli-3.0.2 → belote_cli-3.2.0}/.gitignore +0 -0
  39. {belote_cli-3.0.2 → belote_cli-3.2.0}/.python-version +0 -0
  40. {belote_cli-3.0.2 → belote_cli-3.2.0}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
  41. {belote_cli-3.0.2 → belote_cli-3.2.0}/LICENSE +0 -0
  42. {belote_cli-3.0.2 → belote_cli-3.2.0}/scripts/benchmark.py +0 -0
  43. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/__init__.py +0 -0
  44. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/a11y.py +0 -0
  45. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/achievements.py +0 -0
  46. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/ansi.py +0 -0
  47. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/__init__.py +0 -0
  48. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/core/__init__.py +0 -0
  49. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/core/economy.py +0 -0
  50. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/engine/__init__.py +0 -0
  51. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/engine/event_bus.py +0 -0
  52. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ghost_run.py +0 -0
  53. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/__init__.py +0 -0
  54. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/base.py +0 -0
  55. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/jokers/__init__.py +0 -0
  56. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/jokers/annonces.py +0 -0
  57. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/jokers/coinche.py +0 -0
  58. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/jokers/contract.py +0 -0
  59. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  60. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/jokers/economy.py +0 -0
  61. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  62. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  63. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  64. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  65. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/planets.py +0 -0
  66. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/vouchers.py +0 -0
  67. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/partner/__init__.py +0 -0
  68. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/partner/partner_state.py +0 -0
  69. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/partner/personality.py +0 -0
  70. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/partner/trust.py +0 -0
  71. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/progression/__init__.py +0 -0
  72. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/progression/save.py +0 -0
  73. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/progression/unlocks.py +0 -0
  74. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/run/__init__.py +0 -0
  75. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/run/ante.py +0 -0
  76. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/run/ante_themes.py +0 -0
  77. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/run/decks.py +0 -0
  78. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/run_summary.py +0 -0
  79. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ui/__init__.py +0 -0
  80. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ui/announce.py +0 -0
  81. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ui/collection.py +0 -0
  82. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ui/hud.py +0 -0
  83. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ui/menu.py +0 -0
  84. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ui/rules.py +0 -0
  85. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ui/trust_bar.py +0 -0
  86. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/config.py +0 -0
  87. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/context.py +0 -0
  88. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/deck.py +0 -0
  89. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/gameflow.py +0 -0
  90. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/main.py +0 -0
  91. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/rules.py +0 -0
  92. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/themes.py +0 -0
  93. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/ui/__init__.py +0 -0
  94. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/ui/announce.py +0 -0
  95. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/ui/menu.py +0 -0
  96. {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/ui/prompts.py +0 -0
  97. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/__init__.py +0 -0
  98. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/__init__.py +0 -0
  99. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_collection_logic.py +0 -0
  100. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_contract_unlocks.py +0 -0
  101. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_deck_variants.py +0 -0
  102. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_ghost_run.py +0 -0
  103. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_hud_synergy.py +0 -0
  104. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_partner_trust.py +0 -0
  105. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_phase0_coverage.py +0 -0
  106. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_progression.py +0 -0
  107. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_a11y.py +0 -0
  108. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_achievements.py +0 -0
  109. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_ai.py +0 -0
  110. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_ansi_helpers.py +0 -0
  111. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_extended.py +0 -0
  112. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_game_logic.py +0 -0
  113. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_gameflow.py +0 -0
  114. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_layout.py +0 -0
  115. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_new_coverage.py +0 -0
  116. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_properties.py +0 -0
  117. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_replay.py +0 -0
  118. {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_undo.py +0 -0
@@ -11,7 +11,8 @@
11
11
  "Bash(python3 -m pytest tests/ -x -q)",
12
12
  "Bash(PYTHONPATH=src python3 *)",
13
13
  "Bash(.venv/bin/python -m mypy src/)",
14
- "Bash(PYTHONPATH=src python -m pytest --tb=short -q)"
14
+ "Bash(PYTHONPATH=src python -m pytest --tb=short -q)",
15
+ "Bash(python3 *)"
15
16
  ]
16
17
  }
17
18
  }
@@ -0,0 +1,12 @@
1
+ You are auditing a Python codebase for bugs.
2
+
3
+ Rules:
4
+ 1. Every finding has fields: file:line, claim, evidence, confidence (CONFIRMED|LIKELY|SPECULATIVE), severity.
5
+ 2. CONFIRMED requires citing both the buggy code AND the code that proves the bug (e.g., the missing reader for a "dead
6
+ flag", the caller that passes the bad input).
7
+ 3. Severity P0/P1 requires CONFIRMED. LIKELY findings max out at P2.
8
+ 4. For any "X is unused/dead/never called" claim: paste `grep -rn "X" .` output. Zero non-test hits required.
9
+ 5. For any "Y crashes" claim: name the input that triggers the crash and the call path that delivers it.
10
+ 6. End the report with a "Findings I considered but rejected" section — at least 3 items. This forces you to
11
+ demonstrate you tried to falsify.
12
+ 7. Use the tools provided (Read, Grep, Bash). Do not reason from memory about file contents.
@@ -5,6 +5,126 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.2.0] - 2026-05-10
9
+
10
+ Two-audit reconciliation release — the prioritized fix list distilled from Qwen 3.6 27B + Ring 1T audits (~30 raw claims, ~half held up under verification). Twelve real bugs fixed across joker logic, registry hygiene, RNG determinism, and UI offsets; one new finding (Tarot RNG was also unseeded) caught by the fresh-hunt pass. Eleven audit claims rejected as false positives are catalogued in the plan file so they aren't re-investigated. 528 tests passing (up from 525), ruff and mypy strict still clean.
11
+
12
+ ### Fixed
13
+
14
+ - **`src/belote/belatro/items/jokers/hand_comp.py::LaSentinelle`** — Detection of the trump Jack now keys on the NS *team* via `team_of(seat) == 0` instead of `seat == Seat.SOUTH`. Pre-3.2 the joker was silently no-op when North (the partner) was dealt the trump Jack, even though Belote's "you" is team-level. Trick-win detection follows the same team rule. Regressions: `tests/belatro/test_dead_flag_fixes.py::test_la_sentinelle_arms_when_partner_plays_trump_jack`, `test_la_sentinelle_does_not_arm_for_opponent_jack`.
15
+ - **`src/belote/belatro/items/jokers/trick_timing.py::LeDernierMot`** — Dix de Der replacement now fires whenever the NS team wins the last trick (`team_of(event.winner) == 0`), not only when South personally takes it. Pre-3.2 the joker silently did nothing when partner won the closing trick. Regressions: `tests/belatro/test_belatro.py::TestLeDernierMot::test_north_last_trick_returns_result`, `test_east_last_trick_returns_none`.
16
+ - **`src/belote/belatro/items/jokers/corrupted.py::LEgoiste` → `src/belote/belatro/core/scoring.py::ScoreAccumulator.get_total`** — Final chip total is now `max(0, state._chips)`. L'Égoïste subtracts `event.card_points` for every partner-won trick; with enough partner wins the running total could cross zero, producing a negative final round score. Clamping at the scoring boundary preserves the intermediate accounting log while guaranteeing the visible score is never negative.
17
+ - **`src/belote/belatro/engine/round_driver.py:236-249`** — NS-taker `auto_coinche` path now re-emits `BidMadeEvent` with the new `coinche_level` so jokers/HUD subscribed to `on_bid` see the bump. The EW-taker branch above always emitted; this NS-side branch silently set `coinche_level = 1` without notifying subscribers.
18
+ - **`src/belote/belatro/core/run_state.py::BelAtroRun.advance_blind`** — Victory now sets both `run_won = True` and `run_over = True`, so downstream callers can rely on `run_over` alone as the terminal-state signal. `enter_endless()` resets both, re-opening the run for endless mode. Pre-3.2 the main loop only terminated via a `break` after a `run_won` check — semantically correct but fragile under refactors.
19
+ - **`src/belote/belatro/items/registry.py::ItemRegistry.register_*`** — All four register methods (`joker` / `planet` / `tarot` / `voucher`) now assert that an existing entry under the same `id` is the *same class*. Pre-3.2 a typo'd duplicate ID would silently overwrite the prior class, and the override would never surface until the original behaviour visibly broke. Idempotent re-registration of the same class still works for the test-suite swap pattern.
20
+ - **`src/belote/belatro/engine/modifier_patch.py`** — `boss_fields` is now derived from `BossModifiers`' dataclass fields via `dataclasses.fields(BossModifiers)` instead of a hardcoded set. Pre-3.2 a new boss flag added to `BossModifiers` would be silently no-op'd until someone remembered to add it to the hardcoded allowlist in lock-step.
21
+
22
+ ### Determinism
23
+
24
+ - **`src/belote/belatro/run/shop.py::Shop.generate_inventory`** — All RNG calls (`random.random` / `random.choice` / `random.sample` across edition rolls, joker pick, tarot/planet pick, voucher pick) now use `self.run._get_rng()` instead of the module-level `random`. Pre-3.2 shop contents were non-deterministic even with a seeded run, which broke ghost-run reproducibility. `Shop._roll_edition` signature changed to accept an explicit `rng` argument; the `test_shop_edition_weights_match_distribution` test was updated to pass the seeded RNG directly instead of monkey-patching `shop_mod.random.random`.
25
+ - **`src/belote/belatro/items/tarots.py`** — `LeJugement`, `LaPretresse`, and `LeFou` all now draw from `run._get_rng()` instead of the module-level `random`. Module-level `import random` removed.
26
+
27
+ ### Improved
28
+
29
+ - **`LaPretresse` planet picks now deduplicate** — switched from two independent `random.choice(planets)` calls to `rng.sample(planets, k=2)`, so the tarot can no longer pick the same planet twice. Falls back to a single pick when the planet pool has fewer than 2 entries.
30
+ - **`LeJugement` slot-full notification** — new `BelAtroRun.last_tarot_message: str | None` field carries a non-fatal failure reason ("joker slots are full — no joker granted") when the tarot can't complete. Pre-3.2 the joker was silently dropped with no UI signal. Cleared whenever a tarot is used.
31
+ - **`src/belote/ui/render.py::patch_trick_card`** — Now reads `_last_rendered_unpadded_h` (set by `render()`) and threads it into `_calculate_base_row`, so single-card patches re-apply the same vertical-centering offset `render()` used. Pre-3.2 it passed the "I don't know" sentinel (0) and skipped the offset entirely, drawing cards too high on tall terminals (>40 rows).
32
+ - **`src/belote/ui/layout.py`** — `hud_style` docstring corrected. Pre-3.2 it claimed `"verbose" / "standard" / "compact"`, but no preset used `"standard"` and no consumer recognized it — only `"verbose"` and `"compact"` are real.
33
+
34
+ ### Rejected (catalogued so they aren't re-investigated)
35
+
36
+ Eleven claims from the input audits were rejected after verification against the actual code:
37
+
38
+ - LaBalance voucher (`tie_breaks_for_taker`) and LaCompetition (`separate_scoring`) flags — **both consumed** in `src/belote/scoring.py` and `src/belote/belatro/main.py`. Qwen flagged both as P0 dead-flag bugs; verification falsified both.
39
+ - LeFou tarot "chain broken" — `run_state.py::consume` sets `last_consumable_id` *before* `item.use()` runs, so chaining works as intended.
40
+ - `no_belote_rebelote` deck-mod flag — consumed at `src/belote/scoring.py:630`.
41
+ - `_pending_tierce_charge` cross-round leak — each blind constructs a fresh `ScoreAccumulator` (main.py:126) and `drive_round` builds a fresh `GameState` via `new_game()` (round_driver.py:84), so `_joker_state` is empty at every round start. No cross-round persistence path exists.
42
+ - `fuse_jokers` "loses `on_purchase` effects" — `on_purchase` mutates `run` state (which survives fusion); re-applying on the fused instance would *double-apply* cumulative effects (LeDemon's trust drop). Pre-3.2 behaviour is correct.
43
+ - IllegalMoveError in `round_driver.py:291` — reachable only via test MockCallbacks; production `prompt_card` has a guard.
44
+ - `_card_beats` defensive `assert trump is not None` — unreachable under current contract invariants.
45
+ - `display_hud` no clear-to-EOL — HUD is rebuilt fresh per call; the claim was wrong.
46
+ - Libra planet description — "×4 instead of ×3" matches the payout; mechanism is additive per coinche level but the description references the result.
47
+ - `get_total()` float precision — explicit `int()` guard at scoring.py:248-249.
48
+ - KeyboardInterrupt save — profile is saved *before* the loop starts; only intra-run delta is lost.
49
+
50
+ ### Internal
51
+
52
+ - **Tests**: 525 → 528 (+3 net: −1 test renamed/repurposed for LeDernierMot team check, +2 new for La Sentinelle partner-detection and EW opponent rejection).
53
+ - **Strict gates**: pytest 528/528, mypy 0 errors, ruff 0 violations across `src/` and `tests/`.
54
+ - **Audit plan**: `~/.claude/plans/between-these-two-plans-graceful-puppy.md` — captures the two source audits, the verification pass that filtered them, the implementation order, and the catalogue of rejected claims.
55
+
56
+ ## [3.1.0] - 2026-05-08
57
+
58
+ Audit-action release — implements the prioritized fix list from 3.0.3. One real correctness bug fixed, one unreachable feature wired up, one money-leak path closed, three measurable perf wins, and the long-standing `modifier_patch` underscore shim retired. 525 tests passing (up from 510), ruff and mypy strict still clean across 75 source files.
59
+
60
+ ### Fixed
61
+
62
+ - **`src/belote/game.py:843-855` (HUD multi-boss running total)** — Under `Les Clubs Bannis + Le Roi Mort` (or any combo of `ban_clubs` with a rank-zero boss), the live HUD running total in `play_card` over-credited a clubs-led trick: the `ban_clubs → trick_pts = 0` branch was immediately overwritten by the rank-zero recompute. The eventual round score was already correct (different code path through `scoring.py`). Now `play_card` delegates to `scoring.trick_card_points`, the canonical helper that composes every boss zero-rank flag, `ban_clubs`, and the SE-trump scale in a single pass — the HUD cannot drift from the round score under any boss combo. Regression: `tests/test_official_rules.py::test_hud_running_total_under_multi_boss_ban_clubs_plus_kings_zero`.
63
+ - **`src/belote/belatro/run/shop.py::buy_item` (consumable money-leak)** — Slot-capacity check is now hoisted *above* `Economy.spend_money`. Pre-3.1.0 the player's money was charged for a Tarot/Planet purchase even when consumable slots were full, and the item was silently dropped. New `Shop.last_buy_failure: str | None` carries the reason ("slots_full" / "no_money") so the shop UI surfaces a `BelAtroAnnounce.banner("Slots full — sell first")` banner. Regressions: `tests/belatro/test_belatro.py::TestShop::test_buy_consumable_with_full_slots_does_not_charge_money`, `test_buy_joker_with_full_slots_does_not_charge_money`, `test_buy_item_no_money_records_no_money_failure`.
64
+
65
+ ### Added
66
+
67
+ - **TierceForge UI integration** (`src/belote/belatro/ui/shop.py`) — The `TierceForge` voucher shipped in 3.0.0 with a working `forge_tierce(run, planet_id)` backend (`src/belote/belatro/items/vouchers.py:129`) but no UI caller; the feature was unreachable. The shop now shows a "Forge ×N/3" tile when the voucher is owned, opens a numbered planet picker on Enter, and surfaces a confirmation banner on success. Regressions: `tests/belatro/test_phase2_content.py::test_forge_tierce_voucher_spends_charges_and_levels_planet`, `test_forge_tierce_blocked_when_charges_below_three`.
68
+ - **Block-policy regressions for Tarot overflow** — `LeJugement` and `LaPretresse` are now pinned to no-op when joker/consumable slots are at capacity (rather than partial-grant). Tests: `test_le_jugement_no_op_when_joker_slots_full`, `test_la_pretresse_no_op_when_consumable_slots_full`.
69
+ - **`tests/belatro/test_phase1_plumbing.py::test_joker_state_only_contains_scalar_values`** — Walks every registered joker through `on_round_start` + four event hooks and asserts no mutable container leaks into `_joker_state`. Locks the contract that lets the per-event copy stay shallow (3.1.0 dropped the deepcopy).
70
+ - **`tests/belatro/test_phase1_plumbing.py::test_shop_edition_weights_match_distribution`** — 10 000-roll empirical check on `Shop._roll_edition()`, ±1% per bucket. Catches accidental edits to the `_EDITION_WEIGHTS` table.
71
+ - **`tests/belatro/test_phase3_meta.py::test_endless_ante_target_scaling`** + `test_endless_ante_offset_zero_matches_base_table` — pin the `100 × 1.5^(ante-1) × blind × 2.2^offset` formula and the static-table parity invariant.
72
+ - **`tests/belatro/test_phase2_content.py::test_le_fou_no_prior_consumable_falls_back_to_random_tarot`** — covers the `last_id == self.id` defensive branch in `tarots.py::LeFou.use`.
73
+ - **`tests/belatro/test_boss_modifiers_integration.py::test_invariant_no_underscore_boss_attrs`** — anti-pattern lock for the architecture-pinned rule that boss flags must be reached via `state.boss_modifiers.X`, never `getattr(state, "_X", False)`.
74
+
75
+ ### Improved
76
+
77
+ - **`src/belote/scoring.py` (winners-threading)** — `score_round` already pre-computed the per-trick winner list (3.0.2); the residual `trick_winner_seat` recomputations in the Malédiction branch (lines 776-793) and `apply_round_score` (lines 843-855) are now eliminated. Per-team trick counts ride on the new `ScoringBreakdown.tricks_ns` / `tricks_ew` fields (default 0; `apply_round_score` falls back to walking when a hand-constructed breakdown leaves them at default). Net: ~16 fewer `trick_winner_seat` calls per round.
78
+ - **`src/belote/belatro/core/scoring.py::ScoreAccumulator.update_state` (deepcopy → shallow)** — Replaced the per-event `copy.deepcopy(state._joker_state)` with `dict(state._joker_state)`. All current `_joker_state` writers store scalars (bool/int/str), so the deep-copy was over-defensive — and ran ~20×/round. Module-level `import copy` and `from dataclasses import replace` removed (they were also reimported inside two methods). Contract is locked by the new scalar-invariant test.
79
+ - **`src/belote/ai.py` (Hard AI hot-loop allocations)** — `_hard_play` precomputes `hand_suit_counts: Counter[Suit]`, `my_trumps`, `opp_trumps` once per turn and threads them into `_score_card_play` / `_score_leading_strategy` / `_score_discarding_strategy`. Pre-3.1.0 these counters were rebuilt for every candidate card — a four-card legal set walked the hand and `memory.played` four times each.
80
+ - **`@dataclass(slots=True)` on `Statistics`, `SessionStats`, `ScoreAccumulator`** (`src/belote/stats.py`, `src/belote/belatro/core/scoring.py`). Frequently-instantiated containers; ~40 bytes saved per instance. `BelAtroRun` deliberately stays non-slotted (its `__post_init__` lazy-init pattern fights `slots=True`).
81
+ - **`src/belote/stats.py:97-98`** — `print(..., file=sys.stderr)` on save failure swapped for `logging.getLogger(__name__).warning`. Removed unused `import sys`.
82
+ - **`src/belote/input.py:138, 160`** — bare `except Exception:` in key-press parsing narrowed to `(UnicodeDecodeError,)` and `(ValueError, UnicodeDecodeError)`. Genuine bugs surface; key-press robustness preserved.
83
+ - **`src/belote/replay.py:46`** — explanatory comment added above the `# noqa: BLE001` so the broad-except rationale is visible at the call site.
84
+ - **`src/belote/game.py:213-217, 220-224`** — docstring on `belote_holders` and `_joker_state` documenting the "always replace, never mutate-in-place" contract for mutable dicts inside the frozen `GameState`.
85
+
86
+ ### Removed
87
+
88
+ - **`modifier_patch.py` underscore shim** — The `state.patch("_X", True)` → `state.patch("X", True)` migration is complete. All 23 boss `apply()` methods in `src/belote/belatro/run/boss.py` were rewritten in lock-step. The leading-underscore strip in `PatchedGameState.patch()` and the `__getattr__` fallback to `boss_modifiers.X` are gone; `patch()` now asserts loud on a leading-underscore key. The `getattr(state, "_X", False)` reading anti-pattern is locked against in `test_invariant_no_underscore_boss_attrs`.
89
+
90
+ ### Internal
91
+
92
+ - **Tests**: 510 → 525 (+15).
93
+ - **Strict gates**: pytest 525/525, mypy 0 errors, ruff 0 violations across `src/` and `tests/`.
94
+ - **Audit plan**: `~/.claude/plans/bug-hunt-code-performance-sleepy-ritchie.md`.
95
+
96
+ ## [3.0.3] - 2026-05-08
97
+
98
+ Full-codebase audit pass + documentation accuracy. No behaviour changes; the audit produced a prioritized findings list and corrected three stale README counts. Planned fixes (one P0 functional, two P0 perf/quality, five P1, seven P2) are tracked for follow-up cuts and not yet implemented.
99
+
100
+ ### Fixed (documentation)
101
+
102
+ - **`README.md`** — "Full Boss Blind Suite: All 18 unique bosses" → "All 21 unique bosses". 3.0.0 added Le Sauvage / L'Iconoclaste / Le Mime to bring `ALL_BOSS_MODIFIERS` (in `src/belote/belatro/run/boss.py`) to 21; the showcase line was never bumped.
103
+ - **`README.md`** — two stale "(435 tests)" / "pytest: 435/435 passed" references corrected to 510, matching `pytest --collect-only` and the figure already present at `README.md:250` ("Currently 510 tests passing").
104
+
105
+ ### Audit findings (planning only — implementation deferred)
106
+
107
+ A three-agent audit covered the classic engine vs. canonical Belote rules, BelAtro content wiring (jokers / bosses / planets / vouchers / tarots / editions / unlocks), and performance / code-quality hotspots across ~7,100 LOC. Headline: engine is rule-correct; BelAtro content matrix is 93/93 wired (21 bosses, 8 planets, 36 jokers, 4 editions, 12 vouchers, 12 tarots).
108
+
109
+ Findings tracked at `~/.claude/plans/bug-hunt-code-performance-atomic-sutton.md`:
110
+ - **P0-1** — `EventBus.emit` still never called (carried over from 3.0.2). `L'Exécuteur` / `L'Idéologue` / `Le Fanatique` unlocks silently never fire.
111
+ - **P0-2** — `legal_cards()` LRU wrapper rebuilds `Card` objects on every cache hit (`src/belote/game.py:475-653`); est. 5–8% AI-turn regression vs. caching the resolved tuple.
112
+ - **P0-3** — `play_card()` is 174 LOC / cyclomatic ~20 (`src/belote/game.py:777-950`); split into `_update_belote_tracker` / `_apply_play_modifiers` / `_resolve_trick_complete`.
113
+ - **P0-4** — `_calculate_base_points()` accepts an optional pre-computed `winners` arg; cache-miss callers walk all 8 tricks twice (`src/belote/scoring.py:580-588`). Make required.
114
+ - **P1-1** — `card_points(trump: Suit)` lies about None; 8 `# type: ignore` markers across `game.py` / `scoring.py` should drop once signature becomes `Suit | None`.
115
+ - **P1-2** — Boss zero-rank logic duplicated across three sites (`game.py:856-872`, `scoring.py:390-400`, `scoring.py:429-440`); extract a single `apply_zero_rank_bosses(card, trump, bm)` helper. Highest-leverage maintenance fix.
116
+ - **P1-3..P1-5** — `_hard_bid` recomputes void counts inside the suit loop; `trick_rank()` called twice per overtrump check; missing docstrings on hot APIs.
117
+ - **P2** — carré KeyError harden, `REBELOTE_POINTS = 40` variant doc, AI memory reset hardening, `render()` 129-LOC split, `register_all_items` `__all__`, voucher / tarot integration test (24 effects to cover).
118
+
119
+ ### Internal
120
+
121
+ - **Tests**: 510 (unchanged).
122
+ - **Strict gates**: pytest 510/510, mypy 0 errors, ruff 0 violations (all unchanged from 3.0.2).
123
+
124
+ ### Carried forward
125
+
126
+ - `EventBus.emit` wiring fix (P0-1 above) remains deferred. Now planned for 3.0.4 alongside the perf wins.
127
+
8
128
  ## [3.0.2] - 2026-05-08
9
129
 
10
130
  Audit pass — wired two previously-dead 3.0.0 modules behind opt-in env vars, removed redundant work from `score_round()`, and pinned every boss modifier's patch keys against typo regressions.
@@ -84,14 +84,14 @@ PYTHONPATH=src mypy --strict src/
84
84
  # Linting (0 violations expected)
85
85
  ruff check src/ tests/
86
86
 
87
- # Full test suite (510 tests expected)
87
+ # Full test suite (528 tests expected)
88
88
  PYTHONPATH=src pytest
89
89
  ```
90
90
 
91
- Current baseline (3.0.2):
91
+ Current baseline (3.2.0):
92
92
  - **mypy**: 0 errors (strict mode)
93
93
  - **ruff**: 0 violations
94
- - **pytest**: 510 tests, 0 failures
94
+ - **pytest**: 528 tests, 0 failures
95
95
 
96
96
  Run all gates before committing:
97
97
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: belote-cli
3
- Version: 3.0.2
3
+ Version: 3.2.0
4
4
  Summary: A 4-player terminal card game
5
5
  Project-URL: Homepage, https://github.com/ElysiumDisc/belote
6
6
  Project-URL: Repository, https://github.com/ElysiumDisc/belote
@@ -45,12 +45,36 @@ Description-Content-Type: text/markdown
45
45
 
46
46
  Complete implementation of the French card game Belote for the terminal, with a full-screen green felt table and full card graphics at compass positions (N/W/E/S).
47
47
 
48
+ ## What's new in 3.2.0
49
+
50
+ - **Joker correctness** — La Sentinelle and Le Dernier Mot both used to key on `Seat.SOUTH` instead of the NS team, so the joker silently no-op'd when North (the AI partner) held the trump Jack or won the last trick. Both now correctly fire on team membership.
51
+ - **Score floor** — L'Égoïste's `add_chips = -event.card_points` per partner-won trick could drive the running total negative and produce a negative final round score; `ScoreAccumulator.get_total` now clamps at 0 so the intermediate log can still show the deduction without the visible score going below zero.
52
+ - **Auto-coinche event parity** — The NS-taker `auto_coinche` boss path now re-emits `BidMadeEvent` with the new `coinche_level`, matching the EW-taker branch. Pre-3.2 jokers and HUD subscribed to `on_bid` silently missed this code path.
53
+ - **Determinism** — The shop and the three RNG-using tarots (`LeJugement`, `LaPretresse`, `LeFou`) now all draw from the run's seeded `_get_rng()` instead of the module-level `random`. Ghost-run replays are now reproducible across shop generations. `LaPretresse` additionally `sample`s instead of `choice`-ing twice, so the two planets it grants are always distinct.
54
+ - **Registry / boss-field hygiene** — `register_joker/planet/tarot/voucher` now assert against duplicate IDs (typo'd registrations used to silently overwrite the original). `boss_fields` in `modifier_patch.py` is now derived from `BossModifiers`' dataclass fields, so adding a new flag no longer requires updating an out-of-band allowlist.
55
+ - **UI fix** — `patch_trick_card` now re-applies `render`'s vertical-centering offset; on tall terminals (>40 rows) it used to draw single-card patches too high.
56
+ - **Audit reconciliation** — This release consolidates the verified findings from two independent LLM code audits (Qwen 3.6 27B + Ring 1T). Both audits had load-bearing false positives — Qwen's two P0 "dead voucher / dead boss flag" claims were both wrong (the flags are consumed); Ring's "critical IllegalMoveError" only fires under test mocks. Eleven rejected claims are catalogued in the changelog so they aren't re-investigated.
57
+ - **Test coverage** — 528 tests (up from 525). Strict gates still clean: pytest 528/528, mypy 0 errors, ruff 0 violations.
58
+
59
+ ## What's new in 3.1.0
60
+
61
+ - **Bug fixes** — HUD running-total no longer drifts under multi-boss combos (`Les Clubs Bannis + Le Roi Mort` style: pre-3.1.0 the rank-zero recompute silently overwrote the `ban_clubs` zeroing). `Shop.buy_item` no longer charges money when consumable slots are full — the "Slots full — sell first" banner now fires before any spend.
62
+ - **TierceForge wired up** — the voucher shipped in 3.0.0 with a working backend but no UI caller; the feature was unreachable. The shop now shows a "Forge ×N/3" tile when the voucher is owned, opens a numbered planet picker on Enter, and confirms the level-up via a banner.
63
+ - **Performance** — `score_round` and `apply_round_score` no longer re-walk the trick list (~16 fewer `trick_winner_seat` calls per round). The per-event `copy.deepcopy` in `ScoreAccumulator.update_state` is gone (~20 deepcopies/round saved); replaced with a shallow `dict(...)` plus a scalar-only invariant test that locks the contract. Hard-AI's `_score_card_play` precomputes hand suit counts and trump tallies once per turn instead of per candidate.
64
+ - **Cleanup** — the `modifier_patch` underscore-prefix shim is gone (23 boss `apply()` methods rewritten to use unprefixed field names; the `getattr(state, "_X", False)` anti-pattern is now locked against by a regression test). `slots=True` added to `Statistics`, `SessionStats`, `ScoreAccumulator`. Bare `except Exception:` in key-press parsing narrowed; `print → logging` in stats.
65
+ - **Test coverage** — 525 tests (up from 510). Strict gates still clean: pytest 525/525, mypy 0 errors, ruff 0 violations.
66
+
67
+ ## What's new in 3.0.3
68
+
69
+ - **Full-codebase audit** — three-agent pass over the classic engine, BelAtro content wiring, and perf / code-quality hotspots (~7,100 LOC). Headline: engine is rule-correct against canonical French Belote; BelAtro content matrix is **93/93 wired** (21 bosses, 8 planets, 36 jokers, 4 editions, 12 vouchers, 12 tarots). Prioritized findings list (1 P0 functional, 2 P0 perf, 5 P1, 7 P2) tracked for follow-up cuts; implementation landed in 3.1.0.
70
+ - **Doc accuracy** — README boss-count corrected (18 → 21; 3.0.0 added Le Sauvage / L'Iconoclaste / Le Mime), and two stale `(435 tests)` references bumped to 510 to match the figure already present elsewhere in the file.
71
+
48
72
  ## What's new in 3.0.2
49
73
 
50
74
  - **Replay analyzer + Ghost run wired up** — both shipped in 3.0.0 as code modules but were never called from the running game. Now opt-in behind `BELOTE_REPLAY=1` (post-round Hard-AI comparison) and `BELOTE_GHOST=1` (per-run JSON dump to `~/.local/share/belote/ghosts/`). See DEVELOPMENT.md › Optional Runtime Flags.
51
75
  - **Performance** — `score_round()` now caches per-trick winners once instead of recomputing them in each boss-modifier helper (2-3× walks → 1× walk per round). `register_all_items()` is now idempotent so test setup no longer re-walks every items module per `BelAtroRun`. Bidding's special-bid path (TA / SA) hoists `_suit_lengths` out of the per-difficulty branches.
52
76
  - **Defensive pin** — every entry in `ALL_BOSS_MODIFIERS` is now asserted to actually toggle a `BossModifiers` field via `.flags()`. Catches typo'd `state.patch("_misspelled", True)` keys at test time rather than letting the boss silently no-op.
53
- - **Test coverage** — 510 tests (up from 509).
77
+ - **Test coverage** — 525 tests (up from 509).
54
78
 
55
79
  ## What's new in 3.0.1
56
80
 
@@ -222,7 +246,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
222
246
 
223
247
  - **BelAtro Roguelite Mode:** A massive expansion featuring 36 Jokers, 12 Tarot cards, 8 Planets, 12 Vouchers, and permanent upgrades.
224
248
  - **Collection (Almanac):** Persistent tracker to browse every Joker, Planet, and Voucher you've discovered across your runs.
225
- - **Full Boss Blind Suite:** All 18 unique bosses implemented, including complex mechanics like *L'Anarchie* (dynamic trump) and *La Rupture* (no consecutive wins).
249
+ - **Full Boss Blind Suite:** All 21 unique bosses implemented, including complex mechanics like *L'Anarchie* (dynamic trump) and *La Rupture* (no consecutive wins).
226
250
  - **Multiplier Scoring:** Use items to stack Multipliers and reach scores in the millions.
227
251
  - **Partner Trust:** Build a relationship with your AI partner to unlock synergies.
228
252
  - **Rich Terminal UI:** Full-screen green felt table with detailed card graphics and "You" vs "Partner" terminology.
@@ -274,7 +298,7 @@ belote/
274
298
  │ ├── input.py # Platform-dispatched key reader and interruptible sleep
275
299
  │ ├── stats.py # Global and session statistics tracking
276
300
  │ └── rules.py # Game rules content
277
- ├── tests/ # Comprehensive test suite (435 tests)
301
+ ├── tests/ # Comprehensive test suite (528 tests)
278
302
  ├── scripts/ # Performance benchmarks
279
303
  ├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
280
304
  ├── LICENSE # MIT License
@@ -290,14 +314,14 @@ belote/
290
314
  PYTHONPATH=src pytest
291
315
  ```
292
316
 
293
- Currently **510 tests** passing with 100% coverage on game-logic modules.
317
+ Currently **528 tests** passing with 100% coverage on game-logic modules.
294
318
 
295
319
  ## Technical Integrity
296
320
 
297
321
  The codebase is strictly validated with the following tools:
298
322
  - **mypy**: 0 errors (strict type safety)
299
323
  - **ruff**: 0 violations (linting & formatting)
300
- - **pytest**: 435/435 passed
324
+ - **pytest**: 528/528 passed
301
325
  - **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
302
326
  - **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
303
327
 
@@ -2,12 +2,36 @@
2
2
 
3
3
  Complete implementation of the French card game Belote for the terminal, with a full-screen green felt table and full card graphics at compass positions (N/W/E/S).
4
4
 
5
+ ## What's new in 3.2.0
6
+
7
+ - **Joker correctness** — La Sentinelle and Le Dernier Mot both used to key on `Seat.SOUTH` instead of the NS team, so the joker silently no-op'd when North (the AI partner) held the trump Jack or won the last trick. Both now correctly fire on team membership.
8
+ - **Score floor** — L'Égoïste's `add_chips = -event.card_points` per partner-won trick could drive the running total negative and produce a negative final round score; `ScoreAccumulator.get_total` now clamps at 0 so the intermediate log can still show the deduction without the visible score going below zero.
9
+ - **Auto-coinche event parity** — The NS-taker `auto_coinche` boss path now re-emits `BidMadeEvent` with the new `coinche_level`, matching the EW-taker branch. Pre-3.2 jokers and HUD subscribed to `on_bid` silently missed this code path.
10
+ - **Determinism** — The shop and the three RNG-using tarots (`LeJugement`, `LaPretresse`, `LeFou`) now all draw from the run's seeded `_get_rng()` instead of the module-level `random`. Ghost-run replays are now reproducible across shop generations. `LaPretresse` additionally `sample`s instead of `choice`-ing twice, so the two planets it grants are always distinct.
11
+ - **Registry / boss-field hygiene** — `register_joker/planet/tarot/voucher` now assert against duplicate IDs (typo'd registrations used to silently overwrite the original). `boss_fields` in `modifier_patch.py` is now derived from `BossModifiers`' dataclass fields, so adding a new flag no longer requires updating an out-of-band allowlist.
12
+ - **UI fix** — `patch_trick_card` now re-applies `render`'s vertical-centering offset; on tall terminals (>40 rows) it used to draw single-card patches too high.
13
+ - **Audit reconciliation** — This release consolidates the verified findings from two independent LLM code audits (Qwen 3.6 27B + Ring 1T). Both audits had load-bearing false positives — Qwen's two P0 "dead voucher / dead boss flag" claims were both wrong (the flags are consumed); Ring's "critical IllegalMoveError" only fires under test mocks. Eleven rejected claims are catalogued in the changelog so they aren't re-investigated.
14
+ - **Test coverage** — 528 tests (up from 525). Strict gates still clean: pytest 528/528, mypy 0 errors, ruff 0 violations.
15
+
16
+ ## What's new in 3.1.0
17
+
18
+ - **Bug fixes** — HUD running-total no longer drifts under multi-boss combos (`Les Clubs Bannis + Le Roi Mort` style: pre-3.1.0 the rank-zero recompute silently overwrote the `ban_clubs` zeroing). `Shop.buy_item` no longer charges money when consumable slots are full — the "Slots full — sell first" banner now fires before any spend.
19
+ - **TierceForge wired up** — the voucher shipped in 3.0.0 with a working backend but no UI caller; the feature was unreachable. The shop now shows a "Forge ×N/3" tile when the voucher is owned, opens a numbered planet picker on Enter, and confirms the level-up via a banner.
20
+ - **Performance** — `score_round` and `apply_round_score` no longer re-walk the trick list (~16 fewer `trick_winner_seat` calls per round). The per-event `copy.deepcopy` in `ScoreAccumulator.update_state` is gone (~20 deepcopies/round saved); replaced with a shallow `dict(...)` plus a scalar-only invariant test that locks the contract. Hard-AI's `_score_card_play` precomputes hand suit counts and trump tallies once per turn instead of per candidate.
21
+ - **Cleanup** — the `modifier_patch` underscore-prefix shim is gone (23 boss `apply()` methods rewritten to use unprefixed field names; the `getattr(state, "_X", False)` anti-pattern is now locked against by a regression test). `slots=True` added to `Statistics`, `SessionStats`, `ScoreAccumulator`. Bare `except Exception:` in key-press parsing narrowed; `print → logging` in stats.
22
+ - **Test coverage** — 525 tests (up from 510). Strict gates still clean: pytest 525/525, mypy 0 errors, ruff 0 violations.
23
+
24
+ ## What's new in 3.0.3
25
+
26
+ - **Full-codebase audit** — three-agent pass over the classic engine, BelAtro content wiring, and perf / code-quality hotspots (~7,100 LOC). Headline: engine is rule-correct against canonical French Belote; BelAtro content matrix is **93/93 wired** (21 bosses, 8 planets, 36 jokers, 4 editions, 12 vouchers, 12 tarots). Prioritized findings list (1 P0 functional, 2 P0 perf, 5 P1, 7 P2) tracked for follow-up cuts; implementation landed in 3.1.0.
27
+ - **Doc accuracy** — README boss-count corrected (18 → 21; 3.0.0 added Le Sauvage / L'Iconoclaste / Le Mime), and two stale `(435 tests)` references bumped to 510 to match the figure already present elsewhere in the file.
28
+
5
29
  ## What's new in 3.0.2
6
30
 
7
31
  - **Replay analyzer + Ghost run wired up** — both shipped in 3.0.0 as code modules but were never called from the running game. Now opt-in behind `BELOTE_REPLAY=1` (post-round Hard-AI comparison) and `BELOTE_GHOST=1` (per-run JSON dump to `~/.local/share/belote/ghosts/`). See DEVELOPMENT.md › Optional Runtime Flags.
8
32
  - **Performance** — `score_round()` now caches per-trick winners once instead of recomputing them in each boss-modifier helper (2-3× walks → 1× walk per round). `register_all_items()` is now idempotent so test setup no longer re-walks every items module per `BelAtroRun`. Bidding's special-bid path (TA / SA) hoists `_suit_lengths` out of the per-difficulty branches.
9
33
  - **Defensive pin** — every entry in `ALL_BOSS_MODIFIERS` is now asserted to actually toggle a `BossModifiers` field via `.flags()`. Catches typo'd `state.patch("_misspelled", True)` keys at test time rather than letting the boss silently no-op.
10
- - **Test coverage** — 510 tests (up from 509).
34
+ - **Test coverage** — 525 tests (up from 509).
11
35
 
12
36
  ## What's new in 3.0.1
13
37
 
@@ -179,7 +203,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
179
203
 
180
204
  - **BelAtro Roguelite Mode:** A massive expansion featuring 36 Jokers, 12 Tarot cards, 8 Planets, 12 Vouchers, and permanent upgrades.
181
205
  - **Collection (Almanac):** Persistent tracker to browse every Joker, Planet, and Voucher you've discovered across your runs.
182
- - **Full Boss Blind Suite:** All 18 unique bosses implemented, including complex mechanics like *L'Anarchie* (dynamic trump) and *La Rupture* (no consecutive wins).
206
+ - **Full Boss Blind Suite:** All 21 unique bosses implemented, including complex mechanics like *L'Anarchie* (dynamic trump) and *La Rupture* (no consecutive wins).
183
207
  - **Multiplier Scoring:** Use items to stack Multipliers and reach scores in the millions.
184
208
  - **Partner Trust:** Build a relationship with your AI partner to unlock synergies.
185
209
  - **Rich Terminal UI:** Full-screen green felt table with detailed card graphics and "You" vs "Partner" terminology.
@@ -231,7 +255,7 @@ belote/
231
255
  │ ├── input.py # Platform-dispatched key reader and interruptible sleep
232
256
  │ ├── stats.py # Global and session statistics tracking
233
257
  │ └── rules.py # Game rules content
234
- ├── tests/ # Comprehensive test suite (435 tests)
258
+ ├── tests/ # Comprehensive test suite (528 tests)
235
259
  ├── scripts/ # Performance benchmarks
236
260
  ├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
237
261
  ├── LICENSE # MIT License
@@ -247,14 +271,14 @@ belote/
247
271
  PYTHONPATH=src pytest
248
272
  ```
249
273
 
250
- Currently **510 tests** passing with 100% coverage on game-logic modules.
274
+ Currently **528 tests** passing with 100% coverage on game-logic modules.
251
275
 
252
276
  ## Technical Integrity
253
277
 
254
278
  The codebase is strictly validated with the following tools:
255
279
  - **mypy**: 0 errors (strict type safety)
256
280
  - **ruff**: 0 violations (linting & formatting)
257
- - **pytest**: 435/435 passed
281
+ - **pytest**: 528/528 passed
258
282
  - **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
259
283
  - **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
260
284
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "belote-cli"
7
- version = "3.0.2"
7
+ version = "3.2.0"
8
8
  description = "A 4-player terminal card game"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
- __version__ = "3.0.2"
1
+ __version__ = "3.2.0"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -490,12 +490,31 @@ class AIPlayer:
490
490
  )
491
491
  partner_winning = current_winner is not None and current_winner == p
492
492
 
493
+ # Precompute per-call counters used by every scoring branch — pre-3.1.0
494
+ # these were recomputed per candidate card (n×4 walks of the hand and
495
+ # memory.played for each legal card).
496
+ from collections import Counter
497
+
498
+ my_hand = state.hand_of(self.seat)
499
+ hand_suit_counts: dict[Suit, int] = Counter(c.suit for c in my_hand)
500
+ my_trumps = hand_suit_counts.get(trump, 0)
501
+ opp_trumps = 8 - sum(1 for c in self.memory.played if c.suit == trump)
502
+
493
503
  # Score each legal card by expected outcome
494
504
  best_card = legal[0]
495
505
  best_score: float = -999.0
496
506
 
497
507
  for card in legal:
498
- score = self._score_card_play(card, state, trump, trick, partner_winning)
508
+ score = self._score_card_play(
509
+ card,
510
+ state,
511
+ trump,
512
+ trick,
513
+ partner_winning,
514
+ hand_suit_counts,
515
+ my_trumps,
516
+ opp_trumps,
517
+ )
499
518
  if score > best_score:
500
519
  best_score = score
501
520
  best_card = card
@@ -509,6 +528,9 @@ class AIPlayer:
509
528
  trump: Suit,
510
529
  trick: tuple[TrickCard, ...],
511
530
  partner_winning: bool,
531
+ hand_suit_counts: dict[Suit, int],
532
+ my_trumps: int,
533
+ opp_trumps: int,
512
534
  ) -> float:
513
535
  """Score a card play decision with advanced heuristics."""
514
536
  score = 0.0
@@ -517,20 +539,20 @@ class AIPlayer:
517
539
  score += points * 0.1
518
540
 
519
541
  if not trick:
520
- return self._score_leading_strategy(card, state, trump)
542
+ return self._score_leading_strategy(card, trump, my_trumps, opp_trumps)
521
543
 
522
544
  if partner_winning and trick[0].card.suit != trump:
523
- return self._score_discarding_strategy(card, state, trump, points)
545
+ return self._score_discarding_strategy(card, trump, points, hand_suit_counts)
524
546
 
525
547
  return self._score_winning_strategy(card, state, trump, trick, partner_winning, points)
526
548
 
527
- def _score_leading_strategy(self, card: Card, state: GameState, trump: Suit) -> float:
549
+ def _score_leading_strategy(
550
+ self, card: Card, trump: Suit, my_trumps: int, opp_trumps: int
551
+ ) -> float:
528
552
  """Heuristics for when we are leading the trick."""
529
553
  score = 0.0
530
554
  if card.suit == trump:
531
555
  # Leading trump is good for pulling if opponents still have them
532
- opp_trumps = 8 - sum(1 for c in self.memory.played if c.suit == trump)
533
- my_trumps = sum(1 for c in state.hand_of(self.seat) if c.suit == trump)
534
556
  if opp_trumps > my_trumps:
535
557
  score += 4
536
558
  else:
@@ -543,7 +565,7 @@ class AIPlayer:
543
565
  return score
544
566
 
545
567
  def _score_discarding_strategy(
546
- self, card: Card, state: GameState, trump: Suit, points: int
568
+ self, card: Card, trump: Suit, points: int, hand_suit_counts: dict[Suit, int]
547
569
  ) -> float:
548
570
  """Heuristics for when partner is winning and we can discard."""
549
571
  score = 0.0
@@ -551,9 +573,7 @@ class AIPlayer:
551
573
  score -= points * 0.7 # Penalize throwing away points
552
574
 
553
575
  # Prefer discarding from short suits (to establish voids)
554
- my_hand = state.hand_of(self.seat)
555
- suit_count = sum(1 for c in my_hand if c.suit == card.suit)
556
- if suit_count == 1:
576
+ if hand_suit_counts.get(card.suit, 0) == 1:
557
577
  score += 3
558
578
 
559
579
  # Prefer keeping cards that partner is void in (to trump later)
@@ -70,6 +70,12 @@ class BelAtroRun:
70
70
  # ── Last consumable used (read by LeFou tarot) ─────────
71
71
  last_consumable_id: str | None = None
72
72
 
73
+ # ── Last tarot status message ──────────────────────────
74
+ # Set by tarots that need to surface a non-fatal failure (e.g. LeJugement
75
+ # rolling a joker when joker_slots are full). The UI may read and display
76
+ # this; tests assert it. Cleared whenever a new tarot is used.
77
+ last_tarot_message: str | None = None
78
+
73
79
  # ── Determinism ────────────────────────────────────────
74
80
  seed: int | None = None
75
81
  _rng: Any = None
@@ -178,10 +184,15 @@ class BelAtroRun:
178
184
  self.endless_ante_offset += 1
179
185
  self.blind_index = 0
180
186
  return
181
- # Standard run completion.
187
+ # Standard run completion. Set both flags so the terminal-state
188
+ # invariant (run_over ⇔ run is over, run_won ⇔ run is over AND we won)
189
+ # is consistent; main.py's loop break still handles the exit but
190
+ # downstream callers can now rely on run_over alone.
182
191
  self.run_won = True
192
+ self.run_over = True
183
193
 
184
194
  def enter_endless(self) -> None:
185
195
  """Toggle endless mode after beating ante 8."""
186
196
  self.endless = True
187
197
  self.run_won = False # endless overrides run-won state
198
+ self.run_over = False # ...and re-opens the run so the main loop continues
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from dataclasses import dataclass, field
3
+ from dataclasses import dataclass, field, replace
4
4
  from typing import TYPE_CHECKING, Any
5
5
 
6
6
  if TYPE_CHECKING:
@@ -28,7 +28,7 @@ _SUIT_TO_CONTRACT: dict[Suit, str] = {
28
28
  }
29
29
 
30
30
 
31
- @dataclass
31
+ @dataclass(slots=True)
32
32
  class ScoreAccumulator:
33
33
  """
34
34
  Accumulates Chips and Mult across one round,
@@ -65,7 +65,6 @@ class ScoreAccumulator:
65
65
  new_chips = state._chips + self.permanent_chips
66
66
  new_mult = state._mult * self.permanent_mult if self.permanent_mult != 1.0 else state._mult
67
67
 
68
- from dataclasses import replace
69
68
  return replace(state, _joker_state=joker_state, _chips=new_chips, _mult=new_mult)
70
69
 
71
70
  def update_state(self, state: GameState, event: object) -> GameState:
@@ -73,12 +72,11 @@ class ScoreAccumulator:
73
72
  new_chips = state._chips
74
73
  new_mult = state._mult
75
74
  new_money = state._bonus_money
76
- # Deep-copy the joker state: a shallow dict() shares mutable values
77
- # (lists/dicts/sets nested inside) across rounds, which has bitten us
78
- # before with frozenset/list flags persisting after the round ended.
79
- import copy
80
-
81
- joker_state = copy.deepcopy(state._joker_state)
75
+ # Shallow copy is sufficient: every value written into _joker_state
76
+ # is a scalar (bool / int / str). The pre-3.1.0 deepcopy ran on every
77
+ # event (~20×/round) see test_joker_state_only_contains_scalar_values
78
+ # in tests/belatro/test_phase1_plumbing.py for the locking invariant.
79
+ joker_state = dict(state._joker_state)
82
80
 
83
81
  def _apply(result: JokerResult, source: str) -> None:
84
82
  nonlocal new_chips, new_mult, new_money
@@ -235,7 +233,6 @@ class ScoreAccumulator:
235
233
  _fire_jokers("on_bid", event)
236
234
 
237
235
  # Update GameState with new values
238
- from dataclasses import replace
239
236
  return replace(
240
237
  state,
241
238
  _chips=new_chips,
@@ -245,8 +242,11 @@ class ScoreAccumulator:
245
242
  )
246
243
 
247
244
  def get_total(self, state: GameState) -> int:
248
- # Avoid float precision issues for large integers if mult is effectively an int
249
- chips: int = state._chips
245
+ # Clamp at 0: corrupted jokers (L'Égoïste in particular) can subtract
246
+ # chips per trick won by partner, and with enough partner tricks the
247
+ # running total can go negative, producing a negative final score.
248
+ # Final score should never be negative — clamp at the scoring boundary.
249
+ chips: int = max(0, state._chips)
250
250
  mult: float = state._mult
251
251
  if mult == float(int(mult)):
252
252
  return chips * int(mult)
@@ -1,8 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import dataclasses
3
4
  from typing import Any
4
5
 
5
- from belote.game import GameState
6
+ from belote.game import BossModifiers, GameState
7
+
8
+ # Derived from BossModifiers so new flags added there are picked up
9
+ # automatically; previously this was a hardcoded set that silently no-op'd
10
+ # any new boss field not added here in lockstep.
11
+ _BOSS_FIELDS: frozenset[str] = frozenset(f.name for f in dataclasses.fields(BossModifiers))
6
12
 
7
13
 
8
14
  class PatchedGameState:
@@ -18,22 +24,21 @@ class PatchedGameState:
18
24
  # ── Patch registration ──────────────────────────────────────────────
19
25
 
20
26
  def patch(self, attr: str, value: Any) -> None:
21
- """Override a specific attribute for this round."""
22
- if attr.startswith("_"):
23
- # Strip leading underscore if it was from the old system
24
- attr = attr[1:]
27
+ """Override a specific attribute for this round.
25
28
 
26
- # We'll treat all these flat patches as boss_modifiers fields
27
- boss_fields = {
28
- "no_belote", "dynamic_trump", "no_consecutive_team_wins", "seven_eight_trump",
29
- "invert_scoring", "kings_zero", "auto_coinche", "queen_spades_penalty",
30
- "hide_hud", "ban_clubs", "no_dix_de_der", "tens_zero", "hide_partner_hand",
31
- "agent_double_active", "agent_double_late_only", "partner_forced_pass",
32
- "lock_trust_zero", "separate_scoring",
33
- "aces_zero", "jacks_zero", "declarations_zero",
34
- }
29
+ Boss field names are unprefixed (e.g. "no_belote", not "_no_belote").
30
+ The 3.0.x backward-compat shim that stripped a leading underscore was
31
+ removed in 3.1.0 — call sites in `run/boss.py` were rewritten in lock-
32
+ step. The `getattr(state, "_X", False)` reading anti-pattern is locked
33
+ against in tests/belatro/test_boss_modifiers_integration.py
34
+ `test_invariant_no_underscore_boss_attrs`.
35
+ """
36
+ assert not attr.startswith("_"), (
37
+ f"patch() received leading-underscore attr {attr!r}; the 3.0.x shim "
38
+ "was removed in 3.1.0 — use the unprefixed boss field name."
39
+ )
35
40
 
36
- if attr in boss_fields:
41
+ if attr in _BOSS_FIELDS:
37
42
  current_bm = self.boss_modifiers
38
43
  from belote.game import replace
39
44
  new_bm = replace(current_bm, **{attr: value})
@@ -47,13 +52,6 @@ class PatchedGameState:
47
52
  patches = object.__getattribute__(self, "_patches")
48
53
  if name in patches:
49
54
  return patches[name]
50
-
51
- # Backward compatibility for old underscored names
52
- if name.startswith("_"):
53
- stripped = name[1:]
54
- if hasattr(self.boss_modifiers, stripped):
55
- return getattr(self.boss_modifiers, stripped)
56
-
57
55
  return getattr(object.__getattribute__(self, "_state"), name)
58
56
 
59
57
  def __setattr__(self, name: str, value: Any) -> None: