belote-cli 4.8.1__tar.gz → 4.8.2__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 (163) hide show
  1. {belote_cli-4.8.1 → belote_cli-4.8.2}/CHANGELOG.md +110 -0
  2. {belote_cli-4.8.1 → belote_cli-4.8.2}/DEVELOPMENT.md +1 -1
  3. {belote_cli-4.8.1 → belote_cli-4.8.2}/PKG-INFO +4 -4
  4. {belote_cli-4.8.1 → belote_cli-4.8.2}/README.md +3 -3
  5. {belote_cli-4.8.1 → belote_cli-4.8.2}/pyproject.toml +1 -1
  6. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/__init__.py +1 -1
  7. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/ai.py +101 -20
  8. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/core/scoring.py +30 -2
  9. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/engine/round_driver.py +14 -11
  10. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/progression/unlocks.py +10 -0
  11. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/game.py +11 -1
  12. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/scoring.py +11 -0
  13. belote_cli-4.8.2/tests/belatro/test_competition_malediction.py +118 -0
  14. belote_cli-4.8.2/tests/belatro/test_handler_registry.py +110 -0
  15. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_phase1_plumbing.py +37 -0
  16. belote_cli-4.8.2/tests/test_ai_deluge_voids.py +144 -0
  17. belote_cli-4.8.2/tests/test_ai_sans_atout.py +127 -0
  18. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_belote.py +39 -0
  19. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_bidding_all_pass.py +75 -0
  20. {belote_cli-4.8.1 → belote_cli-4.8.2}/.claude/settings.local.json +0 -0
  21. {belote_cli-4.8.1 → belote_cli-4.8.2}/.gitignore +0 -0
  22. {belote_cli-4.8.1 → belote_cli-4.8.2}/.python-version +0 -0
  23. {belote_cli-4.8.1 → belote_cli-4.8.2}/LICENSE +0 -0
  24. {belote_cli-4.8.1 → belote_cli-4.8.2}/scripts/benchmark.py +0 -0
  25. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/__init__.py +0 -0
  26. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/a11y.py +0 -0
  27. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/achievements.py +0 -0
  28. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/ansi.py +0 -0
  29. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/__init__.py +0 -0
  30. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/core/__init__.py +0 -0
  31. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/core/economy.py +0 -0
  32. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/core/round_ledger.py +0 -0
  33. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/core/run_state.py +0 -0
  34. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/engine/__init__.py +0 -0
  35. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/engine/event_bus.py +0 -0
  36. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/engine/modifier_patch.py +0 -0
  37. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/ghost_run.py +0 -0
  38. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/__init__.py +0 -0
  39. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/base.py +0 -0
  40. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/__init__.py +0 -0
  41. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/annonces.py +0 -0
  42. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/coinche.py +0 -0
  43. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/contract.py +0 -0
  44. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  45. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/economy.py +0 -0
  46. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  47. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
  48. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  49. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  50. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  51. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  52. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/planets.py +0 -0
  53. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/registry.py +0 -0
  54. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/tarots.py +0 -0
  55. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/items/vouchers.py +0 -0
  56. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/main.py +0 -0
  57. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/partner/__init__.py +0 -0
  58. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/partner/partner_state.py +0 -0
  59. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/partner/personality.py +0 -0
  60. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/partner/trust.py +0 -0
  61. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/progression/__init__.py +0 -0
  62. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/progression/save.py +0 -0
  63. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/run/__init__.py +0 -0
  64. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/run/ante.py +0 -0
  65. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/run/ante_themes.py +0 -0
  66. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/run/boss.py +0 -0
  67. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/run/decks.py +0 -0
  68. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/run/shop.py +0 -0
  69. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/run_summary.py +0 -0
  70. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/ui/__init__.py +0 -0
  71. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/ui/announce.py +0 -0
  72. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/ui/collection.py +0 -0
  73. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/ui/consumables.py +0 -0
  74. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/ui/history.py +0 -0
  75. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/ui/hud.py +0 -0
  76. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/ui/inventory.py +0 -0
  77. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/ui/menu.py +0 -0
  78. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/ui/rules.py +0 -0
  79. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/ui/shop.py +0 -0
  80. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/belatro/ui/trust_bar.py +0 -0
  81. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/config.py +0 -0
  82. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/context.py +0 -0
  83. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/deck.py +0 -0
  84. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/gameflow.py +0 -0
  85. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/input.py +0 -0
  86. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/main.py +0 -0
  87. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/replay.py +0 -0
  88. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/rules.py +0 -0
  89. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/stats.py +0 -0
  90. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/themes.py +0 -0
  91. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/ui/__init__.py +0 -0
  92. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/ui/anim.py +0 -0
  93. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/ui/announce.py +0 -0
  94. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/ui/fit_guard.py +0 -0
  95. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/ui/layout.py +0 -0
  96. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/ui/menu.py +0 -0
  97. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/ui/prompts.py +0 -0
  98. {belote_cli-4.8.1 → belote_cli-4.8.2}/src/belote/ui/render.py +0 -0
  99. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/__init__.py +0 -0
  100. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/__init__.py +0 -0
  101. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_belatro.py +0 -0
  102. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_boss_contracts.py +0 -0
  103. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_boss_modifiers_integration.py +0 -0
  104. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_collection_logic.py +0 -0
  105. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_consumables_ui.py +0 -0
  106. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_contract_unlocks.py +0 -0
  107. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_dead_flag_fixes.py +0 -0
  108. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_deck_variants.py +0 -0
  109. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_decks_4_5.py +0 -0
  110. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_endless.py +0 -0
  111. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_event_bus.py +0 -0
  112. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_ghost_run.py +0 -0
  113. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_heist.py +0 -0
  114. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_history_overlay.py +0 -0
  115. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_hud_synergy.py +0 -0
  116. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_hud_toggle.py +0 -0
  117. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_inventory_overlay.py +0 -0
  118. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_joker_callouts.py +0 -0
  119. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_joker_contracts.py +0 -0
  120. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_jokers_4_5.py +0 -0
  121. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_partner_jokers.py +0 -0
  122. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_partner_trust.py +0 -0
  123. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_phase0_coverage.py +0 -0
  124. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_phase2_content.py +0 -0
  125. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_phase3_meta.py +0 -0
  126. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_progression.py +0 -0
  127. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_round_driver.py +0 -0
  128. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_run_summary.py +0 -0
  129. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_shop_animations.py +0 -0
  130. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_shop_empty_pools.py +0 -0
  131. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_slot_machine_tally.py +0 -0
  132. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/belatro/test_voucher_idempotency.py +0 -0
  133. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/perf_baselines.json +0 -0
  134. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_a11y.py +0 -0
  135. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_achievements.py +0 -0
  136. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_ai.py +0 -0
  137. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_alt_screen_scroll.py +0 -0
  138. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_anim_helpers.py +0 -0
  139. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_announce_stats.py +0 -0
  140. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_ansi_helpers.py +0 -0
  141. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_belote_stinger.py +0 -0
  142. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_benchmark_smoke.py +0 -0
  143. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_declaration_tiebreak.py +0 -0
  144. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_extended.py +0 -0
  145. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_game_logic.py +0 -0
  146. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_gameflow.py +0 -0
  147. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_hand_auto_sort.py +0 -0
  148. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_history_overlay_cache.py +0 -0
  149. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_input_eof.py +0 -0
  150. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_input_wasd.py +0 -0
  151. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_invariants.py +0 -0
  152. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_layout.py +0 -0
  153. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_new_coverage.py +0 -0
  154. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_no_color.py +0 -0
  155. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_official_rules.py +0 -0
  156. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_perf_regression.py +0 -0
  157. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_properties.py +0 -0
  158. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_render_diff.py +0 -0
  159. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_render_felt_polish.py +0 -0
  160. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_replay.py +0 -0
  161. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_undo.py +0 -0
  162. {belote_cli-4.8.1 → belote_cli-4.8.2}/tests/test_winner_glow.py +0 -0
  163. {belote_cli-4.8.1 → belote_cli-4.8.2}/uv.lock +0 -0
