belote-cli 3.4.0__tar.gz → 3.5.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 (126) hide show
  1. {belote_cli-3.4.0 → belote_cli-3.5.0}/CHANGELOG.md +127 -0
  2. {belote_cli-3.4.0 → belote_cli-3.5.0}/DEVELOPMENT.md +7 -4
  3. {belote_cli-3.4.0 → belote_cli-3.5.0}/PKG-INFO +35 -4
  4. {belote_cli-3.4.0 → belote_cli-3.5.0}/README.md +34 -3
  5. {belote_cli-3.4.0 → belote_cli-3.5.0}/pyproject.toml +1 -1
  6. {belote_cli-3.4.0 → belote_cli-3.5.0}/scripts/benchmark.py +90 -5
  7. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/__init__.py +1 -1
  8. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/a11y.py +5 -0
  9. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/ai.py +20 -4
  10. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/core/run_state.py +20 -4
  11. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/core/scoring.py +29 -6
  12. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/engine/event_bus.py +32 -3
  13. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/annonces.py +1 -3
  14. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/coinche.py +2 -2
  15. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/contract.py +7 -7
  16. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/trick_timing.py +5 -5
  17. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/main.py +3 -1
  18. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/partner/partner_state.py +12 -1
  19. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/progression/save.py +10 -1
  20. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/progression/unlocks.py +2 -2
  21. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/run/ante_themes.py +12 -7
  22. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/run_summary.py +5 -0
  23. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/ui/announce.py +2 -2
  24. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/ui/collection.py +1 -1
  25. belote_cli-3.5.0/src/belote/belatro/ui/consumables.py +89 -0
  26. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/ui/menu.py +2 -2
  27. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/ui/rules.py +1 -1
  28. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/ui/shop.py +17 -5
  29. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/game.py +8 -5
  30. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/gameflow.py +10 -5
  31. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/input.py +9 -1
  32. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/main.py +2 -2
  33. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/scoring.py +79 -18
  34. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/ui/menu.py +2 -2
  35. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/ui/prompts.py +7 -3
  36. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/ui/render.py +7 -5
  37. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/belatro/test_belatro.py +47 -7
  38. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/belatro/test_boss_modifiers_integration.py +60 -0
  39. belote_cli-3.5.0/tests/belatro/test_collection_logic.py +77 -0
  40. belote_cli-3.5.0/tests/belatro/test_consumables_ui.py +125 -0
  41. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/belatro/test_dead_flag_fixes.py +28 -0
  42. belote_cli-3.5.0/tests/belatro/test_event_bus.py +65 -0
  43. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/belatro/test_partner_trust.py +69 -0
  44. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/belatro/test_phase0_coverage.py +100 -0
  45. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/belatro/test_phase3_meta.py +13 -3
  46. belote_cli-3.5.0/tests/belatro/test_run_summary.py +65 -0
  47. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/test_ai.py +83 -0
  48. belote_cli-3.5.0/tests/test_declaration_tiebreak.py +116 -0
  49. belote_cli-3.5.0/tests/test_input_eof.py +104 -0
  50. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/test_new_coverage.py +49 -0
  51. belote_cli-3.4.0/tests/belatro/test_collection_logic.py +0 -36
  52. {belote_cli-3.4.0 → belote_cli-3.5.0}/.claude/settings.local.json +0 -0
  53. {belote_cli-3.4.0 → belote_cli-3.5.0}/.gitignore +0 -0
  54. {belote_cli-3.4.0 → belote_cli-3.5.0}/.python-version +0 -0
  55. {belote_cli-3.4.0 → belote_cli-3.5.0}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
  56. {belote_cli-3.4.0 → belote_cli-3.5.0}/LICENSE +0 -0
  57. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/__init__.py +0 -0
  58. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/achievements.py +0 -0
  59. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/ansi.py +0 -0
  60. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/__init__.py +0 -0
  61. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/core/__init__.py +0 -0
  62. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/core/economy.py +0 -0
  63. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/engine/__init__.py +0 -0
  64. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/engine/modifier_patch.py +0 -0
  65. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/engine/round_driver.py +0 -0
  66. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/ghost_run.py +0 -0
  67. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/__init__.py +0 -0
  68. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/base.py +0 -0
  69. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/__init__.py +0 -0
  70. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  71. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/economy.py +0 -0
  72. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  73. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  74. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  75. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  76. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  77. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/planets.py +0 -0
  78. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/registry.py +0 -0
  79. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/tarots.py +0 -0
  80. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/items/vouchers.py +0 -0
  81. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/partner/__init__.py +0 -0
  82. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/partner/personality.py +0 -0
  83. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/partner/trust.py +0 -0
  84. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/progression/__init__.py +0 -0
  85. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/run/__init__.py +0 -0
  86. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/run/ante.py +0 -0
  87. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/run/boss.py +0 -0
  88. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/run/decks.py +0 -0
  89. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/run/shop.py +0 -0
  90. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/ui/__init__.py +0 -0
  91. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/ui/history.py +0 -0
  92. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/ui/hud.py +0 -0
  93. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/belatro/ui/trust_bar.py +0 -0
  94. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/config.py +0 -0
  95. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/context.py +0 -0
  96. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/deck.py +0 -0
  97. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/replay.py +0 -0
  98. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/rules.py +0 -0
  99. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/stats.py +0 -0
  100. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/themes.py +0 -0
  101. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/ui/__init__.py +0 -0
  102. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/ui/announce.py +0 -0
  103. {belote_cli-3.4.0 → belote_cli-3.5.0}/src/belote/ui/layout.py +0 -0
  104. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/__init__.py +0 -0
  105. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/belatro/__init__.py +0 -0
  106. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/belatro/test_contract_unlocks.py +0 -0
  107. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/belatro/test_deck_variants.py +0 -0
  108. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/belatro/test_ghost_run.py +0 -0
  109. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/belatro/test_history_overlay.py +0 -0
  110. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/belatro/test_hud_synergy.py +0 -0
  111. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/belatro/test_phase1_plumbing.py +0 -0
  112. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/belatro/test_phase2_content.py +0 -0
  113. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/belatro/test_progression.py +0 -0
  114. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/belatro/test_round_driver.py +0 -0
  115. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/test_a11y.py +0 -0
  116. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/test_achievements.py +0 -0
  117. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/test_ansi_helpers.py +0 -0
  118. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/test_belote.py +0 -0
  119. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/test_extended.py +0 -0
  120. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/test_game_logic.py +0 -0
  121. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/test_gameflow.py +0 -0
  122. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/test_layout.py +0 -0
  123. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/test_official_rules.py +0 -0
  124. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/test_properties.py +0 -0
  125. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/test_replay.py +0 -0
  126. {belote_cli-3.4.0 → belote_cli-3.5.0}/tests/test_undo.py +0 -0
