belote-cli 4.8.2__tar.gz → 4.9.4__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 (167) hide show
  1. belote_cli-4.9.4/CHANGELOG.md +1164 -0
  2. {belote_cli-4.8.2 → belote_cli-4.9.4}/DEVELOPMENT.md +33 -5
  3. {belote_cli-4.8.2 → belote_cli-4.9.4}/LICENSE +1 -1
  4. {belote_cli-4.8.2 → belote_cli-4.9.4}/PKG-INFO +10 -10
  5. {belote_cli-4.8.2 → belote_cli-4.9.4}/README.md +8 -8
  6. {belote_cli-4.8.2 → belote_cli-4.9.4}/pyproject.toml +1 -1
  7. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/__init__.py +1 -1
  8. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/ai.py +159 -1
  9. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/core/scoring.py +4 -2
  10. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/ui/hud.py +21 -2
  11. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/ui/inventory.py +77 -18
  12. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/game.py +6 -0
  13. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/gameflow.py +67 -1
  14. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/scoring.py +20 -4
  15. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/themes.py +28 -0
  16. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/ui/__init__.py +11 -1
  17. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/ui/announce.py +34 -5
  18. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/ui/prompts.py +53 -0
  19. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/ui/render.py +35 -5
  20. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/perf_baselines.json +5 -5
  21. belote_cli-4.9.4/tests/test_g1_coinche_classic.py +164 -0
  22. belote_cli-4.9.4/tests/test_g2_ai_signals.py +128 -0
  23. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_render_diff.py +90 -0
  24. belote_cli-4.9.4/tests/test_render_hand_lift.py +76 -0
  25. belote_cli-4.8.2/CHANGELOG.md +0 -2610
  26. {belote_cli-4.8.2 → belote_cli-4.9.4}/.claude/settings.local.json +0 -0
  27. {belote_cli-4.8.2 → belote_cli-4.9.4}/.gitignore +0 -0
  28. {belote_cli-4.8.2 → belote_cli-4.9.4}/.python-version +0 -0
  29. {belote_cli-4.8.2 → belote_cli-4.9.4}/scripts/benchmark.py +0 -0
  30. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/__init__.py +0 -0
  31. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/a11y.py +0 -0
  32. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/achievements.py +0 -0
  33. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/ansi.py +0 -0
  34. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/__init__.py +0 -0
  35. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/core/__init__.py +0 -0
  36. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/core/economy.py +0 -0
  37. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/core/round_ledger.py +0 -0
  38. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/core/run_state.py +0 -0
  39. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/engine/__init__.py +0 -0
  40. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/engine/event_bus.py +0 -0
  41. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/engine/modifier_patch.py +0 -0
  42. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/engine/round_driver.py +0 -0
  43. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/ghost_run.py +0 -0
  44. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/__init__.py +0 -0
  45. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/base.py +0 -0
  46. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/jokers/__init__.py +0 -0
  47. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/jokers/annonces.py +0 -0
  48. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/jokers/coinche.py +0 -0
  49. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/jokers/contract.py +0 -0
  50. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  51. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/jokers/economy.py +0 -0
  52. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  53. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
  54. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  55. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  56. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  57. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  58. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/planets.py +0 -0
  59. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/registry.py +0 -0
  60. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/tarots.py +0 -0
  61. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/items/vouchers.py +0 -0
  62. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/main.py +0 -0
  63. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/partner/__init__.py +0 -0
  64. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/partner/partner_state.py +0 -0
  65. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/partner/personality.py +0 -0
  66. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/partner/trust.py +0 -0
  67. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/progression/__init__.py +0 -0
  68. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/progression/save.py +0 -0
  69. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/progression/unlocks.py +0 -0
  70. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/run/__init__.py +0 -0
  71. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/run/ante.py +0 -0
  72. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/run/ante_themes.py +0 -0
  73. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/run/boss.py +0 -0
  74. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/run/decks.py +0 -0
  75. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/run/shop.py +0 -0
  76. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/run_summary.py +0 -0
  77. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/ui/__init__.py +0 -0
  78. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/ui/announce.py +0 -0
  79. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/ui/collection.py +0 -0
  80. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/ui/consumables.py +0 -0
  81. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/ui/history.py +0 -0
  82. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/ui/menu.py +0 -0
  83. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/ui/rules.py +0 -0
  84. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/ui/shop.py +0 -0
  85. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/belatro/ui/trust_bar.py +0 -0
  86. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/config.py +0 -0
  87. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/context.py +0 -0
  88. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/deck.py +0 -0
  89. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/input.py +0 -0
  90. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/main.py +0 -0
  91. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/replay.py +0 -0
  92. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/rules.py +0 -0
  93. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/stats.py +0 -0
  94. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/ui/anim.py +0 -0
  95. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/ui/fit_guard.py +0 -0
  96. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/ui/layout.py +0 -0
  97. {belote_cli-4.8.2 → belote_cli-4.9.4}/src/belote/ui/menu.py +0 -0
  98. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/__init__.py +0 -0
  99. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/__init__.py +0 -0
  100. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_belatro.py +0 -0
  101. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_boss_contracts.py +0 -0
  102. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_boss_modifiers_integration.py +0 -0
  103. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_collection_logic.py +0 -0
  104. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_competition_malediction.py +0 -0
  105. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_consumables_ui.py +0 -0
  106. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_contract_unlocks.py +0 -0
  107. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_dead_flag_fixes.py +0 -0
  108. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_deck_variants.py +0 -0
  109. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_decks_4_5.py +0 -0
  110. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_endless.py +0 -0
  111. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_event_bus.py +0 -0
  112. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_ghost_run.py +0 -0
  113. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_handler_registry.py +0 -0
  114. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_heist.py +0 -0
  115. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_history_overlay.py +0 -0
  116. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_hud_synergy.py +0 -0
  117. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_hud_toggle.py +0 -0
  118. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_inventory_overlay.py +0 -0
  119. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_joker_callouts.py +0 -0
  120. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_joker_contracts.py +0 -0
  121. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_jokers_4_5.py +0 -0
  122. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_partner_jokers.py +0 -0
  123. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_partner_trust.py +0 -0
  124. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_phase0_coverage.py +0 -0
  125. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_phase1_plumbing.py +0 -0
  126. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_phase2_content.py +0 -0
  127. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_phase3_meta.py +0 -0
  128. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_progression.py +0 -0
  129. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_round_driver.py +0 -0
  130. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_run_summary.py +0 -0
  131. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_shop_animations.py +0 -0
  132. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_shop_empty_pools.py +0 -0
  133. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_slot_machine_tally.py +0 -0
  134. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/belatro/test_voucher_idempotency.py +0 -0
  135. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_a11y.py +0 -0
  136. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_achievements.py +0 -0
  137. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_ai.py +0 -0
  138. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_ai_deluge_voids.py +0 -0
  139. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_ai_sans_atout.py +0 -0
  140. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_alt_screen_scroll.py +0 -0
  141. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_anim_helpers.py +0 -0
  142. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_announce_stats.py +0 -0
  143. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_ansi_helpers.py +0 -0
  144. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_belote.py +0 -0
  145. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_belote_stinger.py +0 -0
  146. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_benchmark_smoke.py +0 -0
  147. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_bidding_all_pass.py +0 -0
  148. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_declaration_tiebreak.py +0 -0
  149. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_extended.py +0 -0
  150. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_game_logic.py +0 -0
  151. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_gameflow.py +0 -0
  152. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_hand_auto_sort.py +0 -0
  153. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_history_overlay_cache.py +0 -0
  154. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_input_eof.py +0 -0
  155. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_input_wasd.py +0 -0
  156. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_invariants.py +0 -0
  157. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_layout.py +0 -0
  158. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_new_coverage.py +0 -0
  159. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_no_color.py +0 -0
  160. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_official_rules.py +0 -0
  161. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_perf_regression.py +0 -0
  162. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_properties.py +0 -0
  163. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_render_felt_polish.py +0 -0
  164. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_replay.py +0 -0
  165. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_undo.py +0 -0
  166. {belote_cli-4.8.2 → belote_cli-4.9.4}/tests/test_winner_glow.py +0 -0
  167. {belote_cli-4.8.2 → belote_cli-4.9.4}/uv.lock +0 -0