@@ -5,6 +5,116 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [4.8.2] - 2026-05-25
9
+
10
+ Audit-driven correctness + perf-hygiene release. A deep multi-lane audit
11
+ (bug hunt + logic + performance + game-mechanics-implementation review)
12
+ surfaced two real Sans Atout AI bugs, a joker-registry footgun, a
13
+ joker-state alias hazard, and a handful of low-severity loose ends. All
14
+ findings landed; no rules changes; AI play is more accurate under SA.
15
+
16
+ ### Fixed
17
+
18
+ - **(B1, HIGH) Hard AI no longer mis-scores off-suit cards as "winning" under
19
+ Sans Atout** (`src/belote/ai.py::_score_winning_strategy`). Pre-4.8.2 the
20
+ candidate's rank was `_NONTRUMP_ORDER[card.rank]` regardless of suit, while
21
+ `highest_rank` was computed from lead-suit cards only. An off-suit Ace
22
+ (rank 7) compared against a lead-suit Jack (rank 3) fired `win_bonus` even
23
+ though `_card_beats` (game.py) forbids off-suit cards from winning under SA.
24
+ The AI burned high off-suit cards when forced to discard. Fix: pin `rank=-1`
25
+ for off-suit candidates so `rank > highest_rank` is unreachable.
26
+ - **(B2, MED) 2-ply opponent-trump-fear penalty suppressed under SA**
27
+ (`src/belote/ai.py::_score_winning_strategy`). The penalty assumes opponents
28
+ can trump the winner. Under SA `trump is None`, so the conditions
29
+ `trump not in opp_voids` (always True for `None`) and
30
+ `card.suit != trump` (always True) both passed — the AI penalized any
31
+ winning play by -8 when the right-hand opponent was known void in lead.
32
+ Wrapped in `if not is_sa:` so the entire block is moot under SA.
33
+ - **(B3, MED) `ScoreAccumulator._handler_index` rebuilds on `_jokers` mutation**
34
+ (`src/belote/belatro/core/scoring.py::_fire_jokers`). Pre-4.8.2 the lazy
35
+ gate was `not self._handler_index and self._jokers`, which is False once
36
+ `attach_jokers([])` ran (the index has 5 truthy keys with empty lists).
37
+ Tests that appended to `_jokers` after the empty attach were silently
38
+ ignored. Fix: identity-set comparison (`_attached_joker_ids`) against the
39
+ current `_jokers` list — any drift triggers `attach_jokers(self._jokers)`.
40
+ - **(B4, MED) `state._joker_state` alias preserved across post-trigger
41
+ mutations** (`src/belote/belatro/engine/round_driver.py`). The post-
42
+ `trigger_round_start` agent_double sabotage block used
43
+ `replace(state, _joker_state={...})`, which broke the ledger alias and
44
+ forced the heist block into a defensive dual-write. Replaced with in-place
45
+ mutation; the heist dual-write is gone too. Classic-Belote reads via
46
+ `state._joker_state.get(...)` once again see live ledger mutations for the
47
+ rest of the round.
48
+ - **(B5, LOW) `_compute_belote_points` asserts the announcer↔tracker
49
+ invariant** (`src/belote/scoring.py`). Hand-built states with
50
+ `belote_announcer` set but `belote_tracker[0]=False` would silently award
51
+ belote points; now raises AssertionError.
52
+ - **(B6, LOW) `ContractReward.honor_bonus` default switched to `int 0`**
53
+ (`src/belote/belatro/core/scoring.py`). Default was `0.0` (float) but the
54
+ TypedDict declared `int` — worked by numeric duck-typing but drifted from
55
+ the contract. No behavior change.
56
+
57
+ ### Performance
58
+
59
+ - **(P1) Hard AI rank lookups consolidated** (`src/belote/ai.py::_hard_play`).
60
+ Pre-4.8.2 `_score_winning_strategy` called `trick_rank(card, trump, ...)`
61
+ once per candidate plus once per visible partner card — O(legal ×
62
+ partner_hand) recomputations. Now `_hard_play` builds `candidate_ranks`
63
+ and `partner_ranks` once, threaded through `_score_card_play` and
64
+ `_score_winning_strategy` as new kwargs. The SA off-suit `-1` short-circuit
65
+ from B1 is baked into the cache, so the canonical comparison is a dict
66
+ lookup.
67
+ - **(P2) Dropped redundant `from collections import Counter` local in
68
+ `_hard_play`** — `Counter` is already imported at module top.
69
+
70
+ ### Internal / Doc
71
+
72
+ - **(L1) `GameState._joker_state` docstring spells out the BelAtro
73
+ ledger-alias contract** (`src/belote/game.py`). After
74
+ `trigger_round_start` the dict is shared with `acc._ledger.joker_state`;
75
+ round-driver mutates in place to preserve the alias. Classic callers
76
+ outside that scope still use `replace()`.
77
+ - **(L2) `UnlockTracker.on_event` logs at DEBUG on unhandled event types**
78
+ (`src/belote/belatro/progression/unlocks.py`). Silent no-op was the
79
+ contract, but a new unlock keyed off `TrickWonEvent` or `BeloteAnnouncedEvent`
80
+ would silently fail — the log line makes the miss visible under
81
+ `BELOTE_LOG_LEVEL=DEBUG`.
82
+ - **(L3) `AIMemory.last_partner_hand_key` includes `hide_partner_hand`**
83
+ (`src/belote/ai.py`). Memo invalidates if the boss flag flips mid-round.
84
+ Defensive — no boss toggles it today, but a future voucher/joker that
85
+ changes visibility mid-round would otherwise see stale cached partner
86
+ cards.
87
+
88
+ ### Tests
89
+
90
+ - **New file `tests/test_ai_sans_atout.py`** (3 tests) — pins B1 (off-suit
91
+ Ace not burned), B1 unit-level (no `win_bonus` for off-suit candidate),
92
+ B2 (2-ply penalty suppressed under SA).
93
+ - **New file `tests/test_ai_deluge_voids.py`** (4 tests) — pins void
94
+ inference under La Déluge alone, Le Républicain alone, and both
95
+ combined; plus the negative case (non-wild off-lead card still proves
96
+ void under Le Républicain).
97
+ - **New file `tests/belatro/test_handler_registry.py`** (3 tests) — B3
98
+ regression pins: `attach_jokers([])` + append, attach + replace, and the
99
+ no-drift no-rebuild sanity check.
100
+ - **New file `tests/belatro/test_competition_malediction.py`** (3 tests)
101
+ — documents the actual stacking behaviour of La Compétition + La
102
+ Malédiction under `score_round`.
103
+ - **Extended `tests/test_belote.py`** with B5 assertion test.
104
+ - **Extended `tests/belatro/test_phase1_plumbing.py`** with B4 alias
105
+ survival test.
106
+ - **Extended `tests/test_bidding_all_pass.py`** with end-to-end litige
107
+ pool consume after two all-pass redeals (T1).
108
+ - Test count baseline: **1044** (4.8.1 had 1028; +16 in 4.8.2).
109
+
110
+ ### Speculative perf (measured, not implemented)
111
+
112
+ Two speculative items from the plan (P4 `legal_cards` key-build cost; P5
113
+ `ledger.transactional` wrapper overhead) were instrumented and closed.
114
+ Measurement: legal_cards is ~3 % of round time (below the 5 % threshold to
115
+ justify a hand-id-on-tuple refactor); BelAtro round mean is 13.69 ms /
116
+ p95 18.49 ms (73 rounds/sec) — well below any user-visible jank threshold.
117
+
8
118
  ## [4.8.1] - 2026-05-21
