belote-cli 3.4.2__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 (125) hide show
  1. {belote_cli-3.4.2 → belote_cli-3.5.0}/CHANGELOG.md +45 -0
  2. {belote_cli-3.4.2 → belote_cli-3.5.0}/DEVELOPMENT.md +6 -5
  3. {belote_cli-3.4.2 → belote_cli-3.5.0}/PKG-INFO +18 -1
  4. {belote_cli-3.4.2 → belote_cli-3.5.0}/README.md +17 -0
  5. {belote_cli-3.4.2 → belote_cli-3.5.0}/pyproject.toml +1 -1
  6. {belote_cli-3.4.2 → belote_cli-3.5.0}/scripts/benchmark.py +90 -5
  7. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/__init__.py +1 -1
  8. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/a11y.py +5 -0
  9. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/core/run_state.py +20 -4
  10. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/core/scoring.py +29 -6
  11. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/engine/event_bus.py +32 -3
  12. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/annonces.py +1 -3
  13. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/coinche.py +2 -2
  14. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/contract.py +1 -1
  15. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/progression/unlocks.py +2 -2
  16. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/run_summary.py +5 -0
  17. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/announce.py +2 -2
  18. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/collection.py +1 -1
  19. belote_cli-3.5.0/src/belote/belatro/ui/consumables.py +89 -0
  20. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/menu.py +2 -2
  21. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/rules.py +1 -1
  22. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/shop.py +17 -5
  23. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/game.py +8 -0
  24. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/input.py +9 -1
  25. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/main.py +1 -1
  26. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/scoring.py +79 -18
  27. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/ui/menu.py +2 -2
  28. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/ui/prompts.py +7 -3
  29. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/ui/render.py +7 -5
  30. belote_cli-3.5.0/tests/belatro/test_consumables_ui.py +125 -0
  31. belote_cli-3.5.0/tests/belatro/test_event_bus.py +65 -0
  32. belote_cli-3.5.0/tests/belatro/test_run_summary.py +65 -0
  33. belote_cli-3.5.0/tests/test_declaration_tiebreak.py +116 -0
  34. belote_cli-3.5.0/tests/test_input_eof.py +104 -0
  35. {belote_cli-3.4.2 → belote_cli-3.5.0}/.claude/settings.local.json +0 -0
  36. {belote_cli-3.4.2 → belote_cli-3.5.0}/.gitignore +0 -0
  37. {belote_cli-3.4.2 → belote_cli-3.5.0}/.python-version +0 -0
  38. {belote_cli-3.4.2 → belote_cli-3.5.0}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
  39. {belote_cli-3.4.2 → belote_cli-3.5.0}/LICENSE +0 -0
  40. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/__init__.py +0 -0
  41. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/achievements.py +0 -0
  42. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/ai.py +0 -0
  43. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/ansi.py +0 -0
  44. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/__init__.py +0 -0
  45. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/core/__init__.py +0 -0
  46. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/core/economy.py +0 -0
  47. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/engine/__init__.py +0 -0
  48. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/engine/modifier_patch.py +0 -0
  49. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/engine/round_driver.py +0 -0
  50. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ghost_run.py +0 -0
  51. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/__init__.py +0 -0
  52. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/base.py +0 -0
  53. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/__init__.py +0 -0
  54. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  55. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/economy.py +0 -0
  56. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  57. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
  58. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  59. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  60. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  61. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  62. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/planets.py +0 -0
  63. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/registry.py +0 -0
  64. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/tarots.py +0 -0
  65. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/vouchers.py +0 -0
  66. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/main.py +0 -0
  67. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/partner/__init__.py +0 -0
  68. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/partner/partner_state.py +0 -0
  69. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/partner/personality.py +0 -0
  70. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/partner/trust.py +0 -0
  71. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/progression/__init__.py +0 -0
  72. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/progression/save.py +0 -0
  73. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/run/__init__.py +0 -0
  74. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/run/ante.py +0 -0
  75. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/run/ante_themes.py +0 -0
  76. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/run/boss.py +0 -0
  77. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/run/decks.py +0 -0
  78. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/run/shop.py +0 -0
  79. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/__init__.py +0 -0
  80. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/history.py +0 -0
  81. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/hud.py +0 -0
  82. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/trust_bar.py +0 -0
  83. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/config.py +0 -0
  84. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/context.py +0 -0
  85. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/deck.py +0 -0
  86. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/gameflow.py +0 -0
  87. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/replay.py +0 -0
  88. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/rules.py +0 -0
  89. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/stats.py +0 -0
  90. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/themes.py +0 -0
  91. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/ui/__init__.py +0 -0
  92. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/ui/announce.py +0 -0
  93. {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/ui/layout.py +0 -0
  94. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/__init__.py +0 -0
  95. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/__init__.py +0 -0
  96. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_belatro.py +0 -0
  97. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_boss_modifiers_integration.py +0 -0
  98. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_collection_logic.py +0 -0
  99. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_contract_unlocks.py +0 -0
  100. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_dead_flag_fixes.py +0 -0
  101. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_deck_variants.py +0 -0
  102. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_ghost_run.py +0 -0
  103. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_history_overlay.py +0 -0
  104. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_hud_synergy.py +0 -0
  105. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_partner_trust.py +0 -0
  106. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_phase0_coverage.py +0 -0
  107. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_phase1_plumbing.py +0 -0
  108. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_phase2_content.py +0 -0
  109. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_phase3_meta.py +0 -0
  110. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_progression.py +0 -0
  111. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_round_driver.py +0 -0
  112. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_a11y.py +0 -0
  113. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_achievements.py +0 -0
  114. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_ai.py +0 -0
  115. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_ansi_helpers.py +0 -0
  116. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_belote.py +0 -0
  117. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_extended.py +0 -0
  118. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_game_logic.py +0 -0
  119. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_gameflow.py +0 -0
  120. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_layout.py +0 -0
  121. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_new_coverage.py +0 -0
  122. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_official_rules.py +0 -0
  123. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_properties.py +0 -0
  124. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_replay.py +0 -0
  125. {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_undo.py +0 -0
@@ -5,6 +5,51 @@ 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
+
8
53
  ## [3.4.2] - 2026-05-11
9
54
 
10
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`.
@@ -84,15 +84,16 @@ PYTHONPATH=src mypy --strict src/
84
84
  # Linting (0 violations expected)
85
85
  ruff check src/ tests/
86
86
 
87
- # Full test suite (568 tests expected)
87
+ # Full test suite (592 tests expected)
88
88
  PYTHONPATH=src pytest
89
89
  ```
90
90
 
91
- Current baseline (3.4.2):
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**: 568 tests, 0 failures
95
- - 3.4.2 closes 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`) ship 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.
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.
96
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.
97
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.
98
99
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: belote-cli
3
- Version: 3.4.2
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,23 @@ 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
+
48
65
  ## What's new in 3.4.2
49
66
 
50
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.
@@ -2,6 +2,23 @@
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
+
5
22
  ## What's new in 3.4.2
6
23
 
7
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "belote-cli"
7
- version = "3.4.2"
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.2"
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.
@@ -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."""
@@ -68,7 +68,17 @@ class ScoreAccumulator:
68
68
  return replace(state, _joker_state=joker_state, _chips=new_chips, _mult=new_mult)
69
69
 
70
70
  def update_state(self, state: GameState, event: object) -> GameState:
71
- """Process an event and return an updated GameState with new score/joker state."""
71
+ """Process an event and return an updated GameState with new score/joker state.
72
+
73
+ Perf note (3.5.0 P3 investigation): the dominant cost (~65% of the
74
+ function) is the final `dataclasses.replace(state, ...)` call. The
75
+ frozen-GameState invariant is load-bearing — many call sites assume
76
+ `state is final_state` once a round is sealed — so we accept the
77
+ replace cost rather than mutating in place. At ~19μs per event and
78
+ ~25 events per round (8 tricks + 2-4 bids + decls + round-end) the
79
+ accumulator contributes ~0.5ms to a full round, well below the
80
+ ~1ms-per-frame budget where it would matter.
81
+ """
72
82
  new_chips = state._chips
73
83
  new_mult = state._mult
74
84
  new_money = state._bonus_money
@@ -120,10 +130,23 @@ class ScoreAccumulator:
120
130
  # tier 2 (boost) / 3 (strong) → +1 apply (≈ ×2 effect),
121
131
  # matches legacy partner_jokers_double at trust ≥ 7.
122
132
  # tier 4 (elite) → +2 applies (≈ ×3 effect).
123
- # Legacy `partner_jokers_double` flag still forces +1 apply for
124
- # back-compat with tests that set it directly.
133
+ #
134
+ # `partner_jokers_double` is the legacy boolean flag (pre-3.5.0
135
+ # back-compat for tests that set it directly). When both are
136
+ # set, `max()` picks whichever is larger; a one-shot
137
+ # DeprecationWarning fires so callers migrate to tier. The flag
138
+ # is slated for removal in 4.0; new code should use `partner_tier`.
125
139
  if getattr(joker, "is_partner_joker", False):
126
140
  tier_extras = (0, 0, 1, 1, 2)[self.partner_tier]
141
+ if self.partner_jokers_double and tier_extras > 0:
142
+ import warnings
143
+ warnings.warn(
144
+ "ScoreAccumulator.partner_jokers_double is deprecated "
145
+ "alongside partner_tier; set only one. The flag will "
146
+ "be removed in 4.0.",
147
+ DeprecationWarning,
148
+ stacklevel=2,
149
+ )
127
150
  extra_applies = max(
128
151
  tier_extras, 1 if self.partner_jokers_double else 0
129
152
  )
@@ -202,11 +225,11 @@ class ScoreAccumulator:
202
225
  contract_id = _SUIT_TO_CONTRACT.get(event.trump) if event.trump else None
203
226
  if contract_id:
204
227
  reward = self.contract_levels.get(contract_id, {})
205
- if reward.get("add_money") and not getattr(event.breakdown, "is_failed", False):
228
+ if reward.get("add_money") and not event.breakdown.is_failed:
206
229
  new_money += reward["add_money"]
207
230
  self._log.append(f"Planet ({contract_id}): +${reward['add_money']}")
208
231
  # Pluto (Capot bonus)
209
- if event.capot and not getattr(event.breakdown, "is_failed", False):
232
+ if event.capot and not event.breakdown.is_failed:
210
233
  pluto_reward = self.contract_levels.get("capot", {})
211
234
  if pluto_reward.get("capot_bonus"):
212
235
  new_chips += pluto_reward["capot_bonus"]
@@ -215,7 +238,7 @@ class ScoreAccumulator:
215
238
  if (
216
239
  event.coinche_level > 0
217
240
  and event.taker_seat in _NS_TEAM
218
- and not getattr(event.breakdown, "is_failed", False)
241
+ and not event.breakdown.is_failed
219
242
  ):
220
243
  libra_reward = self.contract_levels.get("coinche", {})
221
244
  libra_mult = libra_reward.get("coinche_multiplier", 0)
@@ -1,12 +1,28 @@
1
+ """Round-scoped pub/sub bus for BelAtro joker / unlock / score-accumulator wiring.
2
+
3
+ **Scope invariant**: an `EventBus` is created once per round in
4
+ `round_driver.drive_round`, subscribed to by the round's accumulator and the
5
+ process-wide `UnlockTracker`, then dropped when the round ends. Subscribers
6
+ do not need to unsubscribe explicitly — the bus instance and all its
7
+ subscriber references are released together.
8
+
9
+ If you ever extend the bus's scope (run-level, session-level), you MUST also
10
+ add explicit unsubscribe calls so subscribers don't accumulate across rounds
11
+ and double-fire. The `clear()` method exists for that future use.
12
+ """
13
+
1
14
  from __future__ import annotations
2
15
 
3
16
  from collections.abc import Callable
4
17
  from dataclasses import dataclass
5
- from typing import Any
18
+ from typing import TYPE_CHECKING
6
19
 
7
20
  from belote.deck import Card, Suit
8
21
  from belote.game import Seat
9
22
 
23
+ if TYPE_CHECKING:
24
+ from belote.scoring import ScoringBreakdown
25
+
10
26
  # ── Event types ────────────────────────────────────────────────────────────
11
27
 
12
28
 
@@ -36,8 +52,9 @@ class DeclarationScoredEvent:
36
52
 
37
53
  @dataclass(frozen=True)
38
54
  class RoundEndEvent:
39
- breakdown: Any # ScoringBreakdown from belote.scoring
40
- taker_seat: Seat
55
+ breakdown: "ScoringBreakdown"
56
+ # `taker_seat` is None when the round ended on an all-pass (no contract).
57
+ taker_seat: Seat | None
41
58
  trump: Suit | None
42
59
  capot: bool
43
60
  hand_remainder: tuple[Card, ...] = ()
@@ -68,6 +85,8 @@ Handler = Callable[[AnyEvent], None]
68
85
 
69
86
 
70
87
  class EventBus:
88
+ """Round-scoped event bus. See module docstring for the lifetime contract."""
89
+
71
90
  def __init__(self) -> None:
72
91
  self._handlers: list[Handler] = []
73
92
 
@@ -83,3 +102,13 @@ class EventBus:
83
102
  def emit(self, event: AnyEvent) -> None:
84
103
  for h in list(self._handlers):
85
104
  h(event)
105
+
106
+ def clear(self) -> None:
107
+ """Drop every subscriber.
108
+
109
+ Today the round-scoped bus is created fresh per round so this is
110
+ unused, but exists for the future where a longer-lived bus might
111
+ share lifetime across rounds (debug overlays, replay recorders, etc).
112
+ Call before re-using a bus across round boundaries.
113
+ """
114
+ self._handlers.clear()
@@ -81,8 +81,6 @@ class QuinteRoyale(Joker):
81
81
  def on_round_end(
82
82
  self, event: RoundEndEvent, state: dict[str, Any]
83
83
  ) -> JokerResult | None:
84
- if state.pop(f"{self.id}_armed", False) and not getattr(
85
- event.breakdown, "is_failed", False
86
- ):
84
+ if state.pop(f"{self.id}_armed", False) and not event.breakdown.is_failed:
87
85
  return JokerResult(times_mult=4.0)
88
86
  return None
@@ -24,7 +24,7 @@ class CoincheStack(Joker):
24
24
  def on_round_end(self, event: RoundEndEvent, state: dict[str, Any]) -> JokerResult | None:
25
25
  if event.coinche_level <= 0:
26
26
  return None
27
- if getattr(event.breakdown, "is_failed", False):
27
+ if event.breakdown.is_failed:
28
28
  return None
29
29
  if event.taker_seat not in (Seat.SOUTH, Seat.NORTH):
30
30
  return None
@@ -53,7 +53,7 @@ class ToutStreak(Joker):
53
53
  is_tout = event.trump == Suit.TOUT_ATOUT
54
54
  is_taker_won = (
55
55
  event.taker_seat in (Seat.SOUTH, Seat.NORTH)
56
- and not getattr(event.breakdown, "is_failed", False)
56
+ and not event.breakdown.is_failed
57
57
  )
58
58
 
59
59
  if is_tout and is_taker_won:
@@ -109,7 +109,7 @@ class LePuriste(Joker):
109
109
  # Sans Atout means trump is None. Flag triggers double payout in _play_blind.
110
110
  if (
111
111
  event.trump is None
112
- and not getattr(event.breakdown, "is_failed", False)
112
+ and not event.breakdown.is_failed
113
113
  and event.taker_seat in (Seat.SOUTH, Seat.NORTH)
114
114
  ):
115
115
  state["puriste_triggered"] = True
@@ -64,7 +64,7 @@ class UnlockTracker:
64
64
  if (
65
65
  event.trump is None
66
66
  and event.taker_seat in (Seat.SOUTH, Seat.NORTH)
67
- and not getattr(event.breakdown, "is_failed", False)
67
+ and not event.breakdown.is_failed
68
68
  ):
69
69
  self.profile.stats["sans_atout_wins"] += 1
70
70
  dirty = True
@@ -79,7 +79,7 @@ class UnlockTracker:
79
79
  if (
80
80
  event.trump == Suit.TOUT_ATOUT
81
81
  and event.taker_seat in (Seat.SOUTH, Seat.NORTH)
82
- and not getattr(event.breakdown, "is_failed", False)
82
+ and not event.breakdown.is_failed
83
83
  ):
84
84
  self.profile.stats["tout_atout_wins"] += 1
85
85
  dirty = True
@@ -66,6 +66,11 @@ def append_summary(run: BelAtroRun, *, won: bool) -> None:
66
66
  path = _summary_path()
67
67
  with path.open("a", encoding="utf-8") as f:
68
68
  f.write(json.dumps(record) + "\n")
69
+ # flush + fsync so a crash or power-loss mid-write doesn't leave a
70
+ # truncated final line that breaks downstream `jq` processing.
71
+ # Mirrors the atomic-save pattern in `progression/save.py`.
72
+ f.flush()
73
+ os.fsync(f.fileno())
69
74
  except OSError:
70
75
  # Logging failure is intentionally silent — telemetry is non-essential.
71
76
  pass
@@ -67,7 +67,7 @@ class BelAtroAnnounce:
67
67
  event = reader.read_timeout(remaining)
68
68
  if event is None:
69
69
  break
70
- if event.key in (Key.SPACE, Key.ESC, Key.ENTER):
70
+ if event.key in (Key.SPACE, Key.ESC, Key.ENTER, Key.EOF):
71
71
  break
72
72
  remaining = end - time.time()
73
73
 
@@ -118,7 +118,7 @@ class BelAtroAnnounce:
118
118
  if event is None:
119
119
  break
120
120
  key = event.key
121
- if key in (Key.SPACE, Key.ESC, Key.ENTER):
121
+ if key in (Key.SPACE, Key.ESC, Key.ENTER, Key.EOF):
122
122
  break
123
123
  remaining = end - time.time()
124
124
  toggle_overlay()
@@ -128,7 +128,7 @@ def show_collection(reader: KeyReader, profile: Profile) -> None:
128
128
 
129
129
  event = reader.read()
130
130
  match event.key:
131
- case Key.QUIT | Key.ESC:
131
+ case Key.QUIT | Key.ESC | Key.EOF:
132
132
  return
133
133
  case Key.LEFT:
134
134
  cat_idx = (cat_idx - 1) % len(categories)