@@ -5,6 +5,133 @@ 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.5.0] - 2026-05-12
9
+
10
+ Comprehensive bug-hunt, game-mechanic audit, and performance pass over both the classic Belote engine and the BelAtro roguelite layer. A three-Explore-agent audit produced ~30 candidate findings; verification against the source rejected several as false positives (documented below) and confirmed **15 actionable items** plus **1 latent bug surfaced during implementation**. **24 new regression tests** land here (592 total, up from 568). Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-tidy-meerkat.md`.
11
+
12
+ ### Fixed
13
+
14
+ - **`src/belote/belatro/core/run_state.py:90-117` + new `src/belote/belatro/ui/consumables.py` (C1) — BelAtro consumables can now be activated.** Pre-3.5.0 `BelAtroRun.consume()` was defined but never called from any UI: every Tarot bought from the shop and every directly-purchased Planet accumulated in `run.consumables` with no way to use them. Only the voucher-gated Forge-Tierce path could level a planet. New `ConsumablesOverlay` is reachable from the shop via the `C` key, listing the tray and dispatching to `run.consume(item, context=run)` on a digit press. Hint line in the shop now shows `C: Consumables (N)`. **Also fixes a latent Le Fou bug**: `consume()` was advancing `last_consumable_id` to the *current* item BEFORE calling `item.use()`, so Le Fou's `use()` read its own id and fell through to the fallback path. Reordered to call `item.use()` first; Le Fou is special-cased as transparent so a second Le Fou keeps copying the same source rather than itself. 7 regression tests in `tests/belatro/test_consumables_ui.py`.
15
+ - **`src/belote/belatro/run_summary.py:67-74` (H1) — JSONL appends are now durable.** Added `f.flush() + os.fsync(f.fileno())` inside the `with` block, mirroring the atomic-save pattern in `progression/save.py:81-82`. A crash or power-loss mid-write no longer leaves a truncated final line that breaks downstream `jq` processing. 3 regression tests in `tests/belatro/test_run_summary.py`.
16
+ - **`src/belote/input.py:31-37,74` + ~20 consumer sites (H2) — EOF on stdin is distinct from ESC.** `KeyReader.read()` returned `KeyEvent(Key.ESC)` when `os.read()` returned empty bytes. A closed stdin (broken pipe, headless harness, Ctrl-D) made every prompt loop spin: ESC popped one menu level, the loop fell through and re-read stdin, got another "ESC", popped again — burning CPU until the outermost loop happened to exit. New `Key.EOF` enum value is returned on empty-read. Every `Key.ESC` consumer was updated to also accept `Key.EOF` (semantically equivalent: "back / cancel"); `prompt_card` and `prompt_bid` exit cleanly on EOF instead of spinning. 5 regression tests in `tests/test_input_eof.py`.
17
+ - **`src/belote/belatro/engine/event_bus.py` (H3) — `EventBus` round-scope invariant documented + `clear()` added.** The bus is created fresh per round in `round_driver.drive_round` and subscribers are released with it; no explicit unsubscribe was needed. The invariant was silent — if anyone moved the bus to a longer scope, every subscription would double-fire on round 2. Module docstring now spells out the round-scope contract; a new `clear()` method exists for the future where a longer-lived bus might be desirable. 3 regression tests in `tests/belatro/test_event_bus.py`.
18
+ - **`src/belote/scoring.py:228-330` (M1) — tied carrés / sequences go to the first announcer.** Standard Belote-Coinché awards a tied declaration to the team whose seat declared first (announcement order: taker → clockwise). Pre-3.5.0 the resolver returned `scoring_team=None` (cancel), which was defensive but non-standard. `resolve_declarations` gains an optional `taker: Seat | None = None` parameter; when supplied, tied carrés/sequences are awarded by walking the announce order. Legacy "cancel" behaviour preserved when `taker` is not provided. Both call sites updated to pass `state.taker`. 6 regression tests in `tests/test_declaration_tiebreak.py`.
19
+ - **`src/belote/belatro/engine/event_bus.py` + ~10 consumer sites (M3) — `RoundEndEvent.breakdown` is properly typed.** Pre-3.5.0 the field was `breakdown: Any` and every consumer wrote `getattr(event.breakdown, "is_failed", False)` — defensive noise that hid field-rename regressions until runtime. Now typed as `ScoringBreakdown` (TYPE_CHECKING forward-ref to avoid import cycle); all 9 `getattr` patterns replaced with direct attribute access. `taker_seat` correctly annotated `Seat | None` (was `Seat`, but the all-pass emitter at `round_driver.py:298` actually passes None).
20
+ - **`src/belote/scoring.py:750-763` (M5) — SA belote invariant pinned at contract level.** The `assert taker_belote == 0 and defender_belote == 0` for Sans Atout rounds was only inside the capot branch — a non-capot SA round with stray belote points would silently mis-score instead of surfacing the bug. Hoisted to a contract-level post-condition that covers both capot and non-capot paths.
21
+
22
+ ### Changed
23
+
24
+ - **`src/belote/belatro/core/scoring.py:125-142` (M4) — `partner_jokers_double` legacy flag is now deprecated.** When both the tier scaling and the legacy boolean flag are set, a one-shot `DeprecationWarning` fires. Behaviour unchanged (`max()` of the two still wins); the flag is slated for removal in 4.0.
25
+ - **`src/belote/ui/render.py:988-997` (L1) — `patch_trick_card` batches its writes.** Pre-3.5.0 each card-face line + the HUD update were separate `sys.stdout.write` calls; signal-interruptible terminals could paint half the card before the HUD landed. Now one `write()` per repaint, mirroring the pattern at `render.py:923,933`.
26
+ - **`src/belote/a11y.py:1-20` (L2) — module docstring spells out the env-var invariant.** `BELOTE_A11Y` is read once at import; toggling mid-session has no effect on production code. Tests use `_refresh_enabled_from_env()`. No behaviour change, just documentation.
27
+ - **`src/belote/belatro/core/run_state.py:30-41` (L3) — `consumables` / `jokers` / `vouchers` mutation contract documented.** These lists are intentionally mutable; any future replay / ghost-run snapshot path must deep-copy at the snapshot boundary. No behaviour change.
28
+
29
+ ### Performance
30
+
31
+ - **`scripts/benchmark.py` (M2) — three new micro-benchmarks for real hot paths.** `benchmark_legal_cards_cached` (warm-cache path; production gameplay reuses across 8 tricks), `benchmark_trick_scoring` (`trick_card_points`, called 16× per round), `benchmark_ai_legality_filter` (the legal-move filter step inside `AIPlayer.decide_card`). The pre-existing `benchmark_legal_cards` was clarified as the cache-cleared cold path.
32
+ - **`src/belote/scoring.py:439-477` (P1) — `trick_card_points` hoists boss-flag reads.** Same pattern `_calculate_base_points` (lines 489-493) uses. Saves 4 dataclass-attr lookups per card per trick. Sub-microsecond gain; the function was already micro-optimized at ~2μs per call. **Memoization rejected**: at 2μs × 16 calls = 32μs per round, the cache-key overhead would exceed the gain.
33
+ - **`src/belote/game.py:490-512` (P2) — `legal_cards` cache-key analysis documented.** Benchmark shows cold ~9μs / warm ~6μs; the 33% gap is dominated by key-build cost in `legal_cards()`, not lru_cache lookup. The key already uses small-int IDs (not Card objects) which is the minimal hashable surface. Slimming further would require caching `hand_ids` on the hand tuple itself, which isn't reachable without changing the hand representation. **Documented "no actionable optimization without larger refactor"** in the cache-impl docstring so a future audit doesn't re-investigate the same dead end.
34
+ - **`src/belote/belatro/core/scoring.py:70-89` (P3) — `ScoreAccumulator.update_state` profiling note.** cProfile of 10k calls shows `dataclasses.replace` is 65% of the cost — frozen-GameState invariant is load-bearing, so the replace cost stays. At ~19μs per event × ~25 events per round = ~0.5ms per round, the accumulator is well under the budget.
35
+
36
+ ### Verified clean — agent claims that did NOT survive source verification
37
+
38
+ Catalogued so they aren't re-investigated next cycle.
39
+
40
+ - **"Belote/Rebelote not announced when partner holds K+Q"** (`game.py:863-876`) — The condition `state.belote_holders.get(trump) == state.turn` fires when the holder *plays* the K/Q, which is exactly when the announcement should fire. `state.turn` equals the holder at the moment of play regardless of partnership. **Correct as-is.**
41
+ - **"Capot false-positive under La Rupture on the 8th-trick announcement"** (`gameflow.py:215`) — `is_capot()` already routes through `compute_trick_winners()`, which honours Rupture for both the live-announce path and the final scoring path. The docstring at `scoring.py:317-326` explicitly calls this out. **Correct as-is.**
42
+ - **"AI `partner_hand` not cleared on undo path"** (`ai.py:79-92`) — `update_memory()` always clears `partner_hand` at line 104 and re-fills it from the current state, regardless of which earlier branch ran (new-round / undo / normal). **Correct as-is.**
43
+ - **"Negative-edition slot rollback on purchase failure"** (`run/shop.py:166-168`) — `_can_accept()` returns True unconditionally for Negative jokers, and `_apply_item` runs only after `spend_money()` succeeded. No failure path exists. **Correct as-is.**
44
+ - **"`boss.id == \"…\"` string-branching in pre-round setup"** — `grep -rn 'boss\.id\s*==' src/belote/belatro/` returns zero results. Cleaned up in the May 2026 audit. **Correct as-is.**
45
+
46
+ ### Internal
47
+
48
+ - **Tests**: 568 → 592 (+24). Six new test files: `tests/belatro/test_consumables_ui.py` (7), `tests/belatro/test_run_summary.py` (3), `tests/test_input_eof.py` (5), `tests/belatro/test_event_bus.py` (3), `tests/test_declaration_tiebreak.py` (6). 0 existing tests modified.
49
+ - **Strict gates**: pytest 592/592 green.
50
+ - **Version markers bumped**: `pyproject.toml`, `src/belote/__init__.py`.
51
+ - **Docs bumped**: `CHANGELOG.md` (this entry), `README.md` "What's new in 3.5.0".
52
+
53
+ ## [3.4.2] - 2026-05-11
54
+
55
+ Implements the deferred bug roadmap from 3.4.1's verification pass. **9 fixes land here** — 3 Critical (C1/C3/C4), 4 High (H1/H4/H5/H7), 1 architectural cleanup (H10), 1 dead-code deletion (M4). Adds 17 regression tests (551 → 568). The 3.4.1 entry catalogued these against the source; this entry implements them. Plan file at `/home/mrrobot/.claude/plans/wtf-these-were-verified-shiny-flute.md`.
56
+
57
+ ### Fixed
58
+
59
+ - **`src/belote/ai.py:104-108` (C1) — AI no longer sees partner's hand under `hide_partner_hand`.** `AIMemory.update_memory()` now gates the `partner_hand` population on `not state.boss_modifiers.hide_partner_hand`. Under Le Fantôme Partenaire the human was blinded but the AI continued to play with perfect partner information — the boss's visibility cost was paid by one team only. The fix removes the perfect-information cheat; the AI still infers partner cards via `known_voids` and `played` like any real player. Regression test in `tests/belatro/test_dead_flag_fixes.py::test_ai_memory_respects_hide_partner_hand`.
60
+ - **`src/belote/gameflow.py:200-203` (C3) — Dix de Der announcement now uses the Rupture-aware winner.** Pre-3.4.2 the 8th-trick "Dix de Der (Team X)" line called raw `trick_winner_seat()` on `display_state.current_trick`, which ignores `boss_modifiers.no_consecutive_team_wins`. Under La Rupture the announcement could name a team that was *not* credited the +10 in scoring. Fix swaps to `compute_trick_winners(state, trump, is_sa, tricks=projected)[-1]` (same helper `scoring.py` uses), feeding `projected = completed_tricks + [display_state.current_trick]` since the 8th trick isn't yet pushed to `completed_tricks` when the announcement fires. Removes the now-unused `trick_winner_seat` import. Regression test in `tests/belatro/test_boss_modifiers_integration.py::test_dix_de_der_announcement_honors_rupture`.
61
+ - **`src/belote/ai.py:540-549` (C4) — `opp_trumps` no longer over-counts; TA total fixed.** The pre-3.4.2 formula `8 - sum(played trumps)` conflated "remaining trump anywhere" with "opponent trump", treating South's own hand and partner's visible cards as still in opponents' hands. The fix subtracts `my_trumps`, `played_trumps`, and `partner_trumps` from the total. Under Tout Atout every card is a trump and the total is `32`, not `8` — pre-3.4.2 the formula degraded to `8 - 0 = 8` always under TA because no card's `.suit` equals `Suit.TOUT_ATOUT`. Both regimes are now handled. Has the side effect of also fixing `my_trumps` under TA (used by `_score_leading_strategy` downstream). Regression tests in `tests/test_ai.py::test_opp_trumps_excludes_own_and_partner_hand` and `::test_opp_trumps_under_tout_atout_uses_32_total`.
62
+ - **`src/belote/belatro/items/jokers/contract.py` + `trick_timing.py` (H1) — 8 jokers now gate on team, not seat.** `LIdeologue`, `LeFanatique`, `LeDiplomate`, `LePatriote`, `LIllusionniste`, `LePremierSang` (both checks), `LeSergent`, `LExecuteur` were checking `event.winner == Seat.SOUTH` instead of `team_of(event.winner) == 0`. They silently no-opped when partner (North) took the relevant trick. Now consistent with the 3.2.0 fix that landed `LaSentinelle` and `LeDernierMot` on the same pattern. Five existing tests that asserted the broken behavior (`test_north_*_returns_none`) were rewritten to verify the new correct behavior — partner wins now fire the joker; opposing-team wins (EAST) are added as the new negative case. `LeSergent`'s "streak reset" semantics also shifted: an opposing-team trick now breaks the streak, not a partner-won one. Regression backstop in `tests/belatro/test_phase0_coverage.py::test_h1_team_aware_jokers_fire_on_north_partner_win`. **`LeRebelle` is intentionally not included** — the 3.4.1 catalogue flagged it as a probable audit hallucination; it is an `on_belote` joker, not on `on_trick_won`, and the seat-vs-team distinction there is a separate spec call deferred to a future cycle.
63
+ - **`src/belote/belatro/run/ante_themes.py` + `belatro/main.py:413-414` (H4) — TournoiAnte pays a real 50% of round payout.** `AnteTheme.on_blind_won` gains a `blind_payout: int` parameter (forwarded to base class and both subclasses). The call site at `belatro/main.py:412` snapshots `run.economy.money` immediately before `process_round_end` and computes `blind_payout = money_after - money_before` after all bonus paths (L'Avocat, `_bonus_money`, Le Puriste, L'Aristocrate) have run. TournoiAnte now does `add_money(max(1, blind_payout // 2))` — actually 50% of payout, with a $1 floor so blind payouts of 0 still pay something. Comment rewritten to match. Updated tests in `tests/belatro/test_phase3_meta.py` (CafeAnte test threads the new arg; TournoiAnte tests verify exact `payout // 2` math + the floor).
64
+ - **`src/belote/belatro/progression/save.py:97-101` (H5) — `load_profile` no longer loses starter unlocks.** Pre-3.4.2 the happy path read `data.get("unlocked_ids", [])`, defaulting to an empty list when the key was absent — wiping the Profile dataclass's `["le_classique", "le_courageux", "l_econome"]` starter unlocks for any save missing the key (legacy saves, manual edits, partial writes). The fix falls back to `Profile().unlocked_ids` when the key is missing; an explicitly empty `unlocked_ids: []` is honored unchanged (a player who has reset their unlocks stays reset). Two regression tests in `tests/belatro/test_collection_logic.py` lock both behaviors.
65
+ - **`src/belote/main.py:230-231` (H7) — stats line agrees with menu summary on ties.** Changed `won=(ns >= target and ns >= ew)` to `won=(ns >= target and ns > ew)` so the `update_stats_game` `won` flag aligns with `ui/menu.py:344`'s `winner = "NS" if ns > ew else "EW"`. On an exact tie at target, pre-3.4.2 the stats recorded a NS win while the visible summary said EW. Both regression tests in `tests/test_new_coverage.py` — one source-grep anti-pattern lock against `>=`, one semantic check on the formula.
66
+
67
+ ### Internal
68
+
69
+ - **`src/belote/belatro/partner/partner_state.py:38-49` (H10, architectural)** — `equip_joker` signature widened to `equip_joker(self, joker: Joker, run: BelAtroRun | None = None) -> bool`. When `run` is provided, `joker.on_purchase(run)` fires after the slot append. No current partner joker defines `on_purchase`, so no behaviour changes today — this is forward-looking infrastructure. The catalogue called this "latent"; equipping through this path will now invoke purchase-time effects consistently with the main joker slot equip path. Note: `equip_joker` has zero callers in the current codebase (the shop path equips through a different surface), so this fix is doubly forward-looking. Three regression tests in `tests/belatro/test_partner_trust.py::TestEquipJokerOnPurchase`.
70
+ - **`src/belote/game.py::advance_turn` (M4) — dead code deleted.** Zero callers across `src/` and `tests/`. The function was a one-line `replace(state, turn=state.turn.next_seat())` helper that no live code used.
71
+ - **Tests**: 551 → 568 (+17). Five existing `test_north_*_returns_none` tests in `test_belatro.py` were flipped to assert the new team-aware behavior (these tests had encoded the H1 bug as a contract — they are not test regressions but contract updates).
72
+ - **Strict gates**: pytest 568/568, mypy 0 errors (76 source files), ruff 0 violations.
73
+ - **Version markers bumped**: `pyproject.toml`, `src/belote/__init__.py`.
74
+ - **Docs bumped**: `CHANGELOG.md` (this entry), `README.md` "What's new in 3.4.2", `DEVELOPMENT.md` baseline.
75
+ - **Still deferred**: **H2** (`LEgoiste` partner-trick nullification) was flagged as needing a spec decision in 3.4.1 and was not in any tier the user picked for 3.4.2 — the comment says "Partner's points are nullified" which reads as intent, so a fix would invert documented behavior. Stays on the deferred list pending a spec call. `LeRebelle` `on_belote` seat/team gating (noted above) is similarly deferred.
76
+
77
+ ## [3.4.1] - 2026-05-11
78
+
79
+ Audit-verification-only release — **no code changes**. A fresh external LLM audit ("Comprehensive Audit Report — Belote CLI v3.4.0") produced 26 prioritized findings (4 Critical, 10 High, 12 Medium) plus a test-coverage section and a performance section. Direct verification against the source confirmed **7 real bugs** (3 Critical, 4 High), **1 architectural latent issue** (no current consumer but fragile for future work), and **1 disputed claim** that needs a spec call before any fix. **8 claims were false positives** under verification (intent inversion, defense-in-depth confused for bugs, or stale call-graph reading). Mediums spot-checked: 1 real (dead code), 4 false. The point of cutting a release for verification-only work is to (a) lock the false-positive catalogue against re-investigation next cycle and (b) record the confirmed-bug roadmap before any fixes land. Plan/verification file at `/home/mrrobot/.claude/plans/check-on-this-audit-polished-kite.md`. Test count, mypy, ruff results unchanged from 3.4.0 (no source code touched).
80
+
81
+ ### Confirmed bugs — deferred to 3.4.2+
82
+
83
+ These were verified against current code and are real. None are fixed in 3.4.1; they are catalogued here so the next session has a vetted target list.
84
+
85
+ **Critical**
86
+ - **`src/belote/ai.py:104-108` (C1) — AI sees partner's full hand regardless of `hide_partner_hand`.** `AIMemory.update_memory()` unconditionally populates `self.memory.partner_hand` from `state.hand_of(partner)`. The boss flag `hide_partner_hand` (declared at `game.py:177`, set by Le Fantôme Partenaire at `belatro/run/boss.py:171`) is only read by display code at `belatro/main.py:291`, never by the AI memory path. Net effect: under Le Fantôme Partenaire the human is blinded to partner's hand but the AI continues to play with perfect partner information.
87
+ - **`src/belote/gameflow.py:196-198` (C3) — Dix de Der announcement uses non-Rupture-aware winner.** The announcement calls `trick_winner_seat()` directly; the La Rupture-aware helper `compute_trick_winners()` (defined at `game.py:756`, used by `scoring.py`) is the one that honours `boss_modifiers.no_consecutive_team_wins`. Under La Rupture the announced "Dix de Der goes to TEAM X" line can name a team that is not actually credited the +10 in scoring.
88
+ - **`src/belote/ai.py:533` (C4) — `opp_trumps` formula conflates "remaining trump" with "opponent trump".** Current line is `opp_trumps = 8 - sum(1 for c in self.memory.played if c.suit == trump)`. This counts trump still anywhere in unrevealed hands — including South's own hand (already computed as `my_trumps` on line 532) and partner's hand (visible at `self.memory.partner_hand`). The variable is then compared against `my_trumps` in `_score_leading_strategy`, so the over-count biases Hard AI's trump-coverage decisions when the AI itself is holding trump.
89
+
90
+ **High**
91
+ - **`src/belote/belatro/items/jokers/contract.py` + `trick_timing.py` (H1, partial) — 8 jokers still gate on `event.winner == Seat.SOUTH`.** 3.2.0 fixed La Sentinelle and Le Dernier Mot to use `team_of(event.winner) == 0`. The same anti-pattern survives in: `LIdeologue` (contract.py:21), `LeFanatique` (contract.py:45), `LeDiplomate` (contract.py:62), `LePatriote` (contract.py:81), `LIllusionniste` (contract.py:128), `LePremierSang` (trick_timing.py:26/30), `LeSergent` (trick_timing.py:46), `LExecuteur` (trick_timing.py:82). All silently no-op when North takes the relevant trick. (The audit also named `LeRebelle` but I could not find it among the South-only checks — likely a hallucinated joker name; verify before touching.)
92
+ - **`src/belote/belatro/run/ante_themes.py:73-76` (H4) — TournoiAnte bonus is not the advertised 50%.** Effect uses `run.economy.add_money(max(1, run.economy.bonus_per_round // 2 + 2))`. The comment claims "+50% bonus on top of whatever payout the round produced", but the formula is a flat function of `bonus_per_round` (the per-round flat-bonus economy field), not 50% of actual round payout. Either compute true 50% of the round delta or rewrite the comment.
93
+ - **`src/belote/belatro/progression/save.py:94` (H5) — `load_profile` loses default unlocks.** Line reads `unlocked_ids=data.get("unlocked_ids", [])`. The `Profile` dataclass default is `["le_classique", "le_courageux", "l_econome"]`. A saved profile missing the key (older saves, manual edits, partial writes) reloads with no unlocks. The exception branch correctly returns `Profile()` with defaults; only the happy path corrupts.
94
+ - **`src/belote/main.py:230-231` (H7) — Win operator mismatch on ties.** Main loop uses `won=(ns >= target and ns >= ew)`; menu summary at `ui/menu.py:344` uses `winner = "NS" if ns > ew else "EW"`. On an exact tie at target, main records a NS win while the visible summary attributes the round to EW.
95
+
96
+ ### Architectural / latent
97
+
98
+ - **`src/belote/belatro/partner/partner_state.py:34-38` (H10) — `equip_joker` skips `on_purchase`.** The method simply appends to `self.jokers`; no `on_purchase()` hook is invoked. No current partner joker defines `on_purchase()`, so this is latent today — but any future partner joker with a purchase-time effect (a la `LeTraitre` in `corrupted.py`) will silently fail to fire when equipped through this path. Document or wire the hook before adding such a joker.
99
+ - **`src/belote/game.py:1007` (M4) — `advance_turn()` is dead code.** Defined, never called. Safe to delete.
100
+
101
+ ### Needs spec decision before fixing
102
+
103
+ - **`src/belote/belatro/items/jokers/corrupted.py:56-62` (H2) — `LEgoiste` nullifies the entire partner trick.** On `event.winner == Seat.NORTH` the joker returns `JokerResult(add_chips=-event.card_points)`, where `event.card_points` is the FULL trick's card points (South's contribution included). The audit reads this as a bug (only partner's own contribution should be subtracted); the code comment reads "Partner's points are nullified", implying *intentional* full-trick nullification. Resolve which is canonical before changing the formula — and either way add a test that pins the intended behaviour.
104
+
105
+ ### Verified clean — agent claims that did NOT survive source verification
106
+
107
+ Catalogued so they aren't re-investigated next cycle.
108
+
109
+ - **(C2) "No round-2 bid validation in `place_bid`"** — `place_bid` itself does not validate, but the human-input path filters the up-card suit out of the options menu at `ui/prompts.py:132-135`, and the AI path uses `exclude=forbidden` at `ai.py:137`. Defense-in-depth gap (a programmatic caller could pass a bad bid), not a live bug.
110
+ - **(H3) "`LeFou` fallback is dead code"** — `tarots.py:119` checks `if last_id and last_id != self.id`. `last_consumable_id` defaults to `None` and `getattr(item, "id", None)` can also set it to `None`, so the fallback fires on the first consumable of a run and on self-copy attempts. Live code.
111
+ - **(H6) "Signal handlers skip `finally` and lose stats"** — `main.py:127-129` handler does call `sys.exit(0)`, which bypasses the `finally`. But `flush_stats()` is also invoked at `main.py:160` (quit path) and `main.py:235` (game-over). The data-loss window is narrow (SIGINT before any flush in the same session). Cosmetic at best.
112
+ - **(H8) "`zip(..., strict=False)` causes silent data loss in scoring"** — `winners` at `scoring.py:461,498` is produced by `compute_trick_winners(state, ...)`, which is exactly one entry per completed trick by construction. The length invariant holds; `strict=False` is defensive noise, not a live bug.
113
+ - **(H9) "`_CARD_TO_ID[c]` will KeyError on BelAtro jokers"** — `_CARD_TO_ID` is built from `make_deck()` (32 standard cards). BelAtro jokers are `Joker` objects living in `state._joker_state`, never inserted into `hand`. No reachable code path produces the KeyError.
114
+ - **(M2) "Frozen `GameState` contains mutable `_rng`"** — `_rng` is declared with `field(default_factory=random.Random, compare=False, repr=False)` — the same documented pattern used for `_joker_state` (see comment at `game.py:214-217`). Intentional; the contract is "always rebuild via `dataclasses.replace`".
115
+ - **(M8) "`card in self.memory.partner_hand` is always False"** — `partner_hand` is populated at `ai.py:107-108` during PLAYING/SCORING phase. The check at line 660 is live and prevents double-scoring visible partner cards.
116
+ - **(M12) "`mult == float(int(mult))` float precision bug"** — Intentional optimisation in `belatro/core/scoring.py:254`: when the multiplier is exactly integral, take the lossless integer-multiplication path; else accept float multiplication. Correct logic.
117
+
118
+ ### Audit calibration notes (for the next pass)
119
+
120
+ The audit's overall scaffolding (file:line citations, severity tiers, action plan) was well-presented but had a recurring failure mode: **flagging "suspicious-looking" patterns without verifying behaviour in context**. Concrete recurring misses worth feeding back to the auditing model:
121
+
122
+ 1. **Defense-in-depth confused with bugs** (C2, H8, H9). When the audited line lacks an obvious check, the audit should trace one hop up the call graph before declaring "no validation". In all three cases the invariant is enforced at the caller.
123
+ 2. **Intent inversion** (H2, M2, M12). When code is paired with a comment that describes the exact behaviour the audit flags as wrong, that's evidence of intent, not a bug. Reading the adjacent comment before flagging would have caught these.
124
+ 3. **Self-cancelling dead-code claims** (H3). The audit claimed "`last_consumable_id` is always set before `use()` is called". True — but it can be set to `None`, and the consumer's guard is `if last_id and ...`. Surface-level static reasoning without tracing the guard.
125
+ 4. **Headline metrics with no numbers**. The "Current Health" table lists Tests / Lint / Types / Version rows with no values. The version row is checkable; the others were left blank, which makes the table cosmetic.
126
+ 5. **Joker name drift** (H1). `LeRebelle` appeared in the South-only list but is not among the actual offenders. Probable hallucination.
127
+
128
+ ### Internal
129
+
130
+ - **No source code touched.** Test count, mypy strictness, ruff cleanliness all unchanged from 3.4.0: pytest 551/551, mypy 0 errors (76 files), ruff 0 violations.
131
+ - **Version markers bumped**: `pyproject.toml`, `src/belote/__init__.py`.
132
+ - **Docs bumped**: `README.md` "What's new in 3.4.1" section, `DEVELOPMENT.md` baseline.
133
+ - **Roadmap for 3.4.2**: the 7 confirmed bugs above (C1, C3, C4, H1×8, H4, H5, H7) plus the H10/M4 cleanups. Tier 1 (C1/C3/C4) closes the AI-fairness gap; Tier 2 (H1/H4/H5/H7) closes the remaining verified issues.
134
+
8
135
  ## [3.4.0] - 2026-05-10
9
136
 
10
137
  Audit + endless-mode reliability + HUD polish release. A fresh three-agent codebase pass (classic engine / BelAtro layer / UI + I/O) produced ~80 candidate findings. Direct verification against the source rejected ~95% as false positives or by-design patterns. The five surviving issues plus two **new** bugs uncovered during follow-up verification of endless mode and classic game flow are fixed here. Two HUD features land alongside (joker pip strip with edition glow, synergy tooltip, polished trust bar). 551 tests passing (up from 549), ruff and mypy strict still clean. Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-fizzy-summit.md`.
@@ -84,14 +84,17 @@ PYTHONPATH=src mypy --strict src/
84
84
  # Linting (0 violations expected)
85
85
  ruff check src/ tests/
86
86
 
87
- # Full test suite (551 tests expected)
87
+ # Full test suite (592 tests expected)
88
88
  PYTHONPATH=src pytest
89
89
  ```
90
90
 
91
- Current baseline (3.4.0):
92
- - **mypy**: 0 errors (strict mode, 76 files)
91
+ Current baseline (3.5.0):
92
+ - **mypy**: 0 errors (strict mode, 77 files — `belatro/ui/consumables.py` is new)
93
93
  - **ruff**: 0 violations
94
- - **pytest**: 551 tests, 0 failures
94
+ - **pytest**: 592 tests, 0 failures
95
+ - 3.5.0 lands a 15-fix audit pass over the classic engine + BelAtro layer: C1 (consumables UI + Le Fou ordering), H1 (run-summary fsync), H2 (Key.EOF distinct from ESC), H3 (EventBus round-scope docs + `clear()`), M1 (declaration first-announcer tie-break), M3 (typed `RoundEndEvent.breakdown`), M4 (deprecate `partner_jokers_double`), M5 (SA belote invariant hoisted), L1 (`patch_trick_card` single-write), L2/L3 (doc pins), M2 (3 new benchmark micro-tests), P1/P2/P3 (perf hoist + cache-key + accumulator-profile analyses). +24 regression tests across 5 new files; 0 existing tests modified. Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-tidy-meerkat.md`.
96
+ - 3.4.2 closed the 3.4.1 catalogue. All 7 confirmed bugs (C1 AI cheat under `hide_partner_hand`, C3 Dix de Der under La Rupture, C4 `opp_trumps` formula + TA total, H1 8 jokers seat→team, H4 TournoiAnte true 50%, H5 `load_profile` default unlocks, H7 classic-mode tie operator) plus H10 (`equip_joker` wires `on_purchase`) and M4 (delete dead `advance_turn`) shipped in 3.4.2. +17 regression tests (551 → 568). H2 (`LEgoiste` partner-trick nullification) remains deferred — needs a spec call between code-comment intent and the audit's reading.
97
+ - 3.4.1 was **documentation-only** — an external LLM audit was verified against the source. 7 confirmed bugs were catalogued in `CHANGELOG.md` as deferred to 3.4.2+; 8 audit claims were rejected as false positives and are listed in the "Verified clean" section to block re-investigation. No source code changed in 3.4.1.
95
98
  - 3.4.0 covered: A1 `BidMadeEvent` double-fire on coinche paths (HIGH), E1 endless mode replaying Ante 8 Boss instead of advancing to the first scaled cycle (HIGH), E2 classic-mode tie-breaker overridden by main loop (HIGH), A2 termios raw-mode leak on SSH drop (MED), A3 shop selection index off-by-one after reroll (MED), A5 prompts.py dead return (LOW). Plus HUD additions: joker pip strip with edition glow (B.3), synergy tooltip (B.4), four-tier trust bar with tier glyph (B.5). Score gutter (B.2) and trick-lane compass (B.1) intentionally deferred — they touch `ui/render.py`'s vertical-centering logic and want a dedicated session.
96
99
 
97
100
  Run all gates before committing:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: belote-cli
3
- Version: 3.4.0
3
+ Version: 3.5.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,6 +45,37 @@ 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.5.0
49
+
50
+ - **C1 — BelAtro consumables are now usable.** Pre-3.5.0 `BelAtroRun.consume()` was defined but never called from any UI, so every Tarot purchased in the shop and every directly-bought Planet accumulated in `run.consumables` with no way to activate them — only the voucher-gated Forge-Tierce path could level a planet. New `ConsumablesOverlay` (`belatro/ui/consumables.py`) is reachable from the shop via the `C` key; the hint line now shows `C: Consumables (N)`. Surfacing this path also caught a latent Le Fou bug: `consume()` advanced `last_consumable_id` to the *current* item before calling `item.use()`, so Le Fou read its own id and fell through to fallback — fixed by reordering and making Le Fou transparent so a second Le Fou keeps copying the same source.
51
+ - **H1 — `run_summary.py` writes are durable.** Added `flush + fsync` inside the `with` block. A crash mid-write no longer leaves a truncated JSONL line.
52
+ - **H2 — `Key.EOF` distinct from `Key.ESC`.** A closed stdin (pipe / headless harness) used to make every prompt loop spin: ESC popped one level, the loop re-read stdin, got another ESC, popped again. Now `KeyReader.read()` returns `Key.EOF` on empty-read; every consumer accepts it like ESC; `prompt_card` / `prompt_bid` exit cleanly instead of spinning.
53
+ - **H3 — `EventBus` round-scope invariant documented + `clear()` added.** Module docstring now spells out the contract that today is silently enforced by `drive_round()` creating a fresh bus per round.
54
+ - **M1 — Tied carrés / sequences go to the first announcer.** `resolve_declarations` gains an optional `taker: Seat | None` parameter; tied declarations now walk the announce order (taker → clockwise) and award the first matching seat, matching standard Belote-Coinché. Legacy "cancel" behaviour preserved when `taker` not supplied.
55
+ - **M3 — `RoundEndEvent.breakdown` is properly typed** as `ScoringBreakdown`. Removed 9 defensive `getattr(event.breakdown, "is_failed", False)` patterns; `taker_seat` corrected to `Seat | None`.
56
+ - **M4 — `partner_jokers_double` legacy flag is now deprecated.** A `DeprecationWarning` fires when both it and the tier scaling are set; flag slated for removal in 4.0.
57
+ - **M5 — SA belote invariant hoisted to contract level.** Was previously only asserted inside the capot branch.
58
+ - **L1 — `patch_trick_card` batches its `sys.stdout.write` calls.** Eliminates a torn-frame window on signal-interruptible terminals.
59
+ - **L2/L3 — Documentation pins**: `BELOTE_A11Y` is read once at import; `BelAtroRun.consumables/jokers/vouchers` are intentionally mutable.
60
+ - **M2 — Benchmark coverage expanded.** Three new micro-benchmarks: warm-cache `legal_cards`, `trick_card_points`, and the AI legality-filter step. The pre-existing cold-cache `legal_cards` benchmark is now labelled accordingly.
61
+ - **P1/P2/P3 — Performance pass.** `trick_card_points` flag-hoist (already at ~2μs/call — sub-μs gain). `legal_cards` cache-key analysis: warm/cold gap is dominated by key-build, not lru_cache lookup; no actionable optimisation without changing the hand representation — documented to prevent re-investigation. `ScoreAccumulator.update_state` profile shows `dataclasses.replace` is 65% of cost; frozen-GameState invariant is load-bearing, accumulator is ~0.5ms/round — acceptable.
62
+ - **Verified false positives** (catalogued so they aren't re-raised): Belote/Rebelote partner non-announcement, Capot Rupture false-positive, AI `partner_hand` undo clearing, Negative-edition slot rollback, and `boss.id == "..."` branching — five agent flags that didn't survive source verification.
63
+ - **Tests + gates**: 592 tests (up from 568, +24 regression tests across 5 new files, 0 existing tests modified). pytest 592/592 green.
64
+
65
+ ## What's new in 3.4.2
66
+
67
+ - **The 3.4.1 catalogue is closed.** All 7 confirmed bugs plus the H10 architectural cleanup and the M4 dead-code deletion ship here. **C1 — AI cheating under Le Fantôme Partenaire:** AI memory now respects `hide_partner_hand`; the boss flag's visibility cost is paid by both sides. **C3 — Dix de Der announcement under La Rupture:** the 8th-trick "Team X" line now uses the Rupture-aware `compute_trick_winners` helper, so the named team matches what scoring credits. **C4 — `opp_trumps` formula:** subtracts South's own trumps, played trumps, and partner-visible trumps; under Tout Atout the total switches to 32 (every card is a trump). **H1 — 8 BelAtro jokers:** `LIdeologue`, `LeFanatique`, `LeDiplomate`, `LePatriote`, `LIllusionniste`, `LePremierSang`, `LeSergent`, `LExecuteur` now gate on `team_of(event.winner) == 0` instead of `event.winner == Seat.SOUTH`, matching the 3.2.0 fix that landed `LaSentinelle` and `LeDernierMot`. **H4 — TournoiAnte:** `on_blind_won` now receives `blind_payout` and pays a true 50% (was a flat function of `bonus_per_round`). **H5 — `load_profile`:** saves missing the `unlocked_ids` key fall back to the Profile dataclass default starter unlocks. **H7 — classic win attribution on ties:** `update_stats_game` operator aligned to `ns > ew` so the stats line agrees with `menu.py`'s visible winner on an exact tie at target.
68
+ - **Architectural / cleanup.** `equip_joker` accepts an optional `run` and fires `on_purchase` when provided (forward-looking — no current partner joker has a purchase hook). Dead `advance_turn()` deleted from `game.py`.
69
+ - **Still deferred** (need spec calls, not implementation work): H2 `LEgoiste` partner-trick nullification (comment vs. audit reading); `LeRebelle` `on_belote` seat/team gating (separate code path from H1).
70
+ - **Tests + gates** — 568 tests (up from 551, +17 regressions). Five existing `test_north_*_returns_none` tests in `test_belatro.py` were flipped to assert the new team-aware behaviour (they had encoded the H1 bug as a contract). Strict gates still clean: pytest 568/568, mypy 0 errors (76 files), ruff 0 violations.
71
+
72
+ ## What's new in 3.4.1
73
+
74
+ - **Audit verification pass — no code changes.** An external LLM audit ("Comprehensive Audit Report — Belote CLI v3.4.0") flagged 4 Critical, 10 High, and 12 Medium issues. Direct verification against the source confirmed **7 real bugs** (3 Critical, 4 High), **1 architectural latent issue**, and **1 disputed claim** needing a spec decision; **8 claims were false positives**. The roadmap and the false-positive catalogue are recorded in `CHANGELOG.md` so the next session has a vetted target list and the rejected claims aren't re-investigated. *Note (3.4.2): the catalogue is now closed — all 7 confirmed bugs plus H10 and M4 are fixed in 3.4.2. The 3.4.1 entry is preserved as a historical record of the verification-only release.*
75
+ - **Confirmed bugs deferred to 3.4.2+**: AI ignoring `hide_partner_hand` (C1), Dix de Der announcement not La Rupture-aware (C3), AI `opp_trumps` over-count (C4), 8 BelAtro jokers still gated on South instead of NS team (H1), TournoiAnte bonus not actually 50% (H4), `load_profile` losing default unlocks (H5), classic-mode tie-break operator mismatch with the menu summary (H7). Plus latent: `equip_joker` skipping `on_purchase` (H10), and a dead `advance_turn()` (M4).
76
+ - **Audit calibration findings** — recurring failure modes documented for the next pass: defense-in-depth confused with bugs (C2/H8/H9), intent inversion against adjacent comments (H2/M2/M12), self-cancelling dead-code claims (H3), and joker-name hallucination (H1's `LeRebelle`). Useful as feedback to the auditing model.
77
+ - **No behavioural changes.** Test count, mypy strict, ruff all unchanged from 3.4.0: pytest 551/551, mypy 0 errors (76 files), ruff 0 violations.
78
+
48
79
  ## What's new in 3.4.0
49
80
 
50
81
  - **BelAtro joker correctness** — `BidMadeEvent` no longer double-fires `on_bid` joker handlers on coinche paths. Pre-3.4.0 the player-coinche, AI-partner-coinche, `auto_coinche` boss, and `start_coinched` deck-mod paths all re-emitted the same bid event a second time with the resolved `coinche_level`, so on_bid jokers (Le Passeur today, anything new tomorrow) were silently invoked twice for the same bid. Fixed via a `re_emit: bool` field on the event; refreshes update `joker_state["contract"]` but skip joker firing.
@@ -337,7 +368,7 @@ belote/
337
368
  │ ├── input.py # Platform-dispatched key reader and interruptible sleep
338
369
  │ ├── stats.py # Global and session statistics tracking
339
370
  │ └── rules.py # Game rules content
340
- ├── tests/ # Comprehensive test suite (528 tests)
371
+ ├── tests/ # Comprehensive test suite (568 tests)
341
372
  ├── scripts/ # Performance benchmarks
342
373
  ├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
343
374
  ├── LICENSE # MIT License
@@ -353,14 +384,14 @@ belote/
353
384
  PYTHONPATH=src pytest
354
385
  ```
355
386
 
356
- Currently **528 tests** passing with 100% coverage on game-logic modules.
387
+ Currently **568 tests** passing with 100% coverage on game-logic modules.
357
388
 
358
389
  ## Technical Integrity
359
390
 
360
391
  The codebase is strictly validated with the following tools:
361
392
  - **mypy**: 0 errors (strict type safety)
362
393
  - **ruff**: 0 violations (linting & formatting)
363
- - **pytest**: 528/528 passed
394
+ - **pytest**: 568/568 passed
364
395
  - **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
365
396
  - **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
366
397
 
@@ -2,6 +2,37 @@
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.5.0
6
+
7
+ - **C1 — BelAtro consumables are now usable.** Pre-3.5.0 `BelAtroRun.consume()` was defined but never called from any UI, so every Tarot purchased in the shop and every directly-bought Planet accumulated in `run.consumables` with no way to activate them — only the voucher-gated Forge-Tierce path could level a planet. New `ConsumablesOverlay` (`belatro/ui/consumables.py`) is reachable from the shop via the `C` key; the hint line now shows `C: Consumables (N)`. Surfacing this path also caught a latent Le Fou bug: `consume()` advanced `last_consumable_id` to the *current* item before calling `item.use()`, so Le Fou read its own id and fell through to fallback — fixed by reordering and making Le Fou transparent so a second Le Fou keeps copying the same source.
8
+ - **H1 — `run_summary.py` writes are durable.** Added `flush + fsync` inside the `with` block. A crash mid-write no longer leaves a truncated JSONL line.
9
+ - **H2 — `Key.EOF` distinct from `Key.ESC`.** A closed stdin (pipe / headless harness) used to make every prompt loop spin: ESC popped one level, the loop re-read stdin, got another ESC, popped again. Now `KeyReader.read()` returns `Key.EOF` on empty-read; every consumer accepts it like ESC; `prompt_card` / `prompt_bid` exit cleanly instead of spinning.
10
+ - **H3 — `EventBus` round-scope invariant documented + `clear()` added.** Module docstring now spells out the contract that today is silently enforced by `drive_round()` creating a fresh bus per round.
11
+ - **M1 — Tied carrés / sequences go to the first announcer.** `resolve_declarations` gains an optional `taker: Seat | None` parameter; tied declarations now walk the announce order (taker → clockwise) and award the first matching seat, matching standard Belote-Coinché. Legacy "cancel" behaviour preserved when `taker` not supplied.
12
+ - **M3 — `RoundEndEvent.breakdown` is properly typed** as `ScoringBreakdown`. Removed 9 defensive `getattr(event.breakdown, "is_failed", False)` patterns; `taker_seat` corrected to `Seat | None`.
13
+ - **M4 — `partner_jokers_double` legacy flag is now deprecated.** A `DeprecationWarning` fires when both it and the tier scaling are set; flag slated for removal in 4.0.
14
+ - **M5 — SA belote invariant hoisted to contract level.** Was previously only asserted inside the capot branch.
15
+ - **L1 — `patch_trick_card` batches its `sys.stdout.write` calls.** Eliminates a torn-frame window on signal-interruptible terminals.
16
+ - **L2/L3 — Documentation pins**: `BELOTE_A11Y` is read once at import; `BelAtroRun.consumables/jokers/vouchers` are intentionally mutable.
17
+ - **M2 — Benchmark coverage expanded.** Three new micro-benchmarks: warm-cache `legal_cards`, `trick_card_points`, and the AI legality-filter step. The pre-existing cold-cache `legal_cards` benchmark is now labelled accordingly.
18
+ - **P1/P2/P3 — Performance pass.** `trick_card_points` flag-hoist (already at ~2μs/call — sub-μs gain). `legal_cards` cache-key analysis: warm/cold gap is dominated by key-build, not lru_cache lookup; no actionable optimisation without changing the hand representation — documented to prevent re-investigation. `ScoreAccumulator.update_state` profile shows `dataclasses.replace` is 65% of cost; frozen-GameState invariant is load-bearing, accumulator is ~0.5ms/round — acceptable.
19
+ - **Verified false positives** (catalogued so they aren't re-raised): Belote/Rebelote partner non-announcement, Capot Rupture false-positive, AI `partner_hand` undo clearing, Negative-edition slot rollback, and `boss.id == "..."` branching — five agent flags that didn't survive source verification.
20
+ - **Tests + gates**: 592 tests (up from 568, +24 regression tests across 5 new files, 0 existing tests modified). pytest 592/592 green.
21
+
22
+ ## What's new in 3.4.2
23
+
24
+ - **The 3.4.1 catalogue is closed.** All 7 confirmed bugs plus the H10 architectural cleanup and the M4 dead-code deletion ship here. **C1 — AI cheating under Le Fantôme Partenaire:** AI memory now respects `hide_partner_hand`; the boss flag's visibility cost is paid by both sides. **C3 — Dix de Der announcement under La Rupture:** the 8th-trick "Team X" line now uses the Rupture-aware `compute_trick_winners` helper, so the named team matches what scoring credits. **C4 — `opp_trumps` formula:** subtracts South's own trumps, played trumps, and partner-visible trumps; under Tout Atout the total switches to 32 (every card is a trump). **H1 — 8 BelAtro jokers:** `LIdeologue`, `LeFanatique`, `LeDiplomate`, `LePatriote`, `LIllusionniste`, `LePremierSang`, `LeSergent`, `LExecuteur` now gate on `team_of(event.winner) == 0` instead of `event.winner == Seat.SOUTH`, matching the 3.2.0 fix that landed `LaSentinelle` and `LeDernierMot`. **H4 — TournoiAnte:** `on_blind_won` now receives `blind_payout` and pays a true 50% (was a flat function of `bonus_per_round`). **H5 — `load_profile`:** saves missing the `unlocked_ids` key fall back to the Profile dataclass default starter unlocks. **H7 — classic win attribution on ties:** `update_stats_game` operator aligned to `ns > ew` so the stats line agrees with `menu.py`'s visible winner on an exact tie at target.
25
+ - **Architectural / cleanup.** `equip_joker` accepts an optional `run` and fires `on_purchase` when provided (forward-looking — no current partner joker has a purchase hook). Dead `advance_turn()` deleted from `game.py`.
26
+ - **Still deferred** (need spec calls, not implementation work): H2 `LEgoiste` partner-trick nullification (comment vs. audit reading); `LeRebelle` `on_belote` seat/team gating (separate code path from H1).
27
+ - **Tests + gates** — 568 tests (up from 551, +17 regressions). Five existing `test_north_*_returns_none` tests in `test_belatro.py` were flipped to assert the new team-aware behaviour (they had encoded the H1 bug as a contract). Strict gates still clean: pytest 568/568, mypy 0 errors (76 files), ruff 0 violations.
28
+
29
+ ## What's new in 3.4.1
30
+
31
+ - **Audit verification pass — no code changes.** An external LLM audit ("Comprehensive Audit Report — Belote CLI v3.4.0") flagged 4 Critical, 10 High, and 12 Medium issues. Direct verification against the source confirmed **7 real bugs** (3 Critical, 4 High), **1 architectural latent issue**, and **1 disputed claim** needing a spec decision; **8 claims were false positives**. The roadmap and the false-positive catalogue are recorded in `CHANGELOG.md` so the next session has a vetted target list and the rejected claims aren't re-investigated. *Note (3.4.2): the catalogue is now closed — all 7 confirmed bugs plus H10 and M4 are fixed in 3.4.2. The 3.4.1 entry is preserved as a historical record of the verification-only release.*
32
+ - **Confirmed bugs deferred to 3.4.2+**: AI ignoring `hide_partner_hand` (C1), Dix de Der announcement not La Rupture-aware (C3), AI `opp_trumps` over-count (C4), 8 BelAtro jokers still gated on South instead of NS team (H1), TournoiAnte bonus not actually 50% (H4), `load_profile` losing default unlocks (H5), classic-mode tie-break operator mismatch with the menu summary (H7). Plus latent: `equip_joker` skipping `on_purchase` (H10), and a dead `advance_turn()` (M4).
33
+ - **Audit calibration findings** — recurring failure modes documented for the next pass: defense-in-depth confused with bugs (C2/H8/H9), intent inversion against adjacent comments (H2/M2/M12), self-cancelling dead-code claims (H3), and joker-name hallucination (H1's `LeRebelle`). Useful as feedback to the auditing model.
34
+ - **No behavioural changes.** Test count, mypy strict, ruff all unchanged from 3.4.0: pytest 551/551, mypy 0 errors (76 files), ruff 0 violations.
35
+
5
36
  ## What's new in 3.4.0
6
37
 
7
38
  - **BelAtro joker correctness** — `BidMadeEvent` no longer double-fires `on_bid` joker handlers on coinche paths. Pre-3.4.0 the player-coinche, AI-partner-coinche, `auto_coinche` boss, and `start_coinched` deck-mod paths all re-emitted the same bid event a second time with the resolved `coinche_level`, so on_bid jokers (Le Passeur today, anything new tomorrow) were silently invoked twice for the same bid. Fixed via a `re_emit: bool` field on the event; refreshes update `joker_state["contract"]` but skip joker firing.
@@ -294,7 +325,7 @@ belote/
294
325
  │ ├── input.py # Platform-dispatched key reader and interruptible sleep
295
326
  │ ├── stats.py # Global and session statistics tracking
296
327
  │ └── rules.py # Game rules content
297
- ├── tests/ # Comprehensive test suite (528 tests)
328
+ ├── tests/ # Comprehensive test suite (568 tests)
298
329
  ├── scripts/ # Performance benchmarks
299
330
  ├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
300
331
  ├── LICENSE # MIT License
@@ -310,14 +341,14 @@ belote/
310
341
  PYTHONPATH=src pytest
311
342
  ```
312
343
 
313
- Currently **528 tests** passing with 100% coverage on game-logic modules.
344
+ Currently **568 tests** passing with 100% coverage on game-logic modules.
314
345
 
315
346
  ## Technical Integrity
316
347
 
317
348
  The codebase is strictly validated with the following tools:
318
349
  - **mypy**: 0 errors (strict type safety)
319
350
  - **ruff**: 0 violations (linting & formatting)
320
- - **pytest**: 528/528 passed
351
+ - **pytest**: 568/568 passed
321
352
  - **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
322
353
  - **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
323
354
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "belote-cli"
7
- version = "3.4.0"
7
+ version = "3.5.0"
8
8
  description = "A 4-player terminal card game"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -149,21 +149,103 @@ def benchmark_deal(iterations: int = 1000) -> float:
149
149
 
150
150
  def benchmark_legal_cards(iterations: int = 1000) -> float:
151
151
  from belote.game import legal_cards, clear_legal_cards_cache, replace
152
- print(f"Benchmarking legal_cards() over {iterations} iterations...")
153
-
152
+ print(f"Benchmarking legal_cards() (cache cleared per call) over {iterations} iterations...")
153
+
154
154
  state = new_game()
155
155
  state = start_round(state, random.Random(42))
156
156
  state = replace(state, phase=Phase.PLAYING, trump=Suit.SPADES, turn=Seat.SOUTH)
157
-
157
+
158
158
  times = []
159
159
  for _ in range(iterations):
160
160
  clear_legal_cards_cache()
161
161
  start = time.perf_counter()
162
162
  _ = legal_cards(state, Seat.SOUTH)
163
163
  times.append(time.perf_counter() - start)
164
-
164
+
165
+ avg = statistics.mean(times) * 1000
166
+ print(f" Legal Cards (cold) Time: {avg:.3f}ms")
167
+ return avg
168
+
169
+
170
+ def benchmark_legal_cards_cached(iterations: int = 1000) -> float:
171
+ """Measure the cache-hit path. Production gameplay reuses the cache across
172
+ multiple AI rollouts and HUD redraws for the same `(state, seat)` pair —
173
+ `benchmark_legal_cards` above invalidates every iteration and so reflects
174
+ worst-case-only time.
175
+ """
176
+ from belote.game import legal_cards, clear_legal_cards_cache, replace
177
+ print(f"Benchmarking legal_cards() (warm cache) over {iterations} iterations...")
178
+
179
+ state = new_game()
180
+ state = start_round(state, random.Random(42))
181
+ state = replace(state, phase=Phase.PLAYING, trump=Suit.SPADES, turn=Seat.SOUTH)
182
+ clear_legal_cards_cache()
183
+ legal_cards(state, Seat.SOUTH) # warm the cache once
184
+
185
+ times = []
186
+ for _ in range(iterations):
187
+ start = time.perf_counter()
188
+ _ = legal_cards(state, Seat.SOUTH)
189
+ times.append(time.perf_counter() - start)
190
+
191
+ avg = statistics.mean(times) * 1000
192
+ print(f" Legal Cards (warm) Time: {avg:.3f}ms")
193
+ return avg
194
+
195
+
196
+ def benchmark_trick_scoring(iterations: int = 1000) -> float:
197
+ """Measure `trick_card_points` — called 8× per round from `game.py::play_card`
198
+ (HUD running total) and again from `scoring.py::_calculate_base_points`
199
+ (final round score). One of the hottest functions in a played round.
200
+ """
201
+ from belote.game import TrickCard, replace
202
+ from belote.deck import Card, Rank
203
+ from belote.scoring import trick_card_points
204
+
205
+ print(f"Benchmarking trick_card_points() over {iterations} iterations...")
206
+
207
+ state = new_game()
208
+ state = replace(state, trump=Suit.SPADES, contract="normal", taker=Seat.SOUTH)
209
+ trick = (
210
+ TrickCard(Seat.SOUTH, Card(Suit.SPADES, Rank.JACK)),
211
+ TrickCard(Seat.WEST, Card(Suit.SPADES, Rank.NINE)),
212
+ TrickCard(Seat.NORTH, Card(Suit.HEARTS, Rank.ACE)),
213
+ TrickCard(Seat.EAST, Card(Suit.SPADES, Rank.SEVEN)),
214
+ )
215
+
216
+ times = []
217
+ for _ in range(iterations):
218
+ start = time.perf_counter()
219
+ _ = trick_card_points(state, trick)
220
+ times.append(time.perf_counter() - start)
221
+
222
+ avg = statistics.mean(times) * 1000
223
+ print(f" Trick Scoring Time: {avg:.3f}ms")
224
+ return avg
225
+
226
+
227
+ def benchmark_ai_legality_filter(iterations: int = 500) -> float:
228
+ """Isolate the legal-move filter step inside `AIPlayer.decide_card`. The
229
+ AI calls `legal_cards` once per decision; an unrepresentative cold-cache
230
+ benchmark above would over-attribute AI time to legality checks.
231
+ """
232
+ from belote.game import legal_cards, replace
233
+ print(f"Benchmarking AI legality filter over {iterations} iterations...")
234
+
235
+ state = new_game()
236
+ state = start_round(state, random.Random(42))
237
+ state = replace(state, phase=Phase.PLAYING, trump=Suit.SPADES, taker=Seat.SOUTH, turn=Seat.NORTH)
238
+
239
+ times = []
240
+ for _ in range(iterations):
241
+ start = time.perf_counter()
242
+ legal = legal_cards(state, Seat.NORTH)
243
+ # The filter step: callers usually check membership in a 6-8 card hand.
244
+ _ = [c for c in state.hands[Seat.NORTH.value] if c in legal]
245
+ times.append(time.perf_counter() - start)
246
+
165
247
  avg = statistics.mean(times) * 1000
166
- print(f" Legal Cards Time: {avg:.3f}ms")
248
+ print(f" Legality Filter Time: {avg:.3f}ms")
167
249
  return avg
168
250
 
169
251
 
@@ -180,6 +262,9 @@ def run_benchmarks() -> None:
180
262
  benchmark_scoring()
181
263
  benchmark_deal()
182
264
  benchmark_legal_cards()
265
+ benchmark_legal_cards_cached()
266
+ benchmark_trick_scoring()
267
+ benchmark_ai_legality_filter()
183
268
  print("========================================")
184
269
 
185
270
 
@@ -1,3 +1,3 @@
1
- __version__ = "3.4.0"
1
+ __version__ = "3.5.0"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -5,6 +5,11 @@ line to stderr — readable by terminal screen readers such as Orca, NVDA in WSL
5
5
  or VoiceOver via iTerm2. Disabled by default so it doesn't pollute output for
6
6
  sighted players.
7
7
 
8
+ **Invariant**: ``BELOTE_A11Y`` is read **once at module import**. Toggling the
9
+ env var mid-session has no effect on production code — restart the process
10
+ to enable/disable. Tests that mutate the env may call ``_refresh_enabled_from_env()``
11
+ to re-read the cached flag.
12
+
8
13
  Hooked from gameflow.py (card plays, trick winners, round results) and from
9
14
  belatro/main.py (boss reveal, ante advance, run won/lost). Each hook is a
10
15
  single line — no rich formatting — so the screen reader can speak it cleanly.
@@ -102,8 +102,10 @@ class AIPlayer:
102
102
  # In this implementation, AI tracks what it can see
103
103
  p = partner(self.seat)
104
104
  self.memory.partner_hand.clear()
105
- if state.phase in (Phase.PLAYING, Phase.SCORING):
106
- # Partner's remaining cards
105
+ if (
106
+ state.phase in (Phase.PLAYING, Phase.SCORING)
107
+ and not state.boss_modifiers.hide_partner_hand
108
+ ):
107
109
  for card in state.hand_of(p):
108
110
  self.memory.partner_hand.add(card)
109
111
 
@@ -529,8 +531,22 @@ class AIPlayer:
529
531
 
530
532
  my_hand = state.hand_of(self.seat)
531
533
  hand_suit_counts: dict[Suit, int] = Counter(c.suit for c in my_hand)
532
- my_trumps = hand_suit_counts.get(trump, 0)
533
- opp_trumps = 8 - sum(1 for c in self.memory.played if c.suit == trump)
534
+ # Under Tout Atout every card is a trump; under a normal contract
535
+ # trump cards are those matching the trump suit. `opp_trumps` must
536
+ # subtract everything that is no longer in opponents' hands: my own
537
+ # trumps, trumps already played, and any of partner's visible
538
+ # trumps (empty under `hide_partner_hand`).
539
+ if trump is Suit.TOUT_ATOUT:
540
+ total_trumps = 32
541
+ my_trumps = len(my_hand)
542
+ played_trumps = len(self.memory.played)
543
+ partner_trumps = len(self.memory.partner_hand)
544
+ else:
545
+ total_trumps = 8
546
+ my_trumps = hand_suit_counts.get(trump, 0)
547
+ played_trumps = sum(1 for c in self.memory.played if c.suit == trump)
548
+ partner_trumps = sum(1 for c in self.memory.partner_hand if c.suit == trump)
549
+ opp_trumps = max(0, total_trumps - my_trumps - played_trumps - partner_trumps)
534
550
 
535
551
  # Score each legal card by expected outcome
536
552
  best_card = legal[0]
@@ -28,6 +28,11 @@ class BelAtroRun:
28
28
  profile: Profile | None = None
29
29
 
30
30
  # ── Collectibles ───────────────────────────────────────
31
+ # These lists are intentionally mutable: jokers append on purchase, vouchers
32
+ # apply their effects in-place, and `consume()` removes items via
33
+ # `consumables.remove(item)`. If a future feature needs to snapshot a run
34
+ # for replay / ghost-run reconstruction, deep-copy these lists at the
35
+ # snapshot boundary — they alias live state otherwise.
31
36
  jokers: list[Joker] = field(default_factory=list)
32
37
  vouchers: list[Voucher] = field(default_factory=list)
33
38
  consumables: list[Any] = field(default_factory=list) # Tarot/Planet instances
@@ -90,9 +95,17 @@ class BelAtroRun:
90
95
  def consume(self, item: Any, context: object = None) -> None:
91
96
  """Centralised consumable activation.
92
97
 
93
- Records the item id as the most recent consumable (so LeFou can copy
94
- it) and removes it from `consumables` if present, then dispatches to
95
- the right hook based on item type (Tarot vs Planet).
98
+ Removes the item from `consumables` if present, dispatches to the right
99
+ hook based on item type (Tarot vs Planet), then advances
100
+ `last_consumable_id` so Le Fou can copy the prior consumable later.
101
+
102
+ Apply order matters: `item.use()` must run BEFORE updating
103
+ `last_consumable_id`, because Le Fou's `use()` reads that field to
104
+ find what to copy — if we advanced it to `le_fou` first, Le Fou would
105
+ see itself and fall through to the fallback path. Le Fou itself is
106
+ treated as transparent: the bookmark stays pointed at the source it
107
+ copied, so a second Le Fou keeps copying the same source rather than
108
+ copying itself.
96
109
  """
97
110
  import contextlib
98
111
 
@@ -100,11 +113,14 @@ class BelAtroRun:
100
113
 
101
114
  with contextlib.suppress(ValueError):
102
115
  self.consumables.remove(item)
103
- self.last_consumable_id = getattr(item, "id", None)
104
116
  if isinstance(item, Tarot):
105
117
  item.use(self, context)
106
118
  elif isinstance(item, Planet):
107
119
  item.use(self)
120
+ # Le Fou is transparent — leave the bookmark at the source it copied.
121
+ # Every other consumable advances the bookmark to its own id.
122
+ if getattr(item, "id", None) != "le_fou":
123
+ self.last_consumable_id = getattr(item, "id", None)
108
124
 
109
125
  def _get_rng(self) -> Any:
110
126
  """Per-run random.Random instance, seeded from `seed` when given."""