9
119
 
10
120
  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.
@@ -84,7 +84,7 @@ PYTHONPATH=src mypy --strict src/
84
84
  # Linting (0 violations expected)
85
85
  ruff check src/ tests/
86
86
 
87
- # Full test suite (1028 tests expected)
87
+ # Full test suite (1044 tests expected)
88
88
  PYTHONPATH=src pytest
89
89
  ```
90
90
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: belote-cli
3
- Version: 4.8.1
3
+ Version: 4.8.2
4
4
  Summary: A 4-player terminal card game
5
5
  Project-URL: Homepage, https://github.com/ElysiumDisc/belote
6
6
  Project-URL: Repository, https://github.com/ElysiumDisc/belote
@@ -264,7 +264,7 @@ belote/
264
264
  │ ├── input.py # Platform-dispatched key reader and interruptible sleep
265
265
  │ ├── stats.py # Global and session statistics tracking
266
266
  │ └── rules.py # Game rules content
267
- ├── tests/ # Comprehensive test suite (1028 tests)
267
+ ├── tests/ # Comprehensive test suite (1044 tests)
268
268
  ├── scripts/ # Performance benchmarks
269
269
  ├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
270
270
  ├── LICENSE # MIT License
@@ -279,14 +279,14 @@ belote/
279
279
  PYTHONPATH=src pytest
280
280
  ```