@@ -0,0 +1,1164 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [4.9.4] - 2026-05-26
9
+
10
+ Audit + maintenance pass. Three parallel Explore agents reviewed game-logic
11
+ correctness (classic Belote + BelAtro), performance hot paths, and
12
+ end-to-end feature-implementation coverage across every joker / deck / voucher /
13
+ planet / tarot / boss. **One real bug** surfaced (AI surcoinche dead code,
14
+ fixed below); performance is already well-optimised — the 4.6.x / 4.7.x
15
+ audit passes that drove the per-round baseline from 4.21ms → 3.67ms left
16
+ no quick wins on the table; feature wiring is complete with **zero
17
+ broken wirings and zero doc-drift** on item counts.
18
+
19
+ ### Fixed
20
+
21
+ - **AI surcoinche dead code** (`src/belote/ai.py::decide_coinche`). The
22
+ same method serves both initial coinche (defenders respond to the
23
+ taker's bid) and surcoinche (taker team redoubles after a coinche),
24
+ but the early-return guard `if team_of(self.seat) ==
25
+ team_of(state.taker): return False` fired in both phases — so every
26
+ taker-team AI polled by `gameflow.py:217` returned False, and AI
27
+ could never reach the ×4 surcoinche multiplier. The method is now
28
+ polymorphic on `state.coinche_level`: level 0 → defenders only,
29
+ level 1 → taker team only, level 2+ → nobody redoubles further.
30
+ Existing `test_taker_team_member_never_coinches_own_bid` rewired to
31
+ explicitly pin the level-0 path; three new tests in
32
+ `tests/test_g1_coinche_classic.py` pin the level-1 taker surcoinche,
33
+ the level-1 defender no-op, and the level-2 universal no-op.
34
+
35
+ ### Internal
36
+
37
+ - **CHANGELOG condensed through 4.5.x.** The 4.0.0 → 4.5.1 verbose
38
+ entries are collapsed into a new `## Pre-4.6.0 Releases — Condensed
39
+ History` section, mirroring the existing pre-4.0.0 block. Six bullets
40
+ (one per release), -~190 lines, no historical info lost — full
41
+ implementation context remains in `git log` and the per-release plan
42
+ files referenced in the original entries.
43
+ - **`DEVELOPMENT.md` gains a "Performance measurement" runbook**: the
44
+ `tests/test_perf_regression.py` guard's role, the
45
+ `scripts/benchmark.py` re-baseline path, and a pointer to the two
46
+ canonical hot paths (`render()`, `_calculate_legal_cards_impl`) for
47
+ live profiling. So the next perf sweep starts with a known recipe
48
+ instead of guesswork.
49
+ - **Doc-accuracy sweep.** README test count: 1066 → 1069 (two call
50
+ sites updated); README version bump 4.9.3 → 4.9.4 in the same
51
+ paragraph. Feature counts (42 jokers / 12 tarots / 8 planets / 12
52
+ vouchers / 12 decks / 21 bosses) re-verified against the registry —
53
+ unchanged.
54
+ - **Verified-OK (do not re-litigate)**: zero-rank boss flags applied at
55
+ all three sites (`_calculate_base_points`, `game.py::play_card` HUD
56
+ running total, `ai.py::card_points_with_modifiers`); `coinche_level`
57
+ reset between rounds; declaration tie-break order; AIMemory cache
58
+ invalidation on undo and new round; SA belote assertion; overlay
59
+ `invalidate_diff()` discipline; the three 4.9.0 pinned-feature tests
60
+ (`test_g1_coinche_classic`, `test_g2_ai_signals`,
61
+ `test_render_hand_lift`) match their code-under-test exactly; no
62
+ silent `except Exception: pass` beyond three intentional containment
63
+ sites (input.py UTF-8 decode, replay.py legacy state, event_bus
64
+ joker isolation); no `assert` reachable from user input. Performance:
65
+ RoundLedger transactional snapshot, register_all_items module-walk
66
+ guard, run-history I/O batching, AIMemory memo keys, render diff layer
67
+ — all already in their optimised state.
68
+
69
+ ### Test count baseline
70
+
71
+ **1069** (4.9.3 had 1066; +3 surcoinche regressions in
72
+ `tests/test_g1_coinche_classic.py`).
73
+
74
+ ## [4.9.3] - 2026-05-26
75
+
76
+ User-feedback patch over 4.9.2. Both AI-delay decorations introduced
77
+ in the 4.9.x line read as glitchy in practice; both removed. The
78
+ post-trick visual is now carried entirely by `pulse_winner_glow`
79
+ (the "★ Direction wins the trick ★" banner from 4.8.0 / C4), which
80
+ already fires immediately after the deleted animation.
81
+
82
+ ### Removed
83
+
84
+ - **(G4) AI thinking spinner** — `interruptible_sleep_with_spinner`
85
+ in `src/belote/ui/anim.py` (along with the `_SPINNER_GLYPHS`
86
+ braille table) is gone. The call site in
87
+ `src/belote/gameflow.py::run_play` now uses plain
88
+ `interruptible_sleep(ai_delay, reader)`, so AI pacing between
89
+ cards is preserved and a keypress still collapses remaining
90
+ animations (`skip_anims = True`) exactly as before — only the
91
+ top-right braille glyph is dropped.
92
+ - **(U3) Trick-collection animation** — `collect_trick_to_winner` in
93
+ `src/belote/ui/render.py` is gone (function + closures), along
94
+ with its re-export in `src/belote/ui/__init__.py`. The call in
95
+ `gameflow.py` is deleted; `pulse_winner_glow(w, reader)` on the
96
+ following line still fires and remains the post-trick visual.
97
+ 4.9.0's sparkle-glyph version and 4.9.1's hybrid card-frame
98
+ convergence are both retired by this removal.
99
+
100
+ ### Internal
101
+
102
+ - `BELOTE_NO_ANIM=1` docs in `DEVELOPMENT.md` updated to drop the
103
+ 4.9.0 additions bullet (the symbols it referenced no longer exist).
104
+ - `README.md`'s 4.8.0 / 4.9.0 / 4.9.1 animation polish paragraph
105
+ rewritten to drop the G4 and U3 clauses; U1 card-lift remains.
106
+
107
+ ### Test count baseline
108
+
109
+ **1066** (unchanged from 4.9.2 — no tests referenced either removed
110
+ symbol, verified by repo grep).
111
+
112
+ ## [4.9.2] - 2026-05-26
113
+
114
+ Defensive patch release. A targeted multi-model audit flagged ~45
115
+ candidate issues across the classic engine, BelAtro roguelite, and
116
+ the rendering layer; spot-verification against the source filtered
117
+ that down to one real correctness bug, a handful of cache-sizing
118
+ wins, and a deprecation comment refresh. The rest were false
119
+ positives (see `~/.claude/plans/bug-hunt-code-performance-magical-pixel.md`
120
+ "Phase D" for the catalogue so future audits don't relitigate them).
121
+
122
+ ### Fixed
123
+
124
+ - **`animate_score_update` now honors `BELOTE_NO_ANIM=1` and is
125
+ skippable** (`src/belote/ui/announce.py`). The 20-frame score-roll
126
+ used raw `time.sleep`, violating the architectural rule documented
127
+ in `src/belote/ui/anim.py` ("route delays through
128
+ `reader.read_timeout()`"). The function now accepts an optional
129
+ `reader=` kwarg: with a reader supplied, each step waits via
130
+ `reader.read_timeout(delay)` and a keypress collapses the
131
+ animation to its final frame — same idiom as `slot_machine_tally`
132
+ / `belete_stinger`. With `BELOTE_NO_ANIM=1` set, the loop is
133
+ short-circuited entirely and one final HUD frame is painted.
134
+ Call site in `src/belote/gameflow.py::run_game` updated to pass
135
+ the reader through.
136
+
137
+ ### Performance
138
+
139
+ - **`_pip_at` LRU cache raised 1024 → 4096**
140
+ (`src/belote/ui/render.py`). On 80×48 terminals the (row_id, col)
141
+ key space exceeded the previous maxsize, causing eviction
142
+ mid-frame on resize. Function is pure (depends only on row_id,
143
+ col, and the process-constant `TERMINAL.has_utf8`); larger cache
144
+ is a free warm-path win.
145
+ - **`_felt_segment_cached` LRU cache raised 2048 → 4096**
146
+ (`src/belote/ui/render.py`). Terminal resize creates fresh
147
+ `(width, mat_w)` axes without evicting old entries; the larger
148
+ cache absorbs typical resize churn without thrashing. Theme-change
149
+ invalidation already wired via `clear_card_cache`.
150
+ - **`_card_face_internal` LRU cache raised 2048 → 8192**
151
+ (`src/belote/ui/render.py`). The key space across 52 cards ×
152
+ selected/legal/layout/theme/utf8 axes was ~7488; the previous
153
+ 2048 cap evicted working-set cards on theme flip. New cap covers
154
+ the full matrix with ~600KB worst-case memory — negligible for
155
+ a desktop TUI.
156
+
157
+ ### Internal
158
+
159
+ - `partner_jokers_double` deprecation comment refreshed
160
+ (`src/belote/belatro/core/scoring.py`) — the "removal in 4.0"
161
+ target was missed; comment now notes the flag is kept for
162
+ back-compat with external test fixtures, scheduled for removal at
163
+ the next major bump.
164
+ - Pre-existing ruff (C420 dict comprehension, I001 import sort,
165
+ F401 unused import) and mypy strict-mode type-shadow errors in
166
+ the working tree's modified files cleared. `ruff check src/
167
+ tests/` and `mypy --strict src/belote/` are now both clean.
168
+
169
+ ### Verified false (catalogued for posterity)
170
+
171
+ Audit candidates that were flagged but verified against the code as
172
+ non-issues — re-investigate at your peril:
173
+
174
+ - "All-pass redeal leaks `coinche_level` / `declarations` /
175
+ `belote_holders`." False. `start_round()` is called via the
176
+ `phase == DEAL` branch in `gameflow.py` and resets all of them
177
+ via `reset_round_fields`.
178
+ - "L'Anarchie trump rotation loses rebelote." False.
179
+ `_compute_belote_points` prefers `state.belote_announcer` (the
180
+ captured seat at first-belote time) over the
181
+ `belote_holders.get(ctx.trump)` dict lookup.
182
+ - "Endless `_recent_boss_ids` grows unbounded." False. Explicitly
183
+ trimmed to the last 2 ids in `belatro/main.py::start`.
184
+ - "Joker dispatch double-rolls back log state." False. Two
185
+ distinct logs — accumulator's `self._log` (outer handler) and
186
+ `ledger.log` (inner `transactional()` context).
187
+ - "Free-planet reward re-applies on `BelAtroRun` reconstruction."
188
+ Misdirected. Only the `Profile` is persisted, not the live run
189
+ state; `__post_init__` runs once per session.
190
+
191
+ ### Test count baseline
192
+
193
+ **1066** (4.9.1 had 1064; +2 in 4.9.2 — both regression tests for
194
+ the `animate_score_update` short-circuit / skip paths added to
195
+ `tests/test_render_diff.py`).
196
+
197
+ ## [4.9.1] - 2026-05-25
198
+
199
+ User-feedback patch over 4.9.0. The casino theme didn't land and the U3
200
+ trick-collection sparkle animation cluttered the felt. Both addressed:
201
+
202
+ ### Changed
203
+
204
+ - **(U6 → user feedback) Theme swap: Monte Carlo Casino Night → Provence
205
+ Lavande** (`src/belote/themes.py`). The casino-dark-felt direction was
206
+ swapped for a sun-soaked Provençal countryside palette: soft lavender
207
+ felt, sun-parchment cards, terracotta backs + highlights, poppy red
208
+ ♥/♦, olive-noir ♠/♣, faded-indigo banners. Evokes outdoor table
209
+ Belote in a Provence summer — closer to Belote's French parlour-card
210
+ heritage. Theme count stays at 13.
211
+ - **(U3 → user feedback) Trick-collection animation rewritten: sparkle
212
+ glyphs → hybrid card convergence**
213
+ (`src/belote/ui/render.py::collect_trick_to_winner`). 4.9.0 painted
214
+ scattered `✦/✧/·` glyphs along straight paths from compass slots to
215
+ the winner; against the felt vignette this read as visually noisy
216
+ grey/gold flecks. 4.9.1 replaces it with a three-phase animation:
217
+ 1. **Faces converge to center** (3 frames, `ease_out_quad`): each of
218
+ the four trick cards slides from its compass slot inward.
219
+ 2. **Fade to a stack of backs at center** (1 frame): face cards
220
+ replaced by a small fan of `card_back_bg`-filled rectangles.
221
+ 3. **Stack slides to winner** (3 frames): the back-stack interpolates
222
+ from center to the winner's compass slot.
223
+
224
+ Total ~280 ms; skippable on any key; honors `BELOTE_NO_ANIM=1`.
225
+ Signature change: `collect_trick_to_winner(winner, trick, reader)` —
226
+ caller in `src/belote/gameflow.py` updated to pass
227
+ `display_state.current_trick`.
228
+
229
+ ### Internal
230
+
231
+ - `src/belote/ui/render.py` gains a `TrickCard` import (was already
232
+ imported transitively via `gameflow.py`; the new helper needs the
233
+ symbol directly).
234
+ - Test count baseline: **1064** (unchanged from 4.9.0).
235
+
236
+ ---
237
+
238
+ ## [4.9.0] - 2026-05-25
239
+
240
+ Bundled UI-polish + gameplay-correctness release. Six UI / feel
241
+ improvements (U1–U6) close the deferred items from 4.8.0 and add a 13th
242
+ theme; four gameplay items (G1–G4) surface coinche in classic mode, add
243
+ hard-AI signaling, make litige carryover visible, and add a thinking
244
+ spinner during AI delays. L2 (Belote Contrée full bidding) is scoped to a
245
+ later release. **Roadmap drift caught and corrected**: a `colorblind`
246
+ theme already existed at `themes.py:157`, so U6 ships a new Monte Carlo
247
+ Casino Night theme instead; no golden-frame test existed for hand-lift,
248
+ so U1 creates one.
249
+
250
+ ### Added
251
+
252
+ - **(U1) Card-lift on selection** (`src/belote/ui/render.py::_render_hand_horizontal`).
253
+ Selecting a card in the south hand visually rises it +1 row above its
254
+ neighbors. The renderer switches from a fixed `card_h`-row block to a
255
+ `card_h + 1`-row layout when `selection` is set; the highlight bar
256
+ follows the new bottom edge. Unselected hands keep the original
257
+ `card_h` shape (no regression on the no-selection render path).
258
+ - **(U2) Card lift doubles as selection-change transition.** Terminal
259
+ cells can't do sub-character vertical motion, so U2's "interpolated
260
+ slide" collapses into U1's lift itself — the new card visibly rises
261
+ on render, the old one drops. Documented in the lift code so future
262
+ contributors don't re-attempt a broken pixel-slide.
263
+ - **(U3) Trick-collection animation** (`src/belote/ui/render.py::collect_trick_to_winner`,
264
+ wired in `src/belote/gameflow.py:234-248`). When a trick ends, four
265
+ sparkle trails converge from the compass slots toward the winner's
266
+ position before `pulse_winner_glow` fires. Mirrors
267
+ `slide_card_to_table_hint`'s rules (absolute cursor moves,
268
+ `invalidate_diff()` in `finally`, interruptible via reader). ~150ms;
269
+ skipped under `BELOTE_NO_ANIM=1`.
270
+ - **(U4) Live score-formula contributor inline** (`src/belote/belatro/ui/hud.py`,
271
+ row 3). The BelAtro HUD's existing `chips × mult = total` line now
272
+ shows the most-recent joker contributor inline as `(+25 chips from
273
+ Le Banquier)`, sourced from `ScoreAccumulator._log[-1]`. Compact mode
274
+ unaffected (no row budget).
275
+ - **(U5) Inventory + Consumables unified** (`src/belote/belatro/ui/inventory.py`).
276
+ The V-key inventory screen is no longer read-only: pressing `1`–`9`
277
+ activates the Nth consumable directly, and pressing `A` on a
278
+ consumable's detail page does the same. The footer surfaces the
279
+ digit-activate path. The shop's `C` key still launches the numbered
280
+ `ConsumablesOverlay` (the two coexist: V is the unified screen, C is
281
+ the in-shop quick path).
282
+ - **(U6) Monte Carlo Casino Night theme** (`src/belote/themes.py`). 13th
283
+ theme — belle-époque casino: midnight purple-black felt, wine-red
284
+ suits on champagne-cream cards, gold-leaf highlights, burgundy
285
+ banners. Fills the glitzy-formal niche the existing 12 themes don't
286
+ cover. Pivoted away from the original "colorblind palette" plan
287
+ because a `colorblind` theme already ships.
288
+ - **(G1) Coinche / Surcoinche in classic mode** (`src/belote/gameflow.py::_run_coinche_flow`,
289
+ `src/belote/ui/prompts.py::prompt_coinche` / `prompt_surcoinche`,
290
+ `src/belote/ai.py::decide_coinche`, scoring multiplier in
291
+ `src/belote/scoring.py::apply_round_score`). After a bid locks,
292
+ defender side gets a `[C] coinche` prompt; if coinched, taker side
293
+ gets `[S] surcoinche`. New `coinche_level: int` field on `GameState`
294
+ (0/1/2) drives a `2^level` multiplier applied to the winning team's
295
+ credit (`is_failed` decides which side wins). AI heuristic
296
+ (HARD-only): coinche if holding the trump Jack OR 3+ trumps. Resets
297
+ per round via `reset_round_fields`.
298
+ - **(G2) AI signaling convention (peter, HARD tier)** (`src/belote/ai.py`).
299
+ Hard AI's forced off-suit non-trump discards may swap rank within the
300
+ zero-point set (7/8/9) to signal partner: 9 = "lead this suit back",
301
+ 7 = "don't bother", 8 = neutral. New `AIMemory.signals: dict[Suit, int]`
302
+ and `signals_emitted: int` (capped at 2/round so the convention
303
+ doesn't sacrifice all rank flexibility). Signals are read in
304
+ `_process_trick_signals` from completed tricks only (transient
305
+ current-trick reads would update mid-decision). `_hard_lead` prefers
306
+ a signaled suit before falling back to the existing void-based bias.
307
+ - **(G3) Litige carryover HUD chip** (`src/belote/ui/render.py::_build_hud`).
308
+ Compact and verbose HUD modes now render `Lit:162` / `Litige: 162`
309
+ in dim gold when `state.litige_points > 0`. Render-layer only — no
310
+ rules change; the pool is team-neutral and accrues until the next
311
+ fulfilled contract absorbs it (`scoring.py::_score_normal_outcome`).
312
+ - **(G4) AI thinking spinner** (`src/belote/ui/anim.py::interruptible_sleep_with_spinner`,
313
+ wired in `src/belote/gameflow.py:220-226`). A rotating braille glyph
314
+ paints in the top-right of HUD row 1 with the seat initial during AI
315
+ card-play delays. Skipped if `duration < 0.15` (instant mode) or
316
+ `BELOTE_NO_ANIM=1`. Self-cleaning + `invalidate_diff()` in `finally`.
317
+
318
+ ### Tests
319
+
320
+ - New: `tests/test_render_hand_lift.py` (4 tests) — pins U1's row-count
321
+ contract: `card_h` rows without selection, `card_h + 1` cards + bar
322
+ with selection, `+1` more row when `show_readout=True`.
323
+ - New: `tests/test_g1_coinche_classic.py` (8 tests) — coinche multiplier
324
+ doubles/quadruples the winning side, defender gets the multiplier on
325
+ fail, `coinche_level` resets between rounds, HARD-AI heuristic gates.
326
+ - New: `tests/test_g2_ai_signals.py` (8 tests) — signal classification
327
+ (9/7/8), trump discards excluded, easy AI ignores signals, lead-bias
328
+ prefers signaled suit, emit-cap enforces 2/round.
329
+ - Test count baseline: **1064** (4.8.2 had 1044; +20 in 4.9.0).
330
+
331
+ ### Internal / Doc
332
+
333
+ - New plan file `~/.claude/plans/ui-feel-squishy-moonbeam.md` documents
334
+ the bundled design and three roadmap claims verified false during
335
+ exploration: no golden-frame test for hand rendering, the existing
336
+ `colorblind` theme, and the coinche int-flag vs enum discrepancy.
337
+
338
+ ---
339
+
340
+ ## [4.8.2] - 2026-05-25
341
+
342
+ Audit-driven correctness + perf-hygiene release. A deep multi-lane audit
343
+ (bug hunt + logic + performance + game-mechanics-implementation review)
344
+ surfaced two real Sans Atout AI bugs, a joker-registry footgun, a
345
+ joker-state alias hazard, and a handful of low-severity loose ends. All
346
+ findings landed; no rules changes; AI play is more accurate under SA.
347
+
348
+ ### Fixed
349
+
350
+ - **(B1, HIGH) Hard AI no longer mis-scores off-suit cards as "winning" under
351
+ Sans Atout** (`src/belote/ai.py::_score_winning_strategy`). Pre-4.8.2 the
352
+ candidate's rank was `_NONTRUMP_ORDER[card.rank]` regardless of suit, while
353
+ `highest_rank` was computed from lead-suit cards only. An off-suit Ace
354
+ (rank 7) compared against a lead-suit Jack (rank 3) fired `win_bonus` even
355
+ though `_card_beats` (game.py) forbids off-suit cards from winning under SA.
356
+ The AI burned high off-suit cards when forced to discard. Fix: pin `rank=-1`
357
+ for off-suit candidates so `rank > highest_rank` is unreachable.
358
+ - **(B2, MED) 2-ply opponent-trump-fear penalty suppressed under SA**
359
+ (`src/belote/ai.py::_score_winning_strategy`). The penalty assumes opponents
360
+ can trump the winner. Under SA `trump is None`, so the conditions
361
+ `trump not in opp_voids` (always True for `None`) and
362
+ `card.suit != trump` (always True) both passed — the AI penalized any
363
+ winning play by -8 when the right-hand opponent was known void in lead.
364
+ Wrapped in `if not is_sa:` so the entire block is moot under SA.
365
+ - **(B3, MED) `ScoreAccumulator._handler_index` rebuilds on `_jokers` mutation**
366
+ (`src/belote/belatro/core/scoring.py::_fire_jokers`). Pre-4.8.2 the lazy
367
+ gate was `not self._handler_index and self._jokers`, which is False once
368
+ `attach_jokers([])` ran (the index has 5 truthy keys with empty lists).
369
+ Tests that appended to `_jokers` after the empty attach were silently
370
+ ignored. Fix: identity-set comparison (`_attached_joker_ids`) against the
371
+ current `_jokers` list — any drift triggers `attach_jokers(self._jokers)`.
372
+ - **(B4, MED) `state._joker_state` alias preserved across post-trigger
373
+ mutations** (`src/belote/belatro/engine/round_driver.py`). The post-
374
+ `trigger_round_start` agent_double sabotage block used
375
+ `replace(state, _joker_state={...})`, which broke the ledger alias and
376
+ forced the heist block into a defensive dual-write. Replaced with in-place
377
+ mutation; the heist dual-write is gone too. Classic-Belote reads via
378
+ `state._joker_state.get(...)` once again see live ledger mutations for the
379
+ rest of the round.
380
+ - **(B5, LOW) `_compute_belote_points` asserts the announcer↔tracker
381
+ invariant** (`src/belote/scoring.py`). Hand-built states with
382
+ `belote_announcer` set but `belote_tracker[0]=False` would silently award
383
+ belote points; now raises AssertionError.
384
+ - **(B6, LOW) `ContractReward.honor_bonus` default switched to `int 0`**
385
+ (`src/belote/belatro/core/scoring.py`). Default was `0.0` (float) but the
386
+ TypedDict declared `int` — worked by numeric duck-typing but drifted from
387
+ the contract. No behavior change.
388
+
389
+ ### Performance
390
+
391
+ - **(P1) Hard AI rank lookups consolidated** (`src/belote/ai.py::_hard_play`).
392
+ Pre-4.8.2 `_score_winning_strategy` called `trick_rank(card, trump, ...)`
393
+ once per candidate plus once per visible partner card — O(legal ×
394
+ partner_hand) recomputations. Now `_hard_play` builds `candidate_ranks`
395
+ and `partner_ranks` once, threaded through `_score_card_play` and
396
+ `_score_winning_strategy` as new kwargs. The SA off-suit `-1` short-circuit
397
+ from B1 is baked into the cache, so the canonical comparison is a dict
398
+ lookup.
399
+ - **(P2) Dropped redundant `from collections import Counter` local in
400
+ `_hard_play`** — `Counter` is already imported at module top.
401
+
402
+ ### Internal / Doc
403
+
404
+ - **(L1) `GameState._joker_state` docstring spells out the BelAtro
405
+ ledger-alias contract** (`src/belote/game.py`). After
406
+ `trigger_round_start` the dict is shared with `acc._ledger.joker_state`;
407
+ round-driver mutates in place to preserve the alias. Classic callers
408
+ outside that scope still use `replace()`.
409
+ - **(L2) `UnlockTracker.on_event` logs at DEBUG on unhandled event types**
410
+ (`src/belote/belatro/progression/unlocks.py`). Silent no-op was the
411
+ contract, but a new unlock keyed off `TrickWonEvent` or `BeloteAnnouncedEvent`
412
+ would silently fail — the log line makes the miss visible under
413
+ `BELOTE_LOG_LEVEL=DEBUG`.
414
+ - **(L3) `AIMemory.last_partner_hand_key` includes `hide_partner_hand`**
415
+ (`src/belote/ai.py`). Memo invalidates if the boss flag flips mid-round.
416
+ Defensive — no boss toggles it today, but a future voucher/joker that
417
+ changes visibility mid-round would otherwise see stale cached partner
418
+ cards.
419
+
420
+ ### Tests
421
+
422
+ - **New file `tests/test_ai_sans_atout.py`** (3 tests) — pins B1 (off-suit
423
+ Ace not burned), B1 unit-level (no `win_bonus` for off-suit candidate),
424
+ B2 (2-ply penalty suppressed under SA).
425
+ - **New file `tests/test_ai_deluge_voids.py`** (4 tests) — pins void
426
+ inference under La Déluge alone, Le Républicain alone, and both
427
+ combined; plus the negative case (non-wild off-lead card still proves
428
+ void under Le Républicain).
429
+ - **New file `tests/belatro/test_handler_registry.py`** (3 tests) — B3
430
+ regression pins: `attach_jokers([])` + append, attach + replace, and the
431
+ no-drift no-rebuild sanity check.
432
+ - **New file `tests/belatro/test_competition_malediction.py`** (3 tests)
433
+ — documents the actual stacking behaviour of La Compétition + La
434
+ Malédiction under `score_round`.
435
+ - **Extended `tests/test_belote.py`** with B5 assertion test.
436
+ - **Extended `tests/belatro/test_phase1_plumbing.py`** with B4 alias
437
+ survival test.
438
+ - **Extended `tests/test_bidding_all_pass.py`** with end-to-end litige
439
+ pool consume after two all-pass redeals (T1).
440
+ - Test count baseline: **1044** (4.8.1 had 1028; +16 in 4.8.2).
441
+
442
+ ### Speculative perf (measured, not implemented)
443
+
444
+ Two speculative items from the plan (P4 `legal_cards` key-build cost; P5
445
+ `ledger.transactional` wrapper overhead) were instrumented and closed.
446
+ Measurement: legal_cards is ~3 % of round time (below the 5 % threshold to
447
+ justify a hand-id-on-tuple refactor); BelAtro round mean is 13.69 ms /
448
+ p95 18.49 ms (73 rounds/sec) — well below any user-visible jank threshold.
449
+
450
+ ## [4.8.1] - 2026-05-21
451
+
452
+ Maintenance release: outcome of a thorough multi-lane audit (logic / performance / state management, then four deeper passes). Audit headline: **zero critical or high-severity bugs** survived verification. The two micro-fixes below are pure consistency / micro-optimization. No rules, scoring, AI behavior, or UI behavior changes — `belatro` and `belote` play exactly the same.
453
+
454
+ ### Changed
455
+
456
+ - **`BelAtroAnnounce.boss_reveal` harmonized onto the post-4.8.0
457
+ `reader.read_timeout(...)` pattern** (`src/belote/belatro/ui/announce.py`).
458
+ Pre-4.8.1 used `interruptible_sleep(N, reader)` at three sites, which
459
+ routes through a module-level `select.select` call that doesn't go
460
+ through the `KeyReader` interface. `banner()` (just below `boss_reveal`
461
+ in the same file) was already on the new pattern; this aligns
462
+ `boss_reveal` so test stubs can interpose at the reader interface
463
+ without monkey-patching `belote.input.interruptible_sleep`. Runtime
464
+ behavior is byte-identical — any key still skips the reveal.
465
+ - **`AIPlayer._medium_play` precomputes `trick_rank` once per decision**
466
+ (`src/belote/ai.py`). The pre-4.8.1 code recomputed `trick_rank(c,
467
+ trump, self._se)` inside each `min(...)`/`max(...)` walk plus inside
468
+ the void-trump "winners" filter — five+ recomputations per AI play.
469
+ Now a single `ranks = {c: trick_rank(c, trump, self._se) for c in
470
+ legal}` is built up-front and reused via `ranks.__getitem__` and
471
+ `ranks[c]` lookups. Saves roughly 1200 `trick_rank` calls per round
472
+ (~60 µs) and reads cleaner. Decision output is unchanged.
473
+
474
+ ### Internal
475
+
476
+ - Audit found and documented six **verified-false** findings from agent
477
+ passes (LeNotaire/LeRebelle `is_rebelote` gate, ToutStreak streak
478
+ persistence, `pulse_winner_glow` banner persistence, Libra + auto_coinche
479
+ on chute, AI `_hard_play` SA-trump condition, `boss_reveal` print()
480
+ scrolling). All re-investigated against current code and confirmed
481
+ correct as designed. Plan file
482
+ `/home/mrrobot/.claude/plans/bug-hunt-code-performance-greedy-perlis.md`
483
+ has the full list so future audits don't re-raise them.
484
+ - Test count baseline unchanged: **1028**. No new tests; the changes are
485
+ behavior-preserving refactors and the existing suite covers them
486
+ (`test_ai.py` for the AI path, `test_phase2_content.py` for joker
487
+ flow; `boss_reveal` is exercised indirectly through ante setup).
488
+
489
+ ## [4.8.0] - 2026-05-21
490
+
491
+ Feature release: a twelfth theme, a coordinated animation polish pass for
492
+ BelAtro, and a tactile feel pass for classic-mode Belote. All additions are
493
+ UI-only — zero rules / scoring / AI changes. Every new helper follows the
494
+ established `try / finally invalidate_diff()` architectural rule (4.6.4 /
495
+ 4.0.0). New `BELOTE_NO_ANIM=1` env-var kill switch short-circuits every new
496
+ animation to its end-state for slow terminals or scripted runs.
497
+
498
+ ### Added
499
+
500
+ - **Twelfth theme: Sunset Magma** (`src/belote/themes.py::sunset_magma`).
501
+ Deep-magenta felt, coral suits, smoky-purple "blacks", amber-sunset
502
+ highlights, magenta-on-cream banners. Fills the warm orange/magenta niche
503
+ none of the existing 11 themes occupied. Cycles via the `T` key and
504
+ shows up in the main-menu theme selector automatically — no other code
505
+ changes needed because `ThemeManager.set_current` validates dynamically.
506
+ - **Shared animation toolkit** (`src/belote/ui/anim.py`). New module with
507
+ three easing helpers (`ease_out_quad`, `ease_in_out_quad`,
508
+ `ease_out_cubic`) and three painted-frame helpers (`pulse_text`,
509
+ `float_text`, `tick_bar`). Every painted helper ends with
510
+ `invalidate_diff()` in `finally`; every interruptible delay routes
511
+ through `reader.read_timeout(...)` so test stubs (and the existing
512
+ `slot_machine_tally` idiom) work uniformly. Env switch
513
+ `BELOTE_NO_ANIM=1` short-circuits to the end-state, paired with
514
+ `_refresh_animations_enabled_from_env()` for test fixtures.
515
+ - **(BelAtro / B1) Joker trigger callouts in the slot-machine tally**
516
+ (`belatro/ui/announce.py`). Per-trick log entries added by
517
+ `ScoreAccumulator._log` (joker firings, planet/Carnet/Architecte
518
+ attributions) now float above the bucket row as labelled callouts
519
+ (`⚡ Foo: +25 chips` / `✦ Bar: ×2.5 Mult` / `$ Architecte: +$2 …`).
520
+ New module-level `_last_log_count` cursor + `_classify_callout` /
521
+ `_emit_callouts` helpers; reset by `reset_tally_state()`. Capped at 4
522
+ callouts per trick to keep the moment under ~600 ms.
523
+ - **(BelAtro / B2) Shop purchase and reroll feedback**
524
+ (`belatro/ui/shop.py`). New `_animate_purchase(idx, num_items, money_before,
525
+ money_after)` (gold pulse on the bought slot + `tick_bar` money tick-down)
526
+ and `_animate_reroll(num_items)` (pulsed "↻ rerolling..." cue before the
527
+ new inventory paints). Hooked from the existing `run()` loop without
528
+ changing shop state semantics.
529
+ - **(BelAtro / B3) Target-crossing celebration**
530
+ (`belatro/ui/announce.py::_emit_target_celebration`). Fires once per
531
+ round on the first tally where the running total crosses
532
+ `acc.target_score`: gold pulse on the odometer line plus a `★ TARGET ★`
533
+ floater above it. The existing 1.2× flame row is unchanged.
534
+ - **(BelAtro / B4) Trust-bar tick-up animation**
535
+ (`belatro/ui/trust_bar.py::TrustBar.animate_change`). After the
536
+ round-end trust mutations in `belatro/main.py` (`blind_beaten` /
537
+ `blind_failed` / `big_margin_win` / `chute` / `capot_together`), the bar
538
+ animates from the pre-round value to the post-round value via `tick_bar`.
539
+ Snapshot taken right after `trust = self.run.partner.trust`; animation
540
+ fires only when `trust.value != pre_trust_value`.
541
+ - **(Classic / C3) Tactile play-trail on SOUTH plays**
542
+ (`belote/ui/render.py::slide_card_to_table_hint`). A brief vertical
543
+ sparkle trail (`✦/✧/·`) from the south hand toward the south trick
544
+ slot, painted just before the played card lands via `patch_trick_card`.
545
+ AI plays are unchanged. Self-cleaning trail; ~120 ms, skippable.
546
+ - **(Classic / C4) Trick-winner glow + hold**
547
+ (`belote/ui/render.py::pulse_winner_glow`). After all four cards land
548
+ and the MIN_TRICK_DWELL pause completes, a 3-frame gold→white→gold pulse
549
+ on the bottom hint row announces `★ <Direction> wins the trick ★`
550
+ before the trick sweeps. Computed via `trick_winner_seat` (Rupture
551
+ swing takes effect at scoring, so the on-table label stays accurate).
552
+ - **(Classic / C5) Dramatic centered Belote / Rebelote stinger**
553
+ (`belote/ui/announce.py::belote_stinger`). Replaces the modest
554
+ one-line `announce("Belote!")` / `announce("Rebelote!")` call with a
555
+ 4-row centered banner framed in `╔═╗║╚═╝` chars, painted in the active
556
+ theme's `banner_bg()` + `gold_fg()`. Falls back to the slim
557
+ `announce()` path on terminals narrower than 32 columns. Routed through
558
+ `gameflow.py`'s existing `current.announced` dispatch.
559
+
560
+ ### Internal
561
+
562
+ - `belote/ui/__init__.py` now re-exports `belote_stinger`,
563
+ `pulse_winner_glow`, and `slide_card_to_table_hint` alongside the
564
+ existing UI surface.
565
+ - Test count baseline: **1028** (4.7.3 had 1007; +21 in 4.8.0). New files:
566
+ `tests/test_anim_helpers.py` (8), `tests/test_belote_stinger.py` (2),
567
+ `tests/test_winner_glow.py` (5), `tests/belatro/test_joker_callouts.py`
568
+ (5), `tests/belatro/test_shop_animations.py` (2). All anim tests use
569
+ `sys.modules["belote.ui.render"]` to bypass the `belote.ui` re-export
570
+ shadow (per the 4.0.0 architectural note).
571
+ - **Deferred from this release:** the original plan included a
572
+ "card lift on selection" (C1) and "smooth highlight slide between cards"
573
+ (C2) for the south-hand cursor. Both require deeper changes to the
574
+ multi-row horizontal hand renderer than fit cleanly in the same release
575
+ as the new toolkit; tracked for a follow-up. The remaining tactile
576
+ effects (C3 trail, C4 winner glow, C5 stinger) ship as planned.
577
+
578
+ ## [4.7.3] - 2026-05-21
579
+
580
+ Patch release: targeted bug-hunt + performance + code-logic audit. Three
581
+ parallel exploration passes (classic engine, BelAtro layer, UI/render)
582
+ surfaced ~30 candidate findings; verifying each against the live code
583
+ rejected the false positives (deck.py "deluge scores 0", LePasseur "missing
584
+ re_emit guard", show_rules "missing invalidate on scroll", show_history
585
+ "missing term_h in cache key", legal_cards "missing trick_rank hoist") and
586
+ shipped only the verified-true delta. All baselines green: `ruff` 0
587
+ violations, `mypy --strict` 0 errors, `pytest` 1007/1007.
588
+
589
+ ### Fixed
590
+
591
+ - **(Bug) `announce()` did not invalidate the render-diff baseline.** The
592
+ function paints a transient banner with absolute cursor positioning,
593
+ bypassing `display()`. Without a post-paint `invalidate_diff()` the next
594
+ `display()` diffed against `_last_emitted_lines` (which has no record of
595
+ the banner) and could leave the banner visible as a ghost on the bottom
596
+ row. Same architectural rule as `show_help` / `show_history` /
597
+ `show_rules` / `show_card_detail` / `show_round_summary` /
598
+ `animate_score_update`. `announce()` was the last unfixed site of the
599
+ 4.0.0 / 4.6.4 finally-pattern sweep. Pinned by
600
+ `test_announce_invalidates_diff_baseline` in `tests/test_render_diff.py`.
601
+ - **(Bug) L'Infiltré × La Déluge interaction.** The `ghost_lead` deck rule
602
+ paid `+2 Mult / +$1` when NS won a trick by playing trump on a "non-trump
603
+ lead". The is-trump-lead check at `belatro/core/scoring.py:414-417`
604
+ considered only `lead_suit == event.trump` and the TOUT_ATOUT case —
605
+ it did not honour `seven_eight_trump` (La Déluge), so a 7-led or 8-led
606
+ trick was incorrectly treated as a non-trump lead and the bonus could
607
+ fire even though the lead was effectively trump. Pinned by
608
+ `test_ghost_lead_silent_when_lead_is_seven_under_deluge` in
609
+ `tests/belatro/test_decks_4_5.py`.
610
+ - **(Defensive) `LeDemon.on_purchase` is now idempotent.** Re-running the
611
+ hook on an already-owned joker (a future save/load round-trip or replay-
612
+ resume tool) would have compounded the trust subtraction. New
613
+ `_applied_purchase_ids: set[str]` field on `BelAtroRun` (mirrors
614
+ `_applied_voucher_ids` from 3.9.3) short-circuits the second call. Pinned
615
+ by `test_le_demon_on_purchase_is_idempotent` in
616
+ `tests/belatro/test_joker_contracts.py`.
617
+
618
+ ### Changed
619
+
620
+ - **AI comment about La Déluge corrected** (`src/belote/ai.py:293-296`).
621
+ The pre-4.7.3 comment claimed "promotes 7s/8s of trump above the Jack" —
622
+ but `deck.py::trick_rank` puts the 7 at rank 8 and the 8 at rank 9
623
+ (the two LOWEST trumps, scoring 0). The boss description in
624
+ `boss.py:95` ("become trump") matches the code, not the comment. The
625
+ comment now states the actual behaviour so future maintainers don't
626
+ chase a phantom bug.
627
+ - **`render_joker_pip_strip` / `render_synergy_tooltip` split into
628
+ builders + writers** (`belatro/ui/hud.py`). Pre-4.7.3 each helper did
629
+ its own `sys.stdout.write + flush` outside `BelAtroHUD._render`'s
630
+ batched parts list, costing 2–3 syscalls per HUD refresh instead of
631
+ one. New `build_joker_pip_strip` / `build_synergy_tooltip` return
632
+ strings; the legacy `render_*` wrappers (used by direct callers and
633
+ tests) still exist for backward compatibility. `BelAtroHUD.render`
634
+ and `_render_compact` now embed both builders into a single batched
635
+ write. Pinned by `test_belatro_hud_render_writes_once` in
636
+ `tests/belatro/test_hud_toggle.py`.
637
+
638
+ ### Performance
639
+
640
+ - **`_get_card_face` reads `_cached_theme_name` instead of
641
+ `theme_manager.current_name`** (`belote/ui/render.py:314`). The module
642
+ already caches the theme name (line 84) and refreshes it via the theme
643
+ callback (line 95); `_get_card_face` is called ~52 times per game
644
+ frame, so eliminating the per-call property lookup shaves a few
645
+ microseconds off the render budget. The cache is kept in sync via the
646
+ existing theme-change callback. `clear_card_cache` ensures the card
647
+ face cache is reset on every theme change, so reading the cached name
648
+ is safe.
649
+
650
+ ### Internal
651
+
652
+ - New `BelAtroRun._applied_purchase_ids: set[str]` field for joker
653
+ on_purchase idempotency (analogous to `_applied_voucher_ids`). Empty
654
+ by default; populated lazily by corrupted jokers with non-idempotent
655
+ on_purchase actions.
656
+ - `build_joker_pip_strip(...) -> str` / `build_synergy_tooltip(...) -> str`
657
+ public builder API in `belatro/ui/hud.py`; `render_joker_pip_strip` /
658
+ `render_synergy_tooltip` retained as thin wrappers around the builders.
659
+
660
+ ### Test count baseline
661
+
662
+ - 1007 (4.7.2 had 1003; +4 in 4.7.3 across `test_render_diff.py`,
663
+ `test_decks_4_5.py`, `test_joker_contracts.py`, `test_hud_toggle.py`).
664
+
665
+ ## [4.7.2] - 2026-05-20
666
+
667
+ Patch release: external-model audit verification pass. A prior audit (pasted
668
+ from another LLM) flagged ~30 issues across bugs / perf / quality. Verifying
669
+ each claim against the live code rejected the false positives (including one
670
+ HIGH-severity claim — "160 event dispatches per round" — that the model
671
+ invented out of whole cloth) and shipped only the genuinely confirmed
672
+ findings. Two real correctness gaps the prior audit missed were caught via
673
+ fresh-additions review. All baselines green: `ruff` 0 violations,
674
+ `mypy --strict` 0 errors, `pytest` 1003/1003.
675
+
676
+ ### Fixed
677
+
678
+ - **(Bug) Malédiction (`invert_scoring`) 4-4 trick tie.** Pre-fix the boss
679
+ flag zeroed the team that won MORE tricks, but a 4-4 split fell through
680
+ both branches and neither side was zeroed — both teams kept their full
681
+ scores under the curse. Rule chosen: the taker is zeroed (the cursed
682
+ side failed to break the tie). `scoring.py::_apply_invert_scoring` adds
683
+ an `else` branch; pinned by
684
+ `test_boss_invert_scoring_4_4_tie_zeros_taker` in
685
+ `tests/belatro/test_boss_modifiers_integration.py`.
686
+
687
+ ### Changed
688
+
689
+ - **`bm: object` → `bm: BossModifiers`** on `_trick_zeroed_by_ban_clubs`,
690
+ `_card_points_with_zero_ranks`, `card_points_with_modifiers`, and
691
+ `_trick_points_with_modifiers` (`scoring.py`). All four helpers were
692
+ typed as `object` with `getattr(bm, "ban_clubs", False)` defensive
693
+ lookups; the actual callsites always pass `BossModifiers`. Now type-
694
+ safe attribute access — `mypy --strict` enforces every new field is
695
+ spelled correctly.
696
+ - **`UICallbacks` extracted from `BelAtroGame._play_blind`** to a module-
697
+ top class in `belatro/main.py`. The nested class spanned 189 lines and
698
+ captured 8 closure variables (`run`, `profile`, `save_manager`, `hud`,
699
+ `acc`, `trust_bar`, `show_north`, plus a dead-write `last_display_state`
700
+ cell). Now an explicit constructor; `last_display_state` removed
701
+ (write-only, never read).
702
+ - **Litige pool carry across all-pass redeals is now documented.** Added
703
+ a `reset_round_fields` docstring entry calling out that `litige_points`
704
+ is deliberately NOT in the reset list — the pool survives across
705
+ rounds (including all-pass redeals) until a non-litige scoring round
706
+ consumes it. Pinned by
707
+ `test_litige_pool_survives_all_pass_redeal` +
708
+ `test_litige_pool_survives_two_consecutive_all_pass_redeals` in
709
+ `tests/test_bidding_all_pass.py`.
710
+
711
+ ### Performance
712
+
713
+ - **`detect_sequences` is now `@lru_cache(128)`.** Microbench placed it
714
+ at ~25% of `score_round` cost when `initial_hands` is populated, and
715
+ `get_declarations` runs the same 4 hand tuples through the helper
716
+ twice per round (once at bid-acceptance time in `game.py`, once during
717
+ `score_round` itself). Cache hits eliminate the second pass.
718
+ `score_round` macrobench: 300 µs → 227 µs (~24% on the full path).
719
+ Cards are frozen dataclasses (hashable); all callers treat the
720
+ returned list as read-only.
721
+
722
+ ### Tests strengthened
723
+
724
+ - **`test_last_trick_bonus_applied`** (`tests/test_belote.py`) → split
725
+ into `test_capot_subsumes_last_trick_bonus` (asserts the actual
726
+ `CAPOT_BASE` total) and new `test_last_trick_bonus_applied_in_normal_round`
727
+ (deterministic 4-NS/4-EW non-capot fixture proving the +10 dix-de-der
728
+ actually lands on the taker side). Previously only asserted `is_capot
729
+ is True` — the +10 mechanic the test name promised was untested.
730
+ - **`test_non_capot_points_sum_162`** (`tests/test_belote.py`) →
731
+ conditional `if not breakdown.is_capot:` replaced with explicit
732
+ `assert not breakdown.is_capot` plus the unconditional conservation
733
+ check. The old form passed vacuously if `make_deck()` ordering ever
734
+ produced a capot.
735
+
736
+ ### Internal
737
+
738
+ - **Contract→word a11y mapping deduplicated** to a `_contract_word(state)`
739
+ helper in `gameflow.py`. Two near-identical 8-line blocks collapsed
740
+ into one helper call each (bid→play transition; round-result speak).
741
+
742
+ ### Documentation
743
+
744
+ - README test count claims and CHANGELOG / DEVELOPMENT references
745
+ updated 999 → 1003.
746
+
747
+ ### Verified-false audit claims (re-investigate at your peril)
748
+
749
+ For the record so the next audit cycle doesn't relitigate items the
750
+ external model flagged but the code disagreed with:
751
+
752
+ - `partner()` if-chain vs modular arithmetic — style only, not a bug.
753
+ - `start_round` "doesn't reset belote_holders" — self-refuted (line 308
754
+ passes `belote_holders={}`).
755
+ - Score-animation labels "confusing" — `gameflow.py:458` already labels
756
+ by `breakdown.taker_team`.
757
+ - `_empty_breakdown` taker_team default — only returned on contract-
758
+ inactive states; harmless.
759
+ - `BelAtroRun.consume()` "removes before use" — wrapped in
760
+ `contextlib.suppress(ValueError)`, semantically correct.
761
+ - `_bonus_money` "race" — Le Fantôme writes to `acc._ledger.money`
762
+ directly since 4.6.2; main.py only consumes seal_round-routed amounts.
763
+ - "160 event dispatches per round" — false. Jokers subscribe to
764
+ `TrickWonEvent` / `BeloteAnnouncedEvent` / `DeclarationScoredEvent`
765
+ only; per-card emit doesn't iterate jokers. Real count is ~85-100
766
+ dispatches per round and they're already O(jokers × events).
767
+ - LeRebelle / RebeloteEcho / TierceCharger ungated under boss flags —
768
+ verified gated. `game.py:907` short-circuits `BeloteAnnouncedEvent`
769
+ under `no_belote`; `round_driver.py:467-473` documents
770
+ TierceCharger's intentional un-gating; LeMathématicien / QuinteRoyale
771
+ read `event.points` which Le Mime forces to 0.
772
+
773
+ ## [4.7.1] - 2026-05-19
774
+
775
+ Patch release: post-4.7.0 audit pass that found two latent correctness
776
+ foot-guns and three concrete AI hot-path wins, then reconciled the
777
+ documentation surfaces with the actual code. Three parallel Explore-agent
778
+ audits (core engine, BelAtro mode, performance) surfaced ~20 candidate
779
+ issues; the seven false-positives were rejected against the live code
780
+ and only the verified findings shipped. All baselines green: `ruff` 0
781
+ violations, `mypy --strict` 0 errors, `pytest` 999/999.
782
+
783
+ ### Fixed
784
+
785
+ - **(Major) Sans Atout partner-hand rank comparison.** `_score_winning_strategy`
786
+ computed the local `rank` via `_NONTRUMP_ORDER[card.rank]` under SA
787
+ (correct), but the partner-hand penalty block at the bottom of the
788
+ function unconditionally called `trick_rank(card, trump, self._se)` with
789
+ `trump=None` for BOTH the candidate card and every partner card.
790
+ `trick_rank(card, None, ...)` happens to collapse to the same ordering
791
+ as `_NONTRUMP_ORDER` today, so the bug is benign-by-coincidence — but
792
+ any future tweak to `trick_rank`'s SA branch (e.g. honor-scaling)
793
+ would silently break the partner-hand heuristic without any test
794
+ catching it. Now mirrors line 728's `if is_sa` branch on both rank
795
+ computations. Pinned by `test_winning_strategy_partner_hand_uses_nontrump_order_under_sa`
796
+ in `tests/test_ai.py`, which poisons `trick_rank` and confirms the
797
+ SA branch never touches it.
798
+ - **(Minor) `prompt_card` silently substituted `hand[0]` on empty
799
+ `legal_cards`.** Belote's suit-follow / overtrump rules guarantee at
800
+ least one legal card whenever the hand is non-empty, so the path is
801
+ unreachable today — but the `return hand[0], state` fallback would
802
+ let an illegal card through to `play_card` if `legal_cards` ever
803
+ regressed. The AI path (`ai.py::decide_card`) already raises on the
804
+ same precondition; the human-input path now matches. Pinned by
805
+ `test_prompt_card_raises_on_empty_legal_cards`.
806
+
807
+ ### Performance
808
+
809
+ - **Hoisted `highest_rank` and `opp_voids` out of the per-card scoring
810
+ loop in `_hard_play`.** Both depend only on `(trick, trump, is_sa,
811
+ self._se)` and the next-opp seat — all constant across the 1–8 legal
812
+ candidates being scored. Pre-fix they were recomputed inside
813
+ `_score_winning_strategy` per card. Now precomputed once and threaded
814
+ through `_score_card_play` → `_score_winning_strategy` as kwargs with
815
+ `None` defaults so standalone callers (tests, future ad-hoc
816
+ heuristics) still work. ~8–15% on hard-AI card decisions.
817
+ - **Plumbed `points` through `_score_leading_strategy`.**
818
+ `_score_card_play` already computes `points = card_points_with_modifiers(card, trump, bm)`,
819
+ but the leading-strategy helper re-invoked the function to detect waste
820
+ cards. Now the helper accepts a keyword-only `points: int` and reuses
821
+ the caller's value. ~3–5% on lead decisions.
822
+
823
+ ### Changed
824
+
825
+ - `tests/test_ai.py` test count: 22 → 24 (the two new pins above).
826
+ Total suite: 997 → **999**.
827
+
828
+ ### Documentation
829
+
830
+ - `README.md` — test-count claims updated 997 → 999 in three places
831
+ (project-structure block, "Running Tests" section, "Technical
832
+ Integrity" bullet list). Joker/voucher/planet/tarot/boss/deck counts
833
+ reverified against `registry` / `ALL_BOSS_MODIFIERS` / `STARTING_DECKS`
834
+ (42 / 12 / 8 / 12 / 21 / 12) — all already accurate at 4.7.0, no
835
+ change.
836
+ - `DEVELOPMENT.md` — code-quality command block now mentions 999 tests.
837
+ Release process now explicitly calls out that `pyproject.toml` AND
838
+ `src/belote/__init__.py` must be bumped together (they had been
839
+ drifting in older release attempts; `belote --version` /
840
+ `belatro --version` read `__version__` while PyPI / pipx read
841
+ `pyproject.toml`).
842
+
843
+ ## [4.7.0] - 2026-05-19
844
+
845
+ Feature minor: ships the **Dix de Der Heist** (BelAtro roguelite gamble keyed off `economy.interest_rate`) and the **slot-machine score tally** that replaces the static per-trick popup with a rolling odometer animation. Includes a full 4.6.5-style multi-agent audit (six parallel Explore agents covering heist correctness, slot-machine boss safety, items integration, save/load, performance, and 21-boss crash safety) and the resulting fixes, plus a hands-on polish pass — heist-discoverability hint, persistent tally in HUD, and a new V-key inventory overlay. All baselines green: `ruff` 0 violations, `mypy --strict` 0 errors, `pytest` 997/997.
846
+
847
+ ### Added
848
+
849
+ - **Dix de Der Heist (BelAtro)** — Player-only roguelite gamble declared at the start of trick phase. When SOUTH is the taker and `economy.interest_rate > 0` (gated; default rate = 0 = no reward = no prompt), the player can declare a heist that resolves on trick 8:
850
+ - On NS win: `ledger.mult *= (1 + interest_rate)` (`×2` with La Voûte, up to `×4` stacked with interest-bumping tarots).
851
+ - On NS loss: the card_points NS accumulated in tricks 1–7 are subtracted from `ledger.chips`. Declarations / belote / permanent / joker bonuses preserved.
852
+ - The multiplier resolves BEFORE `_fire_jokers("on_trick_won", event)` on trick 8 so jokers like LeDernierMot (×2) and LExecuteur (×1.5) compose on top. Documented in `belatro/core/scoring.py` to lock the composition rule in for future readers.
853
+ - State on `joker_state` as three scalar keys (`heist_declared`, `heist_ns_trick_chips`, `heist_outcome`) seeded fresh by `trigger_round_start`; per-round transient, never persisted. Sentinel `heist_outcome` guards resolution against the ~30 HUD `materialize()` re-reads per round.
854
+ - New `RoundUICallbacks.prompt_heist(state) -> bool` hook with a default no-op (classic Belote never offers the heist). BelAtro UICallbacks overrides in `belatro/main.py`.
855
+ - **Slot-machine score tally** (`BelAtroAnnounce.slot_machine_tally`) — Per-trick odometer animation replacing the static `score_popup`. 20-frame ease-out over ~600ms; bucket bar shows trick card_points filling, mult indicator pulses, odometer ticks toward the new total with color shifts (cyan → white → gold) and a flame row (`≈ ▼ ◆ ▼ ≈`) when running total crosses 120% of target. Skippable on SPACE / ESC / ENTER / EOF. Suppressed under `hide_hud` (Le Brouillard), `separate_scoring` (La Compétition), and on terminals smaller than 6 rows. Module-level cache (`_last_tally_total`) cleared each round via `reset_tally_state()`.
856
+ - **Persistent slot-machine readout in the BelAtro HUD (follow-up).** After the animation completes, the final-frame bucket + odometer lines stay visible at the bottom of the screen until the next trick or until `I` toggles the top HUD off. Adds a 1s skippable hold after the final frame so the player can read the result, then a new `_last_tally_readout` module cache feeds `BelAtroHUD.render()` / `_render_compact()` for the repaint. Cleared via `reset_tally_state()`. Bound to `is_top_hud_visible()` so `I` hides both the HUD and the readout in one shot.
857
+ - **Inventory overlay on the V key** (`BelAtroAnnounce` peer `InventoryOverlay` in `belatro/ui/inventory.py`). Read-only detail-on-select pager that lists owned jokers (with edition tags + per-edition bonus blurb), vouchers, consumables (tarots/planets held but not yet activated), permanent chip / mult bonuses, and per-contract planet levels. List view → ↑/↓ navigate, Enter opens a per-item detail page; Esc/V/Q close. Mirrors the `C` key's `ConsumablesOverlay` paint pattern (`clear_screen` + `vcenter_lines` + `invalidate_diff()` in `finally`).
858
+ - **Discoverability hint for the Dix de Der Heist.** The first time a player takes a contract while `economy.interest_rate == 0` (i.e. they haven't bought La Voûte yet), a one-time banner explains the gating: *"Tip: buy the La Voûte voucher in the shop to unlock the Dix de Der Heist (×2+ Mult on trick 8)."* Persisted via a new `Profile.seen_heist_hint: bool` field; no SCHEMA_VERSION bump needed (dataclass default kicks in for legacy saves).
859
+
860
+ ### Fixed (4.7.0 audit pass)
861
+
862
+ - **(Critical) Slot-machine cache leak on mid-animation interrupt** (`belatro/ui/announce.py`). The `_last_tally_total = new_total` update lived inside the try-block; a `KeyboardInterrupt` or render-time raise mid-animation would skip the update and leave the next round animating from a stale baseline. Moved into the `finally` block alongside `invalidate_diff()`. Pinned by `test_slot_machine_cache_updates_even_if_render_raises`.
863
+ - **(Critical) Slot-machine row collision on tiny terminals** (`belatro/ui/announce.py`). Row math (`term_h - 5..3`) collapsed when `term_h < 6`, painting the animation over the HUD row 1. Added a `term_h < 6` short-circuit that suppresses the animation while still updating the cache. Pinned by `test_slot_machine_tiny_terminal_renders_nothing`.
864
+ - **(Critical) `BelAtroAnnounce.reset_tally_state` AttributeError on round start.** `belatro/main.py::_play_blind` called `BelAtroAnnounce.reset_tally_state()` but the function was defined at module level (mirroring `reset_top_hud_state` / `reset_overlay_state`), not as a static method on the class. Crashed at the first round. Now imported as a module function. User-surfaced.
865
+ - **(Medium) Slot-machine paints misleading totals under La Compétition** (`belatro/ui/announce.py`). `acc.get_total(state)` is a running sum but `separate_scoring` uses a per-seat max at round-end; the animated total diverged from the eventual sealed total. Now gated like the HUD (`belatro/ui/hud.py:171-176`): cache update without paint. Pinned by `test_slot_machine_under_separate_scoring_renders_nothing`.
866
+ - **(Medium) Heist outcome banner leaked under Le Brouillard** (`belatro/main.py`). The post-round "HEIST SECURED / BUSTED" banner ignored `hide_hud`, defeating the boss's "hide the score" promise. Now gated on `not final_state.boss_modifiers.hide_hud`.
867
+ - **(Polish) Blank flame row consumed a screen row** (`belatro/ui/announce.py`). When `displayed < target × 1.2`, `ansi_center("")` painted a `term_w`-wide blank line, wasting vertical space. Now skip the paint when the flame line is empty.
868
+ - **Sans Atout AI type safety.** The 4.6.6 in-flight Sans Atout Hard AI work shipped with type annotations that assumed `trump: Suit`, but the SA path has `trump = None`. Widened `_hard_lead` and `_score_card_play` signatures to `Suit | None`, added an explicit `trump is None` arm to the `opp_trumps` computation in `_hard_play`, and removed a dead `is_sa = True/False` branch (overwritten three lines later by the canonical `state.contract == Contract.SANS_ATOUT` read). `mypy --strict` now clean.
869
+
870
+ ### Changed
871
+
872
+ - **`BelAtroAnnounce.score_popup` no longer called at `on_trick_end`.** Replaced by `slot_machine_tally`. The method is still defined for one release; if no consumer surfaces by 4.8 it will be removed. Bumped the `belatro/ui/announce.py` `invalidate_diff` count in `tests/test_alt_screen_scroll.py::test_belatro_overlays_invalidate_diff` from 4 to 5.
873
+ - **`ScoreAccumulator` gained an `interest_rate: int = 0` field.** Snapshotted at `_play_blind` time alongside `target_score`, so a mid-round La Voûte purchase or tarot effect cannot retroactively change the resolved heist multiplier.
874
+ - **`V` key is no longer an alias of `I`.** Pre-4.7.0 both keys mapped to `Key.OVERLAY` (top-HUD toggle). The follow-up pass split `V` into a new `Key.INVENTORY` enum value that opens the read-only inventory overlay. `I` keeps its existing toggle semantics (and now also covers the persistent tally readout). Wired in `belote/input.py` (both `_UnixKeyReader` and `_WindowsKeyReader`), dispatched in `belote/ui/prompts.py::prompt_card` (`"INVENTORY"` sentinel string), and handled by `belatro/main.py::UICallbacks._show_inventory`. Classic Belote's `gameflow.py` treats the sentinel as a no-op (re-prompt).
875
+
876
+ ### Internal
877
+
878
+ - **Test count baseline**: 941 → 997 (+56 in 4.7.0: 23 heist tests including 3 hint-discoverability regressions, 16 slot-machine tests including 5 HUD-readout persistence regressions, 15 inventory-overlay tests, 2 audit-driven entries).
879
+ - **Version markers bumped**: `pyproject.toml` (`4.6.6` → `4.7.0`), `src/belote/__init__.py` (`4.6.6` → `4.7.0`).
880
+ - **No `SCHEMA_VERSION` bump required**: heist state is per-round transient on `joker_state`, seeded fresh by `trigger_round_start` and never persisted. `Economy.interest_rate` was already persisted as part of `BelAtroRun.economy`; existing saves load without migration.
881
+ - **Six-agent audit highlights (all verified clean, no further action)**: per-round transient state hygiene (heist keys reset at round start, slot-machine cache reset at `_play_blind` entry); idempotency under HUD `materialize()` (sentinel-guarded, no re-application); composition with all trick-8 jokers (LeDernierMot, LExecuteur, Le Carnet, L'Infiltré); composition with planets (Pluton capot bonus, Mercure round-end money, Le Soleil Tout Atout per-trick mult); 21-boss safety smoke (La Rupture / La Compétition / L'Avocat / Le Mime / Le Zéro Final / L'Anarchie / L'Agent Double / La Solitude all verified). Performance: heist branches sub-microsecond per trick; slot-machine animation adds ~4.8s/round (designed, skippable).
882
+ - **Documentation accuracy sweep**: README and DEVELOPMENT refreshed; item counts (42 jokers, 12 tarots, 8 planets, 12 vouchers, 12 starting decks, 21 bosses) unchanged from 4.6.6 and re-verified against the registry.
883
+ - **Plan file**: `/home/mrrobot/.claude/plans/let-s-add-dix-de-steady-duckling.md`.
884
+
885
+ ---
886
+
887
+ ## [4.6.6] - 2026-05-19
888
+
889
+ Performance and logic audit pass. Highlights: improved Hard AI strategy for Sans Atout, unified round scoring paths to remove redundancy, and end-to-end documentation accuracy verification. All baselines green: `ruff` 0 violations, `mypy --strict` 0 errors, `pytest` 941/941.
890
+
891
+ ### Fixed
892
+
893
+ - **Hard AI fallback for Sans Atout rounds.** Pre-4.6.6, the 1-ply lookahead heuristic was trump-centric and fell back to random play when no trump was set. Now uses a non-trump ranking heuristic based on `_NONTRUMP_ORDER` (A > 10 > K > Q > J > 9 > 8 > 7), making the Hard AI significantly more competitive in Sans Atout play.
894
+ - **Refactored `apply_round_score` in `scoring.py`.** Unified the NS-taker and EW-taker branches into a single path using conditional mapping, eliminating 50+ lines of duplicated code and reducing the risk of logic drift between teams.
895
+
896
+ ### Internal
897
+
898
+ - **Test count baseline**: 941 (unchanged).
899
+ - **Version markers bumped**: `pyproject.toml`, `src/belote/__init__.py`, and various documentation references (4.6.5 → 4.6.6).
900
+ - **Documentation accuracy sweep**: Item counts (42 jokers, 12 tarots, 8 planets, 12 vouchers, 12 starting decks, 21 bosses) and test counts (941) verified across `README.md` and `DEVELOPMENT.md`.
901
+
902
+ ## [4.6.5] - 2026-05-18
903
+
904
+ Follow-up audit pass — six parallel Explore agents covered the surface area the 4.6.4 sweep didn't reach (partner-trust + bidding state machine, shop / economy / items / ghost-run, progression / replay / a11y / input). Result: two verified critical bugs in production paths the previous audit didn't touch (the LePrêteur economy exploit and a Windows-only EOF reader bug), three performance wins, and four quality items including the dead-but-declared `announce_round_result` helper finally wired up. Mechanics audits (partner trust, bidding state machine, capot detection, belote/rebelote under L'Anarchie, voucher idempotency, ghost-run write safety, save atomic-writes, achievement unlocks) all came back clean against current code. Plan file: `/home/mrrobot/.claude/plans/bug-hunt-code-performance-sparkling-fairy.md`. All baselines green: `ruff` 0 violations, `mypy --strict` 0 errors, `pytest` 941/941.
905
+
906
+ ### Fixed
907
+
908
+ - **(Critical) LePrêteur's $5 skim cost was silently dropped — free ×1.2 Mult on every $50+ round.** `belatro/main.py:453` gated the `_bonus_money` payout on `> 0`, so `JokerResult(add_money=-5, times_mult=1.2)` from LePrêteur's "$50+ → skim $5 for ×1.2 Mult" branch let the multiplier through (it routes via the accumulator's `times_mult` field, separate from `_bonus_money`) but never debited the cost. Repro: BelAtro run, buy LePrêteur, accumulate >$50, start the next round → wallet does not decrement. Fix: route negative `_bonus_money` through `Economy.spend_money(abs(amount))` — symmetric with the 4.6.4 non-negative guard on `Economy.add_money`. Pinned by `tests/belatro/test_jokers_4_5.py::test_preteur_skim_actually_debits_economy`.
909
+ - **(Critical) Windows EOF never surfaced as `Key.EOF`.** `_WindowsKeyReader.read()` (`input.py:275`) handled the `b"\x00"` / `b"\xe0"` escape prefixes from `msvcrt.getch()` and the WASD / arrow / letter aliases, but had no guard for empty bytes (`msvcrt.getch()` returning `b""` on closed stdin). Control fell through past every match, reached `ch.decode("utf-8")` (which succeeds on empty bytes → `""`), and returned `KeyEvent(Key.CHAR, "")`. Any prompt loop that ignored empty CHAR events would hot-spin until the outermost loop happened to exit. Latent in the codebase since the input layer was introduced; only the Unix reader had the EOF guard. Fix: `if not ch: return KeyEvent(Key.EOF)` immediately after `msvcrt.getch()`, mirroring `_UnixKeyReader`. Pinned by `tests/test_input_eof.py::test_windows_reader_returns_eof_on_empty_read` (mocks msvcrt on Windows; cross-platform static-shape check elsewhere).
910
+ - **`announce_round_result` had zero call sites.** Declared in `a11y.py` since 3.0.0 ("round complete. {team} taker scores ...") but nothing in `gameflow.py` actually called it; screen-reader users heard per-trick winners with no round-summary line. Fix: hooked at the score-animation site in `gameflow.py` after `animate_score_update`. Also added a new optional `contract: str | None` parameter so the spoken line is "round complete on hearts. north-south taker scores 162; defenders score 0" rather than the trump-less original.
911
+
912
+ ### Added
913
+
914
+ - **A11y: `announce_contract(taker, contract_label, coinche_level)`** — spoken at the bid→play transition in `gameflow.py`. Translates the locked trump / Tout Atout / Sans Atout into a screen-reader line. Coinche level is suffixed (", coinched" / ", surcoinched").
915
+ - **A11y: `announce_declaration(seat, kind, points)`** — exposed for future hook in `round_driver.py`'s declaration-emit branch. Helper landed in 4.6.5; the round_driver hook is deferred (the existing on-screen banner is loud enough that the user can hear it via OCR-style screen readers, but pure-stderr a11y readers will need the wiring).
916
+ - **`SaveManager._migrate` forward-incompat guard.** Pre-fix, a save file with `schema_version > SCHEMA_VERSION` would silently load as the current schema with future fields ignored. 4.6.5 raises `ValueError` instead; loud failure beats silent garbage. Auto-fallback to a fresh `Profile()` is intentionally **not** added — silently dropping progress is worse than asking the player to upgrade the game build.
917
+ - **Regression test `test_preteur_skim_actually_debits_economy`** — pins the LePrêteur economy exploit fix.
918
+ - **Regression test `test_windows_reader_returns_eof_on_empty_read`** — Windows-conditional mock + cross-platform static-shape check (asserts the empty-bytes guard appears before the decode fallback in the source).
919
+
920
+ ### Performance
921
+
922
+ - **`_medium_lead` (`ai.py:466-477`) collapsed from 3× hand-walks to 1× Counter pass.** Pre-fix built `non_trumps`, then computed `suit_counts = {s: sum(1 for c in non_trumps if c.suit == s) for s in Suit if ...}` (4 walks across non_trumps), then re-filtered `[c for c in non_trumps if c.suit == best_suit]`. Now: `Counter(c.suit for c in non_trumps if c.suit.is_card_suit).most_common(1)` returns the best suit + count in one pass.
923
+ - **`animate_score_update` no longer allocates a fresh frozen `GameState` per frame.** Pre-fix the 20-step score-roll animation called `dataclasses.replace(state, team_scores=(curr_ns, curr_ew))` per frame. 4.6.5 threads an optional `team_scores_override` kwarg through `display_hud` / `_build_hud`; the loop now passes the intermediate scores directly. `dataclasses.replace` is no longer imported in `ui/announce.py`. Existing `invalidate_diff()` in the `finally` block preserved (still pinned by `test_animate_score_update_invalidates_diff_baseline`).
924
+ - **Declaration tie-break (`scoring.py:300-326`) collapsed from nested-zips to a dict lookup.** Pre-fix `_resolve_tie_carre` / `_resolve_tie_seq` each did `for s in seat_order: for c, cs in zip(...): if cs == s and ...`. Now: build `{seat: team}` once, then `for s in seat_order: if s in seat_team: return seat_team[s]`. O(seats × decls) → O(seats); both factors are tiny so the speedup is microscopic but the dict form reads more clearly.
925
+
926
+ ### Changed
927
+
928
+ - **`UnlockTracker.on_event` docstring** (`belatro/progression/unlocks.py`) now enumerates handled event types (`RoundEndEvent`, `DeclarationScoredEvent`). Other bus events remain no-op by design — future unlocks that key off `BidMadeEvent` / `TrickWonEvent` / `BeloteAnnouncedEvent` add an `elif` here.
929
+ - **`JokerResult.add_money` sign convention documented** on the dataclass docstring (`belatro/items/base.py:43-49`): positive = credit, negative = debit (cost), zero = no-op; negative values route through `Economy.spend_money` at the payout site, not through `Economy.add_money` (which rejects negatives since 4.6.4).
930
+ - **`_build_hud` and `display_hud`** gained an optional `team_scores_override: tuple[int, int] | None = None` kwarg. Existing callers pass nothing and get the same behaviour (read from `state.team_scores`).
931
+ - **`a11y.announce_round_result`** gained an optional `contract: str | None = None` parameter that, when set, produces "round complete on hearts. ..." instead of "round complete. ...".
932
+
933
+ ### Removed
934
+
935
+ - **Dead constant `_TIER_NAMES`** in `belatro/ui/trust_bar.py:13`. Defined in 3.4.0 as `("Méfiant", "Sulking", "Neutre", "Loyal", "Mécène")` but never indexed; the BelAtro UI reads mood names from `TrustTrack.mood()` directly.
936
+
937
+ ### Internal
938
+
939
+ - **Test count baseline**: 939 → 941 (+2 in 4.6.5: LePrêteur economy + Windows-EOF regressions).
940
+ - **Version markers bumped**: `pyproject.toml` (`4.6.4` → `4.6.5`), `src/belote/__init__.py` (`4.6.4` → `4.6.5`).
941
+ - **Documentation accuracy sweep**: `README.md` test-count + version markers refreshed (939 → 941; 4.6.4 → 4.6.5). `DEVELOPMENT.md` baseline section rebuilt with the 4.6.5 highlights, 4.6.4 demoted to "Previous baselines". Accessibility section in `DEVELOPMENT.md` updated to mention the new contract / declaration announcements and the hook-point convention. Item counts re-verified end-to-end: **42 jokers, 12 tarots, 8 planets, 12 vouchers, 12 starting decks, 21 bosses** — all README claims match the registry.
942
+ - **Audit scope (verified clean, no action)**: partner trust score-bounds and tier-bucketing; all 6 personality archetypes; all-pass dealer rotation; Tout Atout / Sans Atout round-2 gating (triple-defended in `game.py`, `prompts.py`, `round_driver.py`); coinche → surcoinche flow on both NS-taker and EW-taker paths; L'Avocat auto-coinche `max()` invariant; capot detection across SA / TA / normal contracts under La Rupture; belote/rebelote under L'Anarchie trump rotation; voucher idempotency through the 3.9.3 base-class `_apply_once` guard; edition roll weight distribution; endless `_recent_boss_ids` cap + 8-attempt reroll guard; LeBanquier chute / NS-taker gating; ghost-run / run-summary write safety (`OSError`-swallowed, `fsync` before rename); theme-change cache-invalidation chain (`_card_back`, `_felt_segment`, `_card_face`, `_last_emitted_lines`); save atomic-writes (tempfile + fsync + rename); WASD aliasing on the Unix reader; AI memo invalidation (`last_voids_key`, `last_partner_hand_key` reset on new-round AND undo paths); `AIPlayer.decide_card` empty-legal assertion. Each item proven against current source rather than memory.
943
+ - **Plan file**: `/home/mrrobot/.claude/plans/bug-hunt-code-performance-sparkling-fairy.md`.
944
+
945
+ ---
946
+
947
+ ## [4.6.4] - 2026-05-17
948
+
949
+ Multi-agent audit pass — fixes five confirmed defects surfaced by an LLM-driven codebase audit, the most serious being a silently-broken unlock pipeline. The `EventBus` parameter that `drive_round` accepted was never `emit()`-ed, so every event-driven unlock (L'Exécuteur on first Capot, L'Idéologue on Sans Atout win, Le Fanatique on Tout Atout win, Quinte Royale on a NS sequence ≥100 pts) silently never fired in real play despite the `tests/belatro/test_contract_unlocks.py` suite passing — those tests `bus.emit(...)` directly and bypass `drive_round`. Also wires the previously-defined `RoundLedger.transactional()` exception-safety guard, closes the Le Mime + L'Architecte score-leak combo, replaces a dead `card in self.memory.partner_hand` identity check in hard-AI scoring with a real "partner has stronger same-suit card" comparison, and pins `animate_score_update` against the diff-baseline-staleness pattern the BelAtro overlays already enforce. All baselines green: `ruff` 0 violations, `mypy --strict` 0 errors, `pytest` 939/939.
950
+
951
+ ### Fixed
952
+
953
+ - **`drive_round` accepted an `EventBus` but never emitted to it — UnlockTracker silently broken.** `_emit()` in `src/belote/belatro/engine/round_driver.py` only called `acc.process_event(s, event)`; `bus.emit(event)` was missing. `UnlockTracker.subscribe_to(bus)` (called in `belatro/main.py:168`) added the unlock dispatcher to an empty pipeline, so every event-driven unlock condition (`progression/unlocks.py::on_event` for RoundEndEvent / DeclarationScoredEvent) was unreachable in natural play. Repro: complete a Capot in a normal run → L'Exécuteur never appears in your unlocks; declare a Quinte ≥100 pts → Quinte Royale stays locked. Fix: `_emit()` now publishes to the bus after the accumulator runs. The `bus.emit` ordering after `acc.process_event` keeps subscribers' state inspections consistent with ledger mutations made by jokers fired in the same dispatch.
954
+ - **`RoundLedger.transactional()` exception-safety guard was defined but never used.** `ScoreAccumulator.process_event` dispatched joker handlers raw — a joker raising mid-`_fire_jokers` left the ledger in a partial-mutation state AND short-circuited every sibling joker subscribed to the same event. The `transactional()` context manager (defined at `src/belote/belatro/core/round_ledger.py:74-103` with a documented "preserves the pre-4.6.2 exception-safety guarantee" docstring) wasn't called from anywhere. Fix: each per-joker invocation in `_fire_jokers` is now wrapped in `with ledger.transactional():`, plus a try/except mirroring EventBus's isolation pattern — `logger.exception(...)` and continue with siblings. The accumulator's `self._log` length is snapshotted around each call because `_apply` writes there (separate from `ledger.log` which transactional already rolls back).
955
+ - **Le Mime + L'Architecte combo leaked declaration-derived $$.** Under `declarations_zero`, `round_driver` correctly emitted `DeclarationScoredEvent` with `points=0`, but the BelAtro `core/scoring.py` handler unconditionally stamped `joker_state["_ns_annonce_cards"]` from `state.declarations`. L'Architecte's `annonce_cash_x2` deck rule then still paid +$2 per NS trick containing a declared card, defeating Le Mime's "all declaration value zeroed" promise. Fix: gate the harvest on `not state.boss_modifiers.declarations_zero` so the joker_state keys stay unset.
956
+ - **Dead `card in self.memory.partner_hand` check in hard-AI `_score_winning_strategy`.** `card` came from MY hand; `partner_hand` held partner's cards. With 32 unique deck cards and disjoint hands, the identity check was always False, so the documented "don't duplicate strength" −5 penalty never fired. Fix: walk `partner_hand` for a strictly stronger same-suit card via `trick_rank(...)` (honours L'Anarchie trump rotation via the existing `_se` flag) and apply the penalty only when partner can actually cover the trick.
957
+ - **`animate_score_update` didn't invalidate the diff baseline.** `display_hud` writes row 1 directly via `move(1,1)`, bypassing the `_last_emitted_lines` diff cache. The 20-step animation loop left the cache stale; a subsequent `display()` whose new row 1 happened to match the pre-animation cached row 1 would skip emitting it, leaving the last animation frame painted on screen. Fix: post-loop `invalidate_diff()` in a `finally` block. Mirrors the architectural rule already enforced for `show_help` / `show_history` / `show_rules` / `show_card_detail` / `show_stats` (pinned by `tests/test_alt_screen_scroll.py::test_belatro_overlays_invalidate_diff`).
958
+
959
+ ### Changed
960
+
961
+ - **`Economy.add_money` now rejects negative amounts** (`src/belote/belatro/core/economy.py:15`). Symmetric with `spend_money`, which already rejects negatives. No production caller passes negative (`grep`-verified: every `add_money(...)` site is either a `process_round_end` payout, a `bonus_money > 0` gated branch in `belatro/main.py`, or a `max(0, ...)`-bounded value). The LePrêteur `JokerResult(add_money=-5)` path is unaffected — those go through `ledger.money +=` directly, not through `Economy.add_money`.
962
+ - **`_is_honor` closure hoisted out of the per-suit loop in `ai.py::_hard_bid`** (was rebuilt 4× per call for no benefit). The closure now reads `_drop_jacks` / `_drop_aces` locals captured once before the loop.
963
+
964
+ ### Internal
965
+
966
+ - **New regression tests (+6)**:
967
+ - `tests/belatro/test_round_driver.py::test_drive_round_emits_to_event_bus` — subscribes a list-appender to the bus, drives a real all-pass round, asserts at least one `BidMadeEvent` was received. Pins the bus-wiring fix.
968
+ - `tests/belatro/test_round_driver.py::test_raising_joker_isolated_via_transactional` — attaches two jokers (one that raises, one that adds +42 chips), asserts the sibling still fires and the raise is logged via `logger.exception`. Pins the transactional guard.
969
+ - `tests/belatro/test_decks_4_5.py::test_le_mime_suppresses_architecte_annonce_cash` — activates `declarations_zero` boss flag, declares a Tierce, plays a trick containing a declared-suit card, asserts `_bonus_money == 0`.
970
+ - `tests/test_ai.py::test_winning_strategy_penalises_when_partner_has_stronger_same_suit` — pins the −5 score delta when partner downgrades from a stronger to weaker same-suit card.
971
+ - `tests/test_render_diff.py::test_animate_score_update_invalidates_diff_baseline` — seeds `_last_emitted_lines` to a non-None value, calls `animate_score_update` with `display_hud` and `time.sleep` stubbed, asserts the baseline resets to None.
972
+ - `tests/belatro/test_belatro.py::TestEconomy::test_add_money_rejects_negative` — pins the new guard.
973
+ - **Test count baseline**: 933 → 939 (+6).
974
+ - **Version markers bumped**: `pyproject.toml` (`4.6.3` → `4.6.4`), `src/belote/__init__.py` (`4.6.3` → `4.6.4`).
975
+ - **Documentation accuracy sweep**: `README.md` and `DEVELOPMENT.md` test-count markers refreshed from 933 → 939, version refs from 4.6.3 → 4.6.4. README `Controls > General` entry for `I`/`V` corrected — pre-fix it said "Toggle BelAtro score overlay (per-trick breakdown popup)", which is the pre-4.6.3 behavior (the popup flash on `I` was removed in 4.6.3). Now references the BelAtro top-HUD toggle described in the gameplay section.
976
+
977
+ ---
978
+
979
+ ## [4.6.3] - 2026-05-17
980
+
981
+ Targeted UX fix — pressing `I` (or `V`) now actually toggles the BelAtro top-HUD overlay so the classic `Trump: …` indicator on row 1 is reachable. Before 4.6.3 the joker pip strip at `move(1, 2)` unconditionally painted over the leading ~24 chars of the classic HUD bar produced by `_build_hud()`, hiding `T:♥` and the leading score chunk; the `I` key was wired but only flashed a transient score popup (redundant with the row-3 chips×mult display) and had no effect on what was visible at rest. All baselines green: `ruff` 0 violations, `mypy --strict` 0 errors, `pytest` 933/933.
982
+
983
+ ### Fixed
984
+
985
+ - **BelAtro top HUD covered the classic Trump/Taker bar with no way to hide it.** `render_joker_pip_strip` (`src/belote/belatro/ui/hud.py:275`) anchors at row 1 col 2 — exactly where the classic HUD's `T:♥ / NS:…` lives — and was rendered unconditionally after every `display()` via `BelAtroHUD.render` + the post-write inside `_show_overlay`. The taker letter at the tail (`Tk:S`) survived, but the trump suit symbol was clobbered. Reproducer captured in `screen-2026-05-17-17-24-43.jpg` shows the top row reading `BJ: [..][..][..][..][..]W:0(+16) 2/8 Tk:N` — a single `B` from `BELOTE`, then the joker strip, then the tail of the classic HUD with the trump field gone.
986
+
987
+ ### Changed
988
+
989
+ - **`I` / `V` now toggles the BelAtro top HUD visibility** (joker pip strip, ante/blind/target line, chips×mult score, trust bar, synergy tooltip). Default = visible (no behaviour change at first paint); pressing the key hides them so the classic HUD's `Trump: ♥` and `Taker: NORTH` fields render in full on row 1. Pressing again restores the BelAtro overlays. New module-level flag `_top_hud_visible` in `src/belote/belatro/ui/announce.py` is independent of the existing `_overlay_visible` (still owned by `score_popup`'s transient flip).
990
+ - **`_show_overlay()` in `src/belote/belatro/main.py:204`** rewritten: calls `toggle_top_hud()` + `invalidate_diff()` + `display()` + render passes (which become no-ops when the flag is hidden). The transient `score_popup` flash on `I` is removed — the auto-popup at `on_trick_end()` (line 283) is untouched and continues to fire after every trick.
991
+ - **`render_joker_pip_strip` / `render_synergy_tooltip` / `TrustBar.render` / `BelAtroHUD.render`** all honour `is_top_hud_visible()` at their first statement, so any third-party caller of the helpers gets the same gate.
992
+ - **Classic HUD verbose hint string** (`src/belote/ui/render.py:922`) gained `[I]HUD` next to `[H]Hist [T]Theme [Z]Undo` so the new toggle is discoverable. Compact layout left alone (already at width limit).
993
+
994
+ ### Internal
995
+
996
+ - **`tests/belatro/test_hud_toggle.py` (NEW, 7 tests)** — pins default visible, toggle flips/restores, joker pip strip is silent when hidden, joker pip strip paints `J:` and a `[` slot when visible, synergy tooltip silent when hidden, trust bar silent when hidden, trust bar paints `Trust:` when visible. Uses an autouse fixture that calls `reset_top_hud_state()` around each test so module-level flag state can't leak across the suite.
997
+ - **Test count baseline**: 926 → 933 (+7 in 4.6.3).
998
+ - **Version markers bumped**: `pyproject.toml` (`4.6.2` → `4.6.3`), `src/belote/__init__.py` (`4.6.2` → `4.6.3`).
999
+ - **Documentation accuracy sweep**: `README.md` and `DEVELOPMENT.md` test-count markers refreshed from 926 → 933, version refs from 4.6.2 → 4.6.3.
1000
+ - **Plan file**: `/home/mrrobot/.claude/plans/home-mrrobot-belote-screen-2026-05-17-1-witty-lemon.md`.
1001
+
1002
+ ## [4.6.2] - 2026-05-17
1003
+
1004
+ Deep-audit pass — full per-joker (42) + per-boss (21) walk of the BelAtro feature surface plus a classic-engine and UI-rendering pipeline pass. Three parallel Explore agents found one **HIGH** finding (an event-emit gap that let Le Mime's "declarations score 0" promise leak through the BelAtro accumulator path) plus a small set of defensive items. Two agent findings (L'Avocat description mismatch, "AI not in benchmark") were verified false positives and recorded so future audits don't re-flag them. The release also lands a per-joker / per-boss test matrix to mechanically catch the four foot-guns (seat-vs-team keying, re-emit guards, is_failed gates on cash, boss-flag awareness) on every future joker / boss added. All baselines green: `ruff` 0 violations, `mypy --strict` 0 errors, `pytest` 926/926.
1005
+
1006
+ ### Performance
1007
+
1008
+ - **`src/belote/belatro/core/scoring.py` — `ScoreAccumulator` now mutates a `RoundLedger` instead of doing `dataclasses.replace(GameState, ...)` per event** (`src/belote/belatro/core/round_ledger.py`, new file, ~100 LOC). Pre-4.6.2 every event went through a final `replace(state, _chips=..., _mult=..., _bonus_money=..., _joker_state=...)` that built a fresh frozen GameState (~19μs × ~25 events ≈ 0.48ms per round, ~11% of `drive_round_ms`). 4.6.2 keeps the frozen invariant at round boundaries only: `trigger_round_start` does one `replace()` to install `ledger.joker_state` AS `state._joker_state` (same dict object), then `process_event` mutates the ledger in-place for the rest of the round, and `seal_round` does one final `replace()` to stamp `_chips`/`_mult`/`_bonus_money` into the canonical sealed state. Classic-Belote read sites (`ai.py`, `scoring.py`, `game.py`) reading `state._joker_state.get(...)` see live mutations without further replaces because the dict is shared. The HUD reads chips/mult/money via new accumulator getters (`current_chips()` / `current_mult()` / `current_money()`) instead of `state._chips` directly, so per-render materialize cost is zero. `update_state(state, event) -> GameState` is preserved as a backward-compat wrapper for ~100 unit tests that didn't go through `trigger_round_start` (the lazy-init path on the wrapper aliases the ledger's `joker_state` onto a fresh state via a one-time `replace`, restoring the shared-dict invariant). The `transactional()` context manager on `RoundLedger` preserves the pre-4.6.2 "raising handler doesn't leak partial joker_state mutations" guarantee via snapshot-on-entry / restore-on-exception.
1009
+ - **`ScoreAccumulator.attach_jokers` — pre-resolved joker handler registry** (B.2). `_fire_jokers(method_name, event)` was doing `getattr(joker, method_name, None)` per joker per event in the hot path (~125 getattr calls per round); now `attach_jokers` builds a `dict[str, list[(joker, bound_method)]]` once, skipping jokers that didn't override the base `Joker.on_X` no-op (filter by `getattr(method, "__func__", None) is base_method`). Lazy rebuild on first `_fire_jokers` call protects tests that mutate `_jokers` directly.
1010
+ - **`drive_round` no longer does the per-event replace**. The `_emit` helper now calls `acc.process_event(s, event)` and returns the state unchanged; classic-Belote reads pick up live joker_state mutations via the shared dict; `seal_round` runs once at the end of the round. Net measured change on the dev machine: `drive_round_ms: 4.21 → 3.67` (**−12.9%** over an 80-round sample). The remaining ~3.7ms is dominated by AI inference (3 seats × MEDIUM difficulty × 8 tricks ≈ 24 `decide_card` calls per round); further wins would need AI optimization, not event-bus refactors.
1011
+ - **Le Fantôme partner personality cash payout** (`round_driver.py` line ~452, `+$1` on partner trick wins) was previously a `replace(state, _bonus_money=state._bonus_money + 1)` side-channel that bypassed the accumulator. 4.6.2 routes it through `acc._ledger.money += 1` so the source of truth stays consistent and the HUD's `current_money()` read sees it immediately. A fallback to the old replace path remains for callers without an accumulator.
1012
+ - **`tests/perf_baselines.json`** updated to `drive_round_ms: 3.67` with `_history` line documenting the change. `_captured_version` bumped to `4.6.2`. 2.5× regression tolerance retained.
1013
+
1014
+ ### Fixed
1015
+
1016
+ - **`src/belote/belatro/engine/round_driver.py` — `DeclarationScoredEvent.points` now zeroed when `state.boss_modifiers.declarations_zero` (Le Mime) is set**. Pre-fix, classic scoring honored Le Mime at `scoring.py::_carre_points` / `_sequence_points` (returning `(0, 0)` for declaration totals), but the BelAtro accumulator's `DeclarationScoredEvent` branch (`belatro/core/scoring.py:324`) still did `new_chips += event.points` on the raw declaration value, AND fired on_declaration jokers (LeMathematicien — pays ×2 Mult on multiple-of-5 declarations; QuinteRoyale — arms a ×4 round-end multiplier on declarations ≥ 100 pts) on raw points. Net player-visible effect: under Le Mime, a Carré of Aces correctly scored 0 in the final breakdown but still pushed +200 chips into the BelAtro running total and could arm a ×4 round multiplier. The fix is at the emit site (cleaner than per-joker gating because it propagates to every consumer of `event.points`): under `declarations_zero`, the emitted event carries `points=0`. TierceCharger continues to credit its charge — it's gated on `declaration_type` only, per its "announce" (not "score") description.
1017
+ - **`src/belote/belatro/core/scoring.py:197` — `partner_tier` defensive clamp**. The tuple indexing `(0, 0, 1, 1, 2)[self.partner_tier]` would raise `IndexError` if a corrupted save or future mutation set tier > 4 or < 0. Now clamped via `max(0, min(self.partner_tier, 4))` so out-of-band tier values degrade gracefully to the nearest documented tier. Two regression tests pinned in `tests/belatro/test_joker_contracts.py` (`test_partner_tier_clamp_oob_high` / `_oob_negative`).
1018
+ - **`src/belote/ui/render.py::patch_trick_card` — kept emitting card rows WITHOUT `clear_to_eol()`** (a fix proposed and then reverted in 4.6.2 after visual regression). A `clear_to_eol()` was briefly appended per face line to harden against row-shrink debris on terminal resize, but card patches are positioned at a specific column over the felt mat — `clear_to_eol()` then wipes felt-green cells to the right of every card face with the terminal's default background, leaving grey/black bars next to every patched card. The convention from `display()` diff rows only applies when the emit covers the full row width; per-card patches do not. Row-shrink debris on resize is handled by the existing `invalidate_diff()` call at the end of `patch_trick_card` — the next full `display()` repaints the row. Inline comment added to `render.py` pinning the rule so a future audit doesn't re-propose the same change.
1019
+
1020
+ ### Internal — Audit Matrix & Test Coverage
1021
+
1022
+ - **`tests/belatro/test_joker_contracts.py` (NEW, 100 tests)** — pins each of the 42 jokers' happy-path trigger and at least one non-trigger gate, plus cross-cutting parametrized tests for the four audit foot-guns: (a) re-emit guards on state-mutating handlers (LeBanquier, CoincheStack, ToutStreak, QuinteRoyale, RebeloteEcho, LeCollectionneur), (b) `on_round_start` resets, (c) team_of vs seat-keying (LeDemon and LAccumulateur explicitly pinned as team-keyed; LeTraitre / LAgentDouble / LEgoiste as seat-keyed by design), (d) registry completeness (every joker has non-empty id/name). Two findings pinned without code change: `RebeloteEcho` / `LeNotaire` / `LeRebelle` gate seat=SOUTH only on belote/rebelote (NORTH-held trump K+Q never triggers them — `test_le_notaire_seat_keyed_north_no_op` documents the contract), and `LePatriote` uses raw `card_points()` not `card_points_with_modifiers()` (`test_le_patriote_uses_raw_card_points` pins the trump-bonus behaviour under zero-rank bosses).
1023
+ - **`tests/belatro/test_boss_contracts.py` (NEW, 36 tests)** — pins each of the 21 bosses' `apply() → BossModifiers` flag mapping via parametrized table; BetrayalArc's three-flag composition; the Le Mime fix above; four high-risk boss combo flag-coexistence pins (LaRupture × LAnarchie, LaCompetition × LaMalediction, three zero-rank bosses, BetrayalArc × LaSolitude); and two anti-pattern locks that grep the actual source tree: `test_no_underscore_boss_read_pattern_in_src` rejects any `getattr(state, "_kings_zero", False)`-style read site (the 3.0.x foot-gun) and `test_no_boss_id_string_branching_in_runtime` rejects `boss.id == "..."` branching in runtime files.
1024
+ - **Test count baseline**: 790 → 926 (+136 in 4.6.2).
1025
+ - **Save/load schema migration shim verified live** at `belatro/progression/save.py::_migrate` (110-121) — already wired pre-4.6.2, no change needed. Confirms the audit's "add migration shim" hypothesis was a false positive.
1026
+ - **Verified-false / pinned-against-re-flag** (audit returned and verified clean — listed here so a future audit pass doesn't waste cycles): `L'Avocat` triple-payout math at `main.py:444-446` (math AND description both say triple; verified), benchmark `drive_round_ms` excludes real AI (verified false — `round_driver.py:188-191` uses real `AIPlayer(Difficulty.MEDIUM)` for E/W/N), event_bus `re_emit` central guard at `core/scoring.py:382-383` for on_bid jokers (LePasseur is safe without a per-handler guard), every BelAtro boss flag has at least one canonical read site at `state.boss_modifiers.X` (no string-id branching detected by `test_no_boss_id_string_branching_in_runtime`).
1027
+ - **Version markers bumped**: `pyproject.toml` (`4.6.1` → `4.6.2`), `src/belote/__init__.py` (`4.6.1` → `4.6.2`).
1028
+ - **Documentation accuracy sweep**: `README.md` and `DEVELOPMENT.md` test-count markers refreshed from 790 → 926, version refs from 4.6.1 → 4.6.2.
1029
+ - **Plan file**: `/home/mrrobot/.claude/plans/partitioned-cooking-hennessy.md`.
1030
+
1031
+ ## [4.6.1] - 2026-05-17
1032
+
1033
+ Third audit / hardening pass — this one against the freshly-trimmed 4.6.0 baseline. Three parallel Explore agents (classic engine, BelAtro roguelite layer, UI/render layer) walked the codebase looking for bugs, mechanic gaps, and perf hot paths. Most flagged items resolved as **false positives** or **already-fixed in 4.5.1** once verified against current code; this release lands the two genuine findings and bumps the test count from 787 → 790. All baselines green: `ruff` 0 violations, `mypy --strict` 0 errors, `pytest` 790/790.
1034
+
1035
+ ### Fixed
1036
+
1037
+ - **`src/belote/ui/menu.py` — `invalidate_diff()` on every overlay-bypass exit path**. Four classic-menu screens (`show_theme_selector`, `show_ai_config`, `show_main_menu`, `show_final_screen`) write directly to `sys.stdout` with `clear_screen() + sys.stdout.write(...) + flush()` and were not invalidating the render diff baseline (`render._last_emitted_lines`) before returning. The same overlay-bypass diff-skip class of bug that 4.0.0 fixed for `show_help`/`show_history`/`show_rules` (and that 4.6.0 preserved when it removed `show_card_detail`). The failure mode is latent because all four paths run before/after the gameplay `display()` loop, but it surfaces on **second-game-in-session** play: game-over → menu → start-game → the first `display()` of the second game diffs against the stale post-prior-game frame and emits only changed rows → first-frame artifacts. Now consistent with the rest of the codebase; the BelAtro overlays (`belatro/ui/menu.py:123`, `shop.py:309`, `consumables.py:70`, etc) already followed this pattern.
1038
+
1039
+ ### Performance
1040
+
1041
+ - **`src/belote/belatro/ui/shop.py::ShopScreen._render` now batches its full frame into a single `sys.stdout.write` + `flush`** (was ~16 bare `print()` calls per redraw, each its own syscall). `_render_planet_card` and `_render_item_card` were refactored to return `list[str]` instead of emitting directly; `_render` accumulates everything (clear-screen prefix, title, money line, every card frame, action strip, description, hint row) into one `parts: list[str]` and writes once at the end. Mirrors the single-write convention used in `belatro/ui/hud.py::_render`, `belote/ui/announce.py`, and `prompts.py::show_help`. Not user-visible at terminal speeds — this is a consistency / convention fix.
1042
+
1043
+ ### Internal
1044
+
1045
+ - **Tests**: 787 → 790. Three regression tests appended to `tests/test_render_diff.py`:
1046
+ - `test_show_main_menu_invalidates_diff_on_exit` — stamps a stale baseline, drives `show_main_menu` to its `Quit` exit via a `_QuitReader` stub, asserts `render._last_emitted_lines is None` afterward.
1047
+ - `test_show_theme_selector_invalidates_diff_on_exit` — same contract pinned independently so a future refactor of one path can't silently drop the other's guard.
1048
+ - `test_shop_render_writes_once_per_frame` — instantiates `ShopScreen` over a real `Shop(BelAtroRun(seed=42))`, wraps `sys.stdout` in a write-counting `StringIO`, asserts `_render()` produces exactly one write call (pre-4.6.1 this was ~16).
1049
+ - **Version markers bumped**: `pyproject.toml` (`4.5.1` → `4.6.1`), `src/belote/__init__.py` (`4.5.1` → `4.6.1`).
1050
+ - **Documentation accuracy sweep**: `README.md` — removed the `GRIMAUD Standard Playing-Cards-1898.png` entry from the file-tree block (image was deleted in 4.6.0 but the doc reference lingered), updated stale `792 tests` / `(4.5.1)` markers to `790 tests` / `(4.6.1)`. `DEVELOPMENT.md` — rewrote the *Current baseline* section to lead with 4.6.1, demoted 4.5.x / 4.6.0 to a *Previous baselines* list.
1051
+ - **Audit verified healthy** (no change required — pinned here so the next audit pass doesn't re-flag them):
1052
+ - `game.py:278` `reset_round_fields` resetting `boss_modifiers` to defaults — by design. BelAtro applies boss flags AFTER `start_round` via `PatchedGameState.patch()` → `dataclasses.replace`. Classic mode doesn't use boss flags.
1053
+ - `LeCollectionneur` `re_emit` guard — already added in 4.5.1 (`annonces.py:129`).
1054
+ - L'Infiltré ghost-lead seat-walking loop (`core/scoring.py:285-295`) — correct as-is. Seat is walked from `event.leader_seat.next_seat()` per card; winner-check fires only when the iterating seat equals `event.winner` and the card is trump.
1055
+ - `_architecte_ns_annonce_cards` "dead pop" — not dead. Read live at `core/scoring.py:303` by the `annonce_cash_x2` branch.
1056
+ - `_card_points_with_zero_ranks` three-site consistency — covered by 3.9.3 + 4.1.0 wiring; flags flow through `card_points_with_modifiers` / `trick_card_points` uniformly. Zero changes needed in `ai.py` when a new zero-rank flag lands.
1057
+ - Voucher idempotency, render diff layer, theme palette caching, AI memo keys (`last_voids_key` / `last_partner_hand_key`), planet stacking, belote/rebelote under L'Anarchie, WASD reader aliasing.
1058
+ - **Plan file**: `/home/mrrobot/.claude/plans/bug-hunt-code-performance-synthetic-pearl.md`.
1059
+
1060
+ ## [4.6.0] - 2026-05-16
1061
+
1062
+ The **Grimaud Card Detail** feature shipped in 4.0.0 has been removed. The `F` key no longer opens a full-screen zoomed card view; the hand-drawn pixel-art module is gone. Other overlays (`?` help, `H` history, rules, theme cycle, `I`/`V` overlay) are unchanged.
1063
+
1064
+ ### Removed
1065
+
1066
+ - **`src/belote/ui/card_detail.py`** — entire module deleted (silhouette templates, per-(rank, suit) palettes, held-object overlays, `_FACE_DESIGNS`, `_compile_art`, `_render_card`, public `show_card_detail`).
1067
+ - **`Key.CARD_DETAIL` enum value + `f` binding** in `src/belote/input.py` (both Unix and Windows readers). The `f` key now falls through as a `Key.CHAR` and is unhandled by any prompt — a no-op.
1068
+ - **`Key.CARD_DETAIL` case arms** in `prompt_card` and `prompt_bid` (`src/belote/ui/prompts.py`).
1069
+ - **`[F]` help-screen entry** in `show_help` (`src/belote/ui/prompts.py`).
1070
+ - **`tests/test_card_detail.py`** — all 5 tests deleted (shape, distinctness, key enum, dismiss, diff-baseline regression). Test count drops by 5 from the 4.5.1 baseline.
1071
+ - **README mentions** of the `F` keybind and the Grimaud feature paragraph.
1072
+
1073
+ ### Preserved (intentionally)
1074
+
1075
+ - **`render.invalidate_diff()`** stays — it is still used by `show_help`, `show_history`, and `show_rules` to fix the same latent diff-skip overlay bug. Only the docstring was updated to drop the `show_card_detail` reference.
1076
+
1077
+ ## Pre-4.6.0 Releases — Condensed History
1078
+
1079
+ Detailed entries for releases **4.0.0 through 4.5.1** are condensed below to keep this file scannable. Each line summarizes the headline change(s); the git log carries the full implementation context.
1080
+
1081
+ ### 4.x Series (pre-4.6.0)
1082
+
1083
+ - **[4.5.1] — 2026-05-16.** Audit pass; LeCollectionneur re_emit guard, L'Architecte deck description spells out "NS-won", `_record_belote_announcement` returns tuple directly (792 tests).
1084
+ - **[4.5.0] — 2026-05-16.** Feature release: WASD navigation at the reader source, 2 new BelAtro decks (L'Infiltré, L'Architecte), 6 new "Conditional Engine" jokers (LeCavalierNoir, LArcEnCiel, LeCollectionneur, LeMathematicien, LEclat, LePreteur), bid quick-keys remapped A/S → X/N (790 tests).
1085
+ - **[4.1.1] — 2026-05-16.** Audit pass; AI play heuristics route through `card_points_with_modifiers` so every zero-rank boss flag is honoured at play time (not just bid time), `fit_guard.require_minimum` and `show_stats` invalidate the diff baseline on exit, `show_stats` builds its line list once across keystrokes (751 tests).
1086
+ - **[4.1.0] — 2026-05-15.** Audit/perf pass; LeDémon team-keyed (was seat-keyed and silently dropped North wins), Hard AI excludes Clubs under `ban_clubs`, `decide_card` asserts on empty `legal_cards` (was silently playing illegal), diff-emit appends `clear_to_eol` on row shrink, render perf wins (cached `theme_manager.current_name`, `_pip_at` `@lru_cache`, tuple `_last_emitted_lines`/`_pending_rendered_lines`), 5 defensive `re_emit` guards on state-mutating jokers (742 tests).
1087
+ - **[4.0.1] — 2026-05-15.** Four diff-baseline fixes in the same family: writes that bypass `render.display()` were leaving the render-diff baseline out of sync with terminal state. `gameflow.py` stops writing raw `\r\n` (replaced by `announce()` / new `show_round_summary` modal), `patch_trick_card` invalidates diff, every BelAtro overlay (menu, shop, rules, history, collection, consumables) and every `BelAtroAnnounce` modal exit calls `invalidate_diff()` (723 tests).
1088
+ - **[4.0.0] — 2026-05-15.** Major bump for the **Grimaud Card Detail** feature: `F` key opens a centered half-block pixel-art popup with 12 unique J/Q/K × suit illustrations loosely inspired by the Grimaud Standard 1898 plate; non-face cards get a procedural pip layout on the same canvas. Introduced `render.invalidate_diff()` as the canonical overlay-cleanup hook. (Feature subsequently removed in 4.6.0; the `invalidate_diff()` helper survives.) (716 tests).
1089
+
1090
+ ---
1091
+
1092
+ ## Pre-4.0.0 Releases — Condensed History
1093
+
1094
+ Detailed entries for every release before **4.0.0** are condensed below to keep this file scannable. Each line summarizes the headline change(s); the git log carries the full implementation context.
1095
+
1096
+ ### 3.x Series
1097
+
1098
+ - **[3.9.5] — 2026-05-15.** Audit pass (5 agents); palette accessor fast path, `is_capot` accepts pre-computed winners, dead-helper cleanup in `ui/render.py`, 4 new invariant tests + 5 perf regression guards (711 tests).
1099
+ - **[3.9.4] — 2026-05-15.** UI polish: 4 new themes (Forest Night / Moonlit Tavern / Royal Purple / Emerald Isle), felt vignette + braille pip texture + ornamented frame, hand-readout bar, auto-sort on prompt entry, stats screen splits "Discovered" vs "Unlocked" (702 tests).
1100
+ - **[3.9.3] — 2026-05-15.** Multi-agent audit; AI bidding honors zero-rank bosses (R1), L'Anarchie preserves rebelote across rotation via new `belote_trump` field (R2), HUD disclaimer under Compétition (R3), LeBanquier gated on success+NS-taker (R4), voucher idempotency moved to base class (R5), render diff-emit layer added, Quinte Royale unlock wired, undo banner + skip to SOUTH, rematch removed (691 tests).
1101
+ - **[3.9.0] — 2026-05-14.** Audit pass; `BelAtroAnnounce.yes_no` EOF fix, `NO_COLOR` env-var support, end-to-end belatro-round benchmark, declaration tie-break announce-order shared (661 tests).
1102
+ - **[3.8.2] — 2026-05-14.** Audit polish: Tout Atout streak persistence, QuinteRoyale only arms on Quintes (not high-rank Carrés), LeNotaire/LeRebelle trigger on rebelote not belote, sequences >5 cards cap at Quinte, Carré scoring fix, Capot honors `no_dix_de_der` (655 tests).
1103
+ - **[3.8.1] — 2026-05-14.** Audit pass; La Rupture consistency between `play_card` and `score_round` (CRITICAL), boss flags applied before `acc.trigger_round_start`, `TrickWonEvent.winner` is the resolved seat, LeRebelle/LeNotaire no longer double-fire on rebelote, LAgentDouble joker partner-sabotage wired (655 tests).
1104
+ - **[3.8.0] — 2026-05-13.** UI-fit overhaul: croissant clip fixed, live "terminal too small" overlay (`fit_guard.py`), BelAtro screens converted to `vcenter_lines`, shop action buttons relocated; voucher idempotency guard at shop level, declaration zip-strict; AI partner_hand memoization, `_hard_bid` single-pass suit bucketing (650 tests).
1105
+ - **[3.7.1] — 2026-05-13.** Audit + 3.7.0 deferred items; L'Accumulateur team-keyed (BA-L2), `ContractReward` float fields correctly annotated (BA-L1), `score_round` / `play_card` split via `_ScoringContext` / `_PlayContext`, achievement lookup via dict, partner-jokers coverage (9 jokers, 100% line coverage), NS-taker player surcoinche callback (635 tests).
1106
+ - **[3.6.0] — 2026-05-12.** Audit; EW AI can coinche NS taker (Libra reachable, H1), synergy-ID validation survives `python -O` (H2), EventBus handler isolation (H3), zero-rank/`ban_clubs` consolidated to helpers, `Contract` enum, `sort_hand` lru_cache, `ContractReward` TypedDict (599 tests).
1107
+ - **[3.5.0] — 2026-05-12.** Audit; consumables overlay reachable via `C` (C1), JSONL run-summary fsync (H1), `Key.EOF` distinct from ESC (H2), EventBus round-scope documented (H3), tied declarations to first announcer (M1), `RoundEndEvent.breakdown` typed, partner-tier scaling supersedes `partner_jokers_double` (deprecation warning), SA belote assertion hoisted (592 tests).
1108
+ - **[3.4.2] — 2026-05-11.** Roadmap from 3.4.1; AI respects `hide_partner_hand` (C1), Dix-de-der announcement Rupture-aware (C3), `opp_trumps` excludes own/partner hand + TA-32-trump total (C4), 8 jokers migrated to `team_of` (H1), TournoiAnte pays real 50% (H4), `load_profile` keeps starter unlocks (H5), tie-on-target stats agree with menu (H7), `equip_joker(run=)` for purchase hook (H10), `advance_turn` dead-code removed (568 tests).
1109
+ - **[3.4.1] — 2026-05-11.** Verification-only release; catalogued 7 confirmed bugs + 8 verified-false claims from an external audit (no source changes; 551 tests).
1110
+ - **[3.4.0] — 2026-05-10.** Audit + endless-mode reliability + HUD polish; `BidMadeEvent` no longer double-fires on coinche (re-emit flag), endless mode enters at first-scaled-cycle, tie-breaker rounds play out, terminal-restore robust to broken pipes, shop selection clamp; joker pip strip + synergy tooltip + 4-tier trust bar (551 tests).
1111
+ - **[3.3.4] — 2026-05-10.** Portability: terminal-bell / sound subsystem removed entirely (SIGSYS on Alpine 23/musl). `play_sound` / `is_muted` / `toggle_mute` / `AudioManager` / `Key.MUTE` all gone (549 tests).
1112
+ - **[3.3.3] — 2026-05-10.** Audit-of-audit; `sort_hand` honors TA ladder (F1), boss assignment uses seeded RNG (F2), `LeJugement` filters to Commons (F3); invariant tests for scoring conservation, replay determinism, solo-half synergy (549 tests).
1113
+ - **[3.3.2] — 2026-05-10.** Residual audit; `is_capot` Rupture-aware in both branches (F1), `replay.analyze_round` uses seeded RNG (F2), score popup clamps chips ≥ 0 (F3) (537 tests).
1114
+ - **[3.3.1] — 2026-05-10.** Audit-of-audit; `trick_card_points` ban_clubs whole-trick parity (B1), per-round stats flush (B2), `fuse_jokers` carries edition + corrupted (B7), modifier_patch `python -O`-safe assert→raise (B9), unlock notifications queued not printed (B10), ante-themes wired (B14), trust-bar 3-tier color, La Rupture single-source winner helper, L'Anarchie `belote_announcer` field, AI RNG seeded, void-cache undo invalidation, hard AI under SA (535 tests).
1115
+ - **[3.3.0] — 2026-05-10.** BelAtro history overlay: `[H]` in BelAtro mode opens a populated per-blind ledger via `BelAtroRun.history` + `set_history_override` hook (535 tests).
1116
+ - **[3.2.0] — 2026-05-10.** Two-audit reconciliation (Qwen + Ring 1T); LaSentinelle / LeDernierMot / etc. team-keyed, LEgoiste chip clamp, NS-taker auto_coinche re-emits, registry duplicate-ID assertions, modifier_patch dynamic-fields, shop + tarots seeded RNG (528 tests).
1117
+ - **[3.1.0] — 2026-05-08.** Audit-action; HUD multi-boss running total delegates to `trick_card_points`, shop slot-capacity above spend_money, TierceForge UI integration, scoring winners-threading, `update_state` shallow `dict()` copy, AI hot-loop allocation hoist, `modifier_patch` underscore shim retired (525 tests).
1118
+ - **[3.0.3] — 2026-05-08.** Audit + doc fixes; README boss count 18→21, test count 435→510, full planning catalogue at `~/.claude/plans/bug-hunt-code-performance-atomic-sutton.md` (510 tests; no source changes).
1119
+ - **[3.0.2] — 2026-05-08.** Audit; `GhostRecorder` wired via `BELOTE_GHOST=1`, `replay.analyze_round` wired via `BELOTE_REPLAY=1`, `score_round` pre-computes winners, `register_all_items` idempotent (510 tests).
1120
+ - **[3.0.1] — 2026-05-07.** Post-3.0.0 audit; `play_card` HUD honors `aces_zero`/`jacks_zero`, HUD synergies use registered IDs, a11y trick announce uses `trick_card_points`, `last_voids_key` reset on new round, SA capot belote assertion (509 tests).
1121
+ - **[3.0.0] — 2026-05-07.** Capot scoring per contract (220 SA / 348 TA / 252 normal), The Sun and Libra planets wired, Edition enum (Foil/Holo/Polychrome/Negative) at shop generation, three new boss flags (`aces_zero` / `jacks_zero` / `declarations_zero`), HUD synergy badges, 6 achievements, colorblind theme, `BELOTE_A11Y` screen-reader hints, replay analyzer, `GhostRecorder`, run_summary JSONL, post-Ante-8 endless prompt (489 tests).
1122
+ - **[2.9.5] — 2026-05-07.** Keyboard shortcuts: `T`/`H`/`O`/`?` mapped properly, `s` returned to SA quick-bid, min-trick-dwell 0.5s; slot frames on trick mat; expanded `RoundScore` fields drive 8-column history table; full GRIMAUD-1898-inspired card art redraw (436 tests).
1123
+ - **[2.9.2] — 2026-05-07.** Konsole render-pipeline fix; bidding selector painted inside render frame via `bid_selection`, every frame line `clear_to_eol`-terminated, prompt_bid no extra stdout writes (435 tests).
1124
+ - **[2.9.1] — 2026-05-06.** UI polish: croissant Braille restored, cup-template width tightened, selected-row markers symmetric (435 tests).
1125
+ - **[2.9.0] — 2026-05-06.** Tout Atout + Sans Atout bidding affordance ships end-to-end (round 2 only); 4 audit bugs fixed including `BidValue` typing, scoring `state.contract`-gated, AI three-tier TA/SA heuristics, partner TA/SA gated on `duo_contracts_available`; Le Fanatique + L'Idéologue jokers reachable (test count unchanged).
1126
+ - **[2.8.0] — 2026-05-06.** Two audit sweeps; alt-screen pollution fixed (C1), AI threads `seven_eight_trump` everywhere (C2), `LeFou` re-applies last consumable (C4), `LePremierSang` keeps paying (H4), `LAgentDouble` sabotage decrements every trick (H5), `forge_tierce` uses Planet.use (H6), `LaVoute` max() floor (H7), atomic save (H10), undo clears legal-cards cache (H11), 19 dead-flag fixes (announce_x2 / no_belote_rebelote / republicain_wild / start_coinched / partner_throws_trick / gold_seal_aces / show_partner_bid_tendency / etc.) (409 tests).
1127
+ - **[2.7.1] — 2026-05-04.** Cleared last `getattr(state, "_X", False)` boss-flag reads; deleted 17 redundant underscore property aliases on GameState; coverage for L'Anarchie rotation, endless mode, undo stack (390 tests).
1128
+ - **[2.7.0] — 2026-05-04.** Responsive layout overhaul: COMPACT (80×32) / STANDARD (96×38) / SPACIOUS (120×48) presets, adaptive card art, vertical centering, alt-screen entered before menu loop, KeyReader `__new__` factory regression fixed.
1129
+ - **[2.6.0] — 2026-05-04.** BelAtro Phase 0 audit + 6 feature areas: critical fixes (seeded `_rng`, OVERLAY-key UNDO collision, chute-config), `Suit.TOUT_ATOUT` enum value, coinche/surcoinche player flow, `BeloteAnnouncedEvent` emission, `Rarity`/`fusable`/`BelAtroRun` new fields, contract jokers (CoincheStack, ToutStreak), annonce jokers (TierceCharger, RebeloteEcho, QuinteRoyale), CapotInsurance + TierceForge vouchers, La Maison-Dieu + Le Diable tarots, marseille + coinche decks, trust-tier scaling, BetrayalArc boss, ante themes (CafeAnte, TournoiAnte), endless mode, joker fusion (361 tests).
1130
+ - **[2.5.6] — 2026-05-03.** Massive crash + logic fix sweep: tarot `random` import, `BelAtroRun.consumables` list, `get_available_jokers(None)` accepts None, save schema merge, EventBus.unsubscribe ValueError suppress; LaSentinelle / LeFanatique / sans_atout_wins / LePuriste / LaSentinelleP / LeBanquier / LeDernierMot logic fixes; boss flag enforcement (LAvocat auto_coinche, LeDivorce trust lock, LAgentDoubleBoss 3-trick sabotage, LeBrouillard HUD hide, LeFantomePartenaire hand hide); permanent Tarot / Planet bonuses applied; corrupted joker negative effects wired; LaTelescope / LeGrimoire / LEncyclopedie / LesCartesDorees / LaBalance vouchers implemented.
1131
+ - **[2.5.5] — 2026-05-03.** BelAtro run-loop crash fixes: `drive_round` signature mismatch, TrickWonEvent context fields, `consumable_slots`, legal-cards cache decorator, `[I]` HUD key.
1132
+ - **[2.5.4] — 2026-05-03.** `read_timeout` missing on KeyReader, IndentationError in trick_timing, `LePremierSang` +2 Mult additive.
1133
+ - **[2.5.3] — 2026-05-03.** IndentationError in `game.py` prevented startup.
1134
+ - **[2.5.2] — 2026-05-02.** Critical winner-detection bug fixed; scoring engine desync resolved (state-differential trick points); declaration scoring uses real values; partner trust wired into BelAtro loop; AI personalities in bidding; dynamic partner hand visibility.
1135
+ - **[2.5.1] — 2026-05-02.** Collection discovery on-encounter (not on-unlock); auto-save after run init + shop; trick visibility timing fix.
1136
+ - **[2.5.0] — 2026-05-02.** Functional state architecture (immutable joker state in GameState); 50+ new BelAtro tests (276 total); benchmark suite in `scripts/benchmark.py`; scoring engine + Hard AI refactors; EventBus reliability.
1137
+ - **[2.4.1] — 2026-05-02.** BelAtro rules-UI rendering bug + dynamic formatting.
1138
+ - **[2.4.0] — 2026-05-02.** BelAtro Collection (Almanac); full 17-boss suite; Hard AI overhaul (Dix de Der awareness, 2-ply void inference); "You"/"Partner" labels; hotseat removed; unified save paths; menu streamlining.
1139
+ - **[2.3.3] — 2026-05-02.** BelAtro integrated into main menu; WIP decks completed (L'Ermite, Le Vétéran, Le Flambeur); 50+ jokers fully implemented; alt-screen for BelAtro; dynamic UI centering; `RoundEndEvent` complete snapshot.
1140
+ - **[2.3.1] — 2026-05-01.** SA trump-None handling; full Tout Atout / Sans Atout card values; Coinche (×2) / Surcoinche (×4) multipliers; sequence detection refactor; project-wide type safety (250+ mypy errors resolved).
1141
+ - **[2.3.0] — 2026-05-01.** Full Tarot system (10 cards); complete Voucher set (9 vouchers); dynamic deck-building; Corrupted Joker mechanics; +52 new tests (165 total); opponent Coinche; `[U]` consumable key; render cache size 2048.
1142
+
1143
+ ### 2.0–2.2 Series
1144
+
1145
+ - **[2.2.0] — 2026-04-30.** BelAtro score-overlay toggle on `[I]`; 4th-card trick visibility fix; `on_card_played` reconstructs from `completed_tricks[-1]`.
1146
+ - **[2.0.1] — 2026-04-30.** Le Républicain deck fully playable; Le Carnet voucher implemented; card-count invariant assertion; immediate trick-mat render.
1147
+ - **[2.0.0] — 2026-04-30.** **BelAtro Expansion launched.** 8-Ante run structure, Chips × Multiplier scoring, 50+ Jokers, Planets / Tarots / Vouchers, Partner Trust system, 10+ Boss Blinds, `EventBus` architecture, save/load, dedicated BelAtro HUD + Shop UI; `belatro` CLI entry point.
1148
+
1149
+ ### 1.x Series
1150
+
1151
+ - **[1.1.0] — 2026-04-30.** Incremental AI void inference; Windows `read_timeout` for KeyReader; `ScoringBreakdown` refactor (table vs credit pts); primitive cache keys for `trick_winner_seat`; centralized `_TRICK_ROW_OFFSETS`; chute-vs-litige comparison fix; circular import via observer callback.
1152
+ - **[1.0.0] — 2026-04-30.** Official FFBelote rules compliance; **Litige** tie-break implemented; **Capot** = 252 (152 + 100 bonus) with all declarations to winners; chute scoring 162 + defender_declarations; tie-breaker rounds beyond target; `CAPOT_BASE` 250→252.
1153
+
1154
+ ### 0.9.x Series — Pre-1.0
1155
+
1156
+ - **[0.9.9] — 2026-04-28.** Chute scoring fix (taker declarations annulled, not transferred); Belote announcement no longer re-fires; hand-sort persisted via `prompt_card` return; multi-byte UTF-8 input; project-wide type safety (70 mypy + 243 ruff resolved).
1157
+ - **[0.9.8] — 2026-04-28.** Overtrump rule enforcement; real-time AI void inference; `TOTAL_POINTS` = 152; interruptible announcements (Space/Esc); adaptive `patch_trick_card` coordinates.
1158
+ - **[0.9.7] — 2026-04-27.** Pre-game hand preview; strategic hand sorting; medium AI void tracking; configurable per-seat AI; `IllegalMoveError`; centralized `Config`; XDG-compliant stats path; module-global state replaced with managers.
1159
+ - **[0.9.6] — 2026-04-27.** Per-difficulty win-rate stats; session-stats panel; medium-AI longest-suit leading; Hard AI 2-ply lookahead + void inference; non-UTF-8 graceful degradation; UI modularization into `belote.ui` package.
1160
+ - **[0.9.5] — 2026-04-26.** Hand sorting on `O`; quick-help on `?`; mute toggle on `M` (later removed in 3.3.4); declaration announcements; incremental rendering (zero flicker); LRU caching for legal cards; CLI `--version`.
1161
+ - **[0.9.4] — 2026-04-26.** Round-score history (`H`); global stats screen; hotseat 2P mode (later removed in 2.4.0); undo support (`Z`); terminal sound effects (later removed in 3.3.4); seat-specific AI difficulty; animation skipping.
1162
+ - **[0.9.2] — 2026-04-26.** Fixed `ValueError: max() iterable argument is empty` in `legal_cards` when trump is led.
1163
+ - **[0.9.1] — 2026-04-26.** Fixed `ModuleNotFoundError` from absolute imports in `game.py`.
1164
+ - **[0.9.0] — 2026-04-26.** Initial release: full-screen TUI for French Belote; AI Easy/Medium/Hard; bidding + scoring; EN/FR rules; `src` layout; GitHub sync.