281
281
 
282
- Currently **1028 tests** passing with 100% coverage on game-logic modules (4.8.1).
282
+ Currently **1044 tests** passing with 100% coverage on game-logic modules (4.8.2).
283
283
 
284
284
  ## Technical Integrity
285
285
 
286
286
  The codebase is strictly validated with the following tools:
287
287
  - **mypy**: 0 errors (strict type safety)
288
288
  - **ruff**: 0 violations (linting & formatting)
289
- - **pytest**: 1028/1028 passed
289
+ - **pytest**: 1044/1044 passed
290
290
  - **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
291
291
  - **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
292
292
 
@@ -221,7 +221,7 @@ belote/
221
221
  │ ├── input.py # Platform-dispatched key reader and interruptible sleep
222
222
  │ ├── stats.py # Global and session statistics tracking
223
223
  │ └── rules.py # Game rules content
224
- ├── tests/ # Comprehensive test suite (1028 tests)
224
+ ├── tests/ # Comprehensive test suite (1044 tests)
225
225
  ├── scripts/ # Performance benchmarks
226
226
  ├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
227
227
  ├── LICENSE # MIT License
@@ -236,14 +236,14 @@ belote/
236
236
  PYTHONPATH=src pytest
237
237
  ```
238
238
 
239
- Currently **1028 tests** passing with 100% coverage on game-logic modules (4.8.1).
239
+ Currently **1044 tests** passing with 100% coverage on game-logic modules (4.8.2).
240
240
 
241
241
  ## Technical Integrity
242
242
 
243
243
  The codebase is strictly validated with the following tools:
244
244
  - **mypy**: 0 errors (strict type safety)
245
245
  - **ruff**: 0 violations (linting & formatting)
246
- - **pytest**: 1028/1028 passed
246
+ - **pytest**: 1044/1044 passed
247
247
  - **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
248
248
  - **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
249
249
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "belote-cli"
7
- version = "4.8.1"
7
+ version = "4.8.2"
8
8
  description = "A 4-player terminal card game"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
- __version__ = "4.8.1"
1
+ __version__ = "4.8.2"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -39,10 +39,12 @@ class AIMemory:
39
39
  # (completed_count, current_trick_len) of the last _update_voids call.
40
40
  # Lets us skip re-scanning a stable transient trick on each decision.
41
41
  self.last_voids_key: tuple[int, int] | None = None
42
- # (completed_count, current_trick_len) of the last partner_hand
43
- # rebuild. The partner's hand only changes when they play a card or
44
- # a new round starts; same memo pattern as `last_voids_key`.
45
- self.last_partner_hand_key: tuple[int, int] | None = None
42
+ # (completed_count, current_trick_len, hide_partner_hand) of the
43
+ # last partner_hand rebuild. The partner's hand only changes when
44
+ # they play a card or a new round starts; same memo pattern as
45
+ # `last_voids_key`. The third element (`hide_partner_hand`) is the
46
+ # 4.8.2 L3 addition — invalidates the memo if the boss flag flips.
47
+ self.last_partner_hand_key: tuple[int, int, bool] | None = None
46
48
 
47
49
 
48
50
  class AIPlayer:
@@ -111,14 +113,18 @@ class AIPlayer:
111
113
  # It only changes when partner plays a card (i.e. when
112
114
  # (completed_count, current_count) advances). Memo on the same key
113
115
  # the void cache uses; skip the rebuild on a no-op repeat call.
114
- partner_key = (completed_count, current_count)
116
+ #
117
+ # 4.8.2 (L3): include `hide_partner_hand` in the memo key so the
118
+ # memo invalidates if the boss flag flips mid-round. The flag is
119
+ # set at round start in current code, but the assumption is
120
+ # defensive — a future boss or voucher that toggles visibility
121
+ # mid-round would otherwise see stale cached partner cards.
122
+ hide = bool(state.boss_modifiers.hide_partner_hand)
123
+ partner_key: tuple[int, int, bool] = (completed_count, current_count, hide)
115
124
  if self.memory.last_partner_hand_key != partner_key:
116
125
  p = partner(self.seat)
117
126
  self.memory.partner_hand.clear()
118
- if (
119
- state.phase in (Phase.PLAYING, Phase.SCORING)
120
- and not state.boss_modifiers.hide_partner_hand
121
- ):
127
+ if state.phase in (Phase.PLAYING, Phase.SCORING) and not hide:
122
128
  for card in state.hand_of(p):
123
129
  self.memory.partner_hand.add(card)
124
130
  self.memory.last_partner_hand_key = partner_key
@@ -597,8 +603,8 @@ class AIPlayer:
597
603
  # Precompute per-call counters used by every scoring branch — pre-3.1.0
598
604
  # these were recomputed per candidate card (n×4 walks of the hand and
599
605
  # memory.played for each legal card).
600
- from collections import Counter
601
-
606
+ # 4.8.2 (P2): dropped redundant `from collections import Counter`
607
+ # local import — `Counter` is already imported at module top.
602
608
  my_hand = state.hand_of(self.seat)
603
609
  hand_suit_counts: dict[Suit, int] = Counter(c.suit for c in my_hand)
604
610
  # Under Tout Atout every card is a trump; under a normal contract
@@ -648,6 +654,28 @@ class AIPlayer:
648
654
  next_opp = self.seat.next_seat()
649
655
  opp_voids = self.memory.known_voids.get(next_opp, set())
650
656
 
657
+ # 4.8.2 (P1): pre-compute per-candidate rank once. `_score_winning_strategy`
658
+ # called `trick_rank(card, trump, self._se)` once for the candidate and
659
+ # again for every visible partner card on every legal candidate
660
+ # iteration — O(legal × partner_hand) recomputations. Building dicts
661
+ # here collapses that to O(legal + partner_hand) lookups.
662
+ # Under SA, off-suit candidates get rank -1 (B1 short-circuit) so they
663
+ # cannot win the trick; lead-suit candidates use `_NONTRUMP_ORDER`.
664
+ lead_suit_for_ranks = trick[0].card.suit if trick else None
665
+ if is_sa:
666
+ candidate_ranks: dict[Card, int] = {
667
+ c: (_NONTRUMP_ORDER[c.rank] if c.suit == lead_suit_for_ranks else -1)
668
+ for c in legal
669
+ }
670
+ partner_ranks: dict[Card, int] = {
671
+ c: _NONTRUMP_ORDER[c.rank] for c in self.memory.partner_hand
672
+ }
673
+ else:
674
+ candidate_ranks = {c: trick_rank(c, trump, self._se) for c in legal}
675
+ partner_ranks = {
676
+ c: trick_rank(c, trump, self._se) for c in self.memory.partner_hand
677
+ }
678
+
651
679
  # Score each legal card by expected outcome
652
680
  best_card = legal[0]
653
681
  best_score: float = -999.0
@@ -665,6 +693,8 @@ class AIPlayer:
665
693
  is_sa=is_sa,
666
694
  highest_rank=highest_rank,
667
695
  opp_voids=opp_voids,
696
+ card_rank=candidate_ranks[card],
697
+ partner_ranks=partner_ranks,
668
698
  )
669
699
  if score > best_score:
670
700
  best_score = score
@@ -685,8 +715,18 @@ class AIPlayer:
685
715
  is_sa: bool = False,
686
716
  highest_rank: int = -1,
687
717
  opp_voids: set[Suit] | None = None,
718
+ *,
719
+ card_rank: int | None = None,
720
+ partner_ranks: dict[Card, int] | None = None,
688
721
  ) -> float:
689
- """Score a card play decision with advanced heuristics."""
722
+ """Score a card play decision with advanced heuristics.
723
+
724
+ 4.8.2 (P1): `card_rank` and `partner_ranks` are pre-computed by
725
+ `_hard_play` once per call and threaded through so the per-candidate
726
+ recomputation of `trick_rank` / `_NONTRUMP_ORDER` is gone. Both
727
+ default to `None` for standalone callers (tests) — the downstream
728
+ helper falls back to deriving them itself.
729
+ """
690
730
  score = 0.0
691
731
  # 4.1.1: per-card point value must honor zero-rank boss flags
692
732
  # (kings_zero / aces_zero / jacks_zero / tens_zero / ban_clubs).
@@ -718,6 +758,8 @@ class AIPlayer:
718
758
  is_sa=is_sa,
719
759
  highest_rank=highest_rank,
720
760
  opp_voids=opp_voids,
761
+ card_rank=card_rank,
762
+ partner_ranks=partner_ranks,
721
763
  )
722
764
 
723
765
  def _score_leading_strategy(
@@ -778,6 +820,8 @@ class AIPlayer:
778
820
  *,
779
821
  highest_rank: int | None = None,
780
822
  opp_voids: set[Suit] | None = None,
823
+ card_rank: int | None = None,
824
+ partner_ranks: dict[Card, int] | None = None,
781
825
  ) -> float:
782
826
  """Heuristics for trying to win the trick or ducking.
783
827
 
@@ -788,9 +832,31 @@ class AIPlayer:
788
832
  per card was pure waste. Both default to `None` so standalone
789
833
  callers (tests, ad-hoc heuristics) can omit them and the function
790
834
  will derive them itself.
835
+
836
+ 4.8.2 (P1): `card_rank` and `partner_ranks` are similarly hoisted.
837
+ `card_rank` is the candidate's pre-computed rank under the active
838
+ scale (`trick_rank` or `_NONTRUMP_ORDER`, with the B1 off-suit-SA
839
+ short-circuit baked in). `partner_ranks` is the rank lookup for
840
+ every visible card in `self.memory.partner_hand`. Both default to
841
+ `None` so standalone callers (tests) can omit them; the function
842
+ derives them itself.
791
843
  """
792
844
  score = 0.0
793
- rank = trick_rank(card, trump, self._se) if not is_sa else _NONTRUMP_ORDER[card.rank]
845
+ lead_suit = trick[0].card.suit
846
+ # B1 (4.8.2): under Sans Atout an off-suit card cannot win the trick
847
+ # (per `_card_beats` in game.py). Pin `rank = -1` for off-suit
848
+ # candidates so `rank > highest_rank` is unreachable — pre-4.8.2 an
849
+ # off-suit Ace got rank=7 against highest_rank≤7 (lead-suit only) and
850
+ # fired `win_bonus`, causing the AI to dump high off-suit cards under
851
+ # SA when forced to discard.
852
+ if card_rank is not None:
853
+ rank = card_rank
854
+ elif is_sa and card.suit != lead_suit:
855
+ rank = -1
856
+ elif is_sa:
857
+ rank = _NONTRUMP_ORDER[card.rank]
858
+ else:
859
+ rank = trick_rank(card, trump, self._se)
794
860
  is_last_trick = len(state.completed_tricks) == 7
795
861
  if highest_rank is None:
796
862
  highest_rank = max(
@@ -799,13 +865,12 @@ class AIPlayer:
799
865
  if not is_sa
800
866
  else _NONTRUMP_ORDER[tc.card.rank]
801
867
  for tc in trick
802
- if not is_sa or tc.card.suit == trick[0].card.suit
868
+ if not is_sa or tc.card.suit == lead_suit
803
869
  ),
804
870
  default=-1,
805
871
  )
806
872
  if opp_voids is None:
807
873
  opp_voids = self.memory.known_voids.get(self.seat.next_seat(), set())
808
- lead_suit = trick[0].card.suit
809
874
  p = partner(self.seat)
810
875
 
811
876
  if rank > highest_rank:
@@ -813,7 +878,12 @@ class AIPlayer:
813
878
  score += win_bonus
814
879
 
815
880
  # 2-PLY: If we are winning now, will the next player beat us?
816
- if len(trick) < 3:
881
+ # B2 (4.8.2): suppressed under Sans Atout — there is no trump,
882
+ # so opponents cannot trump our winner; the entire heuristic is
883
+ # moot. Pre-4.8.2 it fired (since `None not in opp_voids` is True
884
+ # and `card.suit != trump` is always True under SA), incorrectly
885
+ # penalizing winning plays by -8.
886
+ if not is_sa and len(trick) < 3:
817
887
  next_opp = self.seat.next_seat()
818
888
  if (
819
889
  next_opp != p
@@ -846,14 +916,25 @@ class AIPlayer:
846
916
  # happens to fall through to the same ordering today, but the SA
847
917
  # path is the canonical one and any future divergence in trick_rank
848
918
  # would silently break this heuristic.
919
+ #
920
+ # 4.8.2 (P1): rank lookups thread through `partner_ranks` (built
921
+ # once in `_hard_play`) and the candidate's own `card_rank` is now
922
+ # the canonical per-call rank. We compare against partner cards in
923
+ # the same suit using the SAME scale we used for the candidate — so
924
+ # for an off-suit-SA candidate (rank=-1) the comparison degrades to
925
+ # "any same-suit partner card strictly stronger" which is always
926
+ # true; harmless because the candidate doesn't win the trick.
849
927
  if self.memory.partner_hand:
850
- my_rank = (
851
- _NONTRUMP_ORDER[card.rank] if is_sa else trick_rank(card, trump, self._se)
852
- )
928
+ if is_sa:
929
+ my_rank = _NONTRUMP_ORDER[card.rank]
930
+ else:
931
+ my_rank = rank if card_rank is not None else trick_rank(card, trump, self._se)
853
932
  for partner_card in self.memory.partner_hand:
854
933
  if partner_card.suit != card.suit:
855
934
  continue
856
- if is_sa:
935
+ if partner_ranks is not None and partner_card in partner_ranks:
936
+ partner_rank = partner_ranks[partner_card]
937
+ elif is_sa:
857
938
  partner_rank = _NONTRUMP_ORDER[partner_card.rank]
858
939
  else:
859
940
  partner_rank = trick_rank(partner_card, trump, self._se)
@@ -94,6 +94,13 @@ class ScoreAccumulator:
94
94
  # cascade in `_fire_jokers`. Saves ~50–80μs/round (5 jokers × ~25
95
95
  # events × ~0.5μs per getattr).
96
96
  _handler_index: dict[str, list[tuple[Joker, Any]]] = field(default_factory=dict)
97
+ # 4.8.2 (B3): identity-set of the jokers passed to the most recent
98
+ # `attach_jokers` call. Compared against `set(map(id, self._jokers))`
99
+ # in `_fire_jokers` to detect post-attach mutation. `None` until the
100
+ # first attach; `set()` after `attach_jokers([])` (which is truthy-
101
+ # distinguishable from None). Excluded from `compare/repr` so test
102
+ # state-equality assertions don't break.
103
+ _attached_joker_ids: set[int] | None = field(default=None, compare=False, repr=False)
97
104
  # 4.6.2: RoundLedger lifetime is one round; created in
98
105
  # trigger_round_start, sealed in seal_round / materialize. Optional
99
106
  # because tests that construct an accumulator without calling
@@ -128,6 +135,12 @@ class ScoreAccumulator:
128
135
  continue # joker didn't override this handler
129
136
  index[method_name].append((joker, method))
130
137
  self._handler_index = index
138
+ # 4.8.2 (B3): remember exactly which joker objects we registered for so
139
+ # `_fire_jokers` can detect post-attach mutation of `self._jokers` and
140
+ # rebuild before firing. A no-override joker still gets tracked here
141
+ # (so it doesn't trigger spurious rebuilds), even though it has no
142
+ # entries in `_handler_index`.
143
+ self._attached_joker_ids = {id(j) for j in jokers}
131
144
 
132
145
  def _make_ledger(self, state: GameState) -> RoundLedger:
133
146
  from .round_ledger import RoundLedger
@@ -283,7 +296,18 @@ class ScoreAccumulator:
283
296
  # so the per-event getattr cascade is gone. Lazy build on first
284
297
  # use if a caller forgot to call `attach_jokers` (this also
285
298
  # protects tests that mutate `self._jokers` directly).
286
- if not self._handler_index and self._jokers:
299
+ #
300
+ # 4.8.2 (B3): also rebuild if the current `_jokers` list differs
301
+ # from the identity-set captured at the most recent attach. Pre-
302
+ # 4.8.2 the gate was `not _handler_index and _jokers` — False once
303
+ # `attach_jokers([])` runs (the index has 5 truthy keys with empty
304
+ # lists), so tests that appended to `_jokers` after the empty
305
+ # attach were silently ignored. Identity-set comparison catches
306
+ # that case and any other post-attach mutation.
307
+ if self._attached_joker_ids is None:
308
+ if self._jokers:
309
+ self.attach_jokers(self._jokers)
310
+ elif self._attached_joker_ids != {id(j) for j in self._jokers}:
287
311
  self.attach_jokers(self._jokers)
288
312
  for joker, method in self._handler_index.get(method_name, ()):
289
313
  # 4.6.4: per-joker transactional + EventBus-style isolation.
@@ -379,7 +403,11 @@ class ScoreAccumulator:
379
403
  # The Moon (Sans Atout): honor bonus per honor won
380
404
  if event.trump is None:
381
405
  moon_reward = self.contract_levels.get("sans_atout", {})
382
- honor_bonus = moon_reward.get("honor_bonus", 0.0)
406
+ # 4.8.2 (B6): default to `0` (int) so the type-narrowed
407
+ # path stays aligned with the `ContractReward.honor_bonus:
408
+ # int` TypedDict declaration. The 0.0 default worked by
409
+ # accident under Python's numeric duck-typing.
410
+ honor_bonus = moon_reward.get("honor_bonus", 0)
383
411
  if honor_bonus:
384
412
  honors = sum(
385
413
  1 for c in event.cards
@@ -179,6 +179,13 @@ def drive_round(
179
179
  # Sources: L'Agent Double boss (3 random tricks), BetrayalArc (tricks 4-8 via
180
180
  # agent_double_late_only flag), traitre joker (already populated above).
181
181
  # Reading the flag — not boss.id — keeps this site reusable.
182
+ #
183
+ # 4.8.2 (B4): mutate `state._joker_state` IN PLACE rather than via
184
+ # `replace(state, _joker_state={...})`. Post-`trigger_round_start` the
185
+ # dict IS `acc._ledger.joker_state`; preserving the alias means future
186
+ # ledger mutations remain visible to classic-Belote reads via
187
+ # `state._joker_state.get(...)`. Pre-4.8.2 this site broke the alias,
188
+ # forcing the heist code (below) into a defensive dual-write.
182
189
  if state.boss_modifiers.agent_double_active and not state._joker_state.get(
183
190
  "agent_double_tricks"
184
191
  ):
@@ -186,8 +193,7 @@ def drive_round(
186
193
  sabotage_tricks = frozenset(range(4, 9))
187
194
  else:
188
195
  sabotage_tricks = frozenset(rng.sample(range(1, 9), 3))
189
- new_jstate = {**state._joker_state, "agent_double_tricks": sabotage_tricks}
190
- state = replace(state, _joker_state=new_jstate)
196
+ state._joker_state["agent_double_tricks"] = sabotage_tricks
191
197
 
192
198
  # B4: Use partner trust-based difficulty for the North (partner) AI seat
193
199
  _north_diff_str = partner.difficulty_for(Seat.NORTH)
@@ -387,20 +393,17 @@ def drive_round(
387
393
  # AI takers never get the prompt — the UICallbacks default returns False
388
394
  # and the BelAtro override also gates on `state.taker == Seat.SOUTH`.
389
395
  #
390
- # Write to BOTH `acc._ledger.joker_state` (the accumulator's source of
391
- # truth read by process_event) and `state._joker_state` (the classic-
392
- # read path). These usually alias to the same dict `trigger_round_start`
393
- # installs the ledger's dict as `state._joker_state` — but the post-
394
- # trigger agent_double sabotage block at line ~175 does a
395
- # `replace(state, _joker_state={**state._joker_state, ...})` that breaks
396
- # the alias. Mutating both keeps both observers consistent regardless.
396
+ # 4.8.2 (B4): single-site mutation. `state._joker_state` IS
397
+ # `acc._ledger.joker_state` after `trigger_round_start` installs the
398
+ # alias and the post-trigger sabotage block above now preserves the
399
+ # alias too (replaced its `replace(state, _joker_state=...)` with
400
+ # in-place mutation). The pre-4.8.2 dual-write was a workaround for
401
+ # the broken alias; with the alias hardened, single-write is enough.
397
402
  if (
398
403
  state.phase == Phase.PLAYING
399
404
  and state.taker == Seat.SOUTH
400
405
  and ui_callbacks.prompt_heist(state)
401
406
  ):
402
- if acc is not None and acc._ledger is not None:
403
- acc._ledger.joker_state["heist_declared"] = True
404
407
  state._joker_state["heist_declared"] = True
405
408
 
406
409
  # Phase: PLAYING
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  from typing import TYPE_CHECKING
4
5
 
5
6
  from belote.game import Seat
@@ -10,6 +11,8 @@ from .save import Profile, SaveManager
10
11
  if TYPE_CHECKING:
11
12
  from ..engine.event_bus import EventBus
12
13
 
14
+ _log = logging.getLogger(__name__)
15
+
13
16
 
14
17
  class UnlockTracker:
15
18
  """
@@ -54,6 +57,13 @@ class UnlockTracker:
54
57
  dirty |= self._handle_round_end(event)
55
58
  elif isinstance(event, DeclarationScoredEvent):
56
59
  dirty |= self._handle_declaration(event)
60
+ else:
61
+ # 4.8.2 (L2): silent no-op was the contract pre-4.8.2, but it
62
+ # masked the case where a new unlock keys off a previously-
63
+ # unhandled event type (e.g. TrickWonEvent). A debug-level log
64
+ # lets future maintainers see misses via `BELOTE_LOG_LEVEL=DEBUG`
65
+ # without breaking prod (default log level is WARNING).
66
+ _log.debug("UnlockTracker: unhandled event type %s", type(event).__name__)
57
67
 
58
68
  if dirty:
59
69
  self.save_manager.save_profile(self.profile)
@@ -237,8 +237,18 @@ class GameState:
237
237
 
238
238
  # Same mutable-dict-inside-frozen contract as `belote_holders`. BelAtro
239
239
  # jokers and the round driver write per-round flags here. Values must be
240
- # scalars (bool/int/str) — see tests/belatro/test_phase1_plumbing.py
240
+ # scalars (bool/int/str/frozenset) — see tests/belatro/test_phase1_plumbing.py
241
241
  # `test_joker_state_only_contains_scalar_values`.
242
+ #
243
+ # 4.8.2 (B4 wiring note): inside a BelAtro round this dict is *aliased*
244
+ # to `ScoreAccumulator._ledger.joker_state` from `trigger_round_start`
245
+ # onward. The round driver mutates the dict IN PLACE (heist declaration,
246
+ # agent_double sabotage sets, etc.) to preserve the alias — `replace(
247
+ # state, _joker_state={...})` would shallow-copy the dict and break it,
248
+ # making subsequent ledger writes invisible to classic-Belote reads
249
+ # (ai.py, scoring.py, game.py via `state._joker_state.get(...)`).
250
+ # Outside the BelAtro round driver, classic callers should still rebuild
251
+ # via `replace()` to keep the frozen-dataclass discipline.
242
252
  _joker_state: dict[str, object] = field(default_factory=dict)
243
253
  _chips: int = 0
244
254
  _mult: float = 1.0
@@ -694,6 +694,17 @@ def _compute_belote_points(ctx: _ScoringContext) -> tuple[int, int, bool, bool]:
694
694
  Returns (taker_belote, defender_belote, taker_rebelote, defender_rebelote).
695
695
  """
696
696
  state = ctx.state
697
+ # 4.8.2 (B5): pin the invariant that `belote_announcer` and
698
+ # `belote_tracker[0]` advance in lockstep — game.py:_record_belote_announcement
699
+ # only assigns the announcer at the moment the tracker flips True. A state
700
+ # with announcer set but tracker[0]=False would be a corruption from
701
+ # replay tooling or a hand-built fixture, and the scoring branches below
702
+ # would credit belote points despite no announcement having fired. Surface
703
+ # this as a hard assertion rather than silently mis-scoring.
704
+ assert state.belote_announcer is None or state.belote_tracker[0], (
705
+ "belote_announcer set without belote_tracker[0] flipped — "
706
+ "GameState invariant violated."
707
+ )
697
708
  # Prefer the captured announcer seat (recorded at the moment belote was
698
709
  # declared) over re-deriving from belote_holders — under L'Anarchie the
699
710
  # current state.trump differs from the trump at announcement time, and