belote-cli 4.8.0__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.0 → belote_cli-4.8.2}/CHANGELOG.md +149 -0
  2. {belote_cli-4.8.0 → belote_cli-4.8.2}/DEVELOPMENT.md +1 -1
  3. {belote_cli-4.8.0 → belote_cli-4.8.2}/PKG-INFO +5 -5
  4. {belote_cli-4.8.0 → belote_cli-4.8.2}/README.md +4 -4
  5. {belote_cli-4.8.0 → belote_cli-4.8.2}/pyproject.toml +1 -1
  6. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/__init__.py +1 -1
  7. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ai.py +114 -28
  8. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/core/scoring.py +30 -2
  9. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/engine/round_driver.py +14 -11
  10. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/progression/unlocks.py +10 -0
  11. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/announce.py +4 -4
  12. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/game.py +11 -1
  13. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/scoring.py +11 -0
  14. belote_cli-4.8.2/tests/belatro/test_competition_malediction.py +118 -0
  15. belote_cli-4.8.2/tests/belatro/test_handler_registry.py +110 -0
  16. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_phase1_plumbing.py +37 -0
  17. belote_cli-4.8.2/tests/test_ai_deluge_voids.py +144 -0
  18. belote_cli-4.8.2/tests/test_ai_sans_atout.py +127 -0
  19. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_belote.py +39 -0
  20. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_bidding_all_pass.py +75 -0
  21. {belote_cli-4.8.0 → belote_cli-4.8.2}/.claude/settings.local.json +0 -0
  22. {belote_cli-4.8.0 → belote_cli-4.8.2}/.gitignore +0 -0
  23. {belote_cli-4.8.0 → belote_cli-4.8.2}/.python-version +0 -0
  24. {belote_cli-4.8.0 → belote_cli-4.8.2}/LICENSE +0 -0
  25. {belote_cli-4.8.0 → belote_cli-4.8.2}/scripts/benchmark.py +0 -0
  26. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/__init__.py +0 -0
  27. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/a11y.py +0 -0
  28. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/achievements.py +0 -0
  29. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ansi.py +0 -0
  30. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/__init__.py +0 -0
  31. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/core/__init__.py +0 -0
  32. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/core/economy.py +0 -0
  33. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/core/round_ledger.py +0 -0
  34. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/core/run_state.py +0 -0
  35. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/engine/__init__.py +0 -0
  36. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/engine/event_bus.py +0 -0
  37. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/engine/modifier_patch.py +0 -0
  38. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ghost_run.py +0 -0
  39. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/__init__.py +0 -0
  40. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/base.py +0 -0
  41. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/__init__.py +0 -0
  42. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/annonces.py +0 -0
  43. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/coinche.py +0 -0
  44. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/contract.py +0 -0
  45. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  46. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/economy.py +0 -0
  47. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  48. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
  49. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  50. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  51. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  52. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  53. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/planets.py +0 -0
  54. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/registry.py +0 -0
  55. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/tarots.py +0 -0
  56. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/vouchers.py +0 -0
  57. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/main.py +0 -0
  58. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/partner/__init__.py +0 -0
  59. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/partner/partner_state.py +0 -0
  60. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/partner/personality.py +0 -0
  61. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/partner/trust.py +0 -0
  62. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/progression/__init__.py +0 -0
  63. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/progression/save.py +0 -0
  64. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/run/__init__.py +0 -0
  65. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/run/ante.py +0 -0
  66. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/run/ante_themes.py +0 -0
  67. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/run/boss.py +0 -0
  68. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/run/decks.py +0 -0
  69. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/run/shop.py +0 -0
  70. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/run_summary.py +0 -0
  71. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/__init__.py +0 -0
  72. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/collection.py +0 -0
  73. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/consumables.py +0 -0
  74. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/history.py +0 -0
  75. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/hud.py +0 -0
  76. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/inventory.py +0 -0
  77. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/menu.py +0 -0
  78. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/rules.py +0 -0
  79. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/shop.py +0 -0
  80. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/trust_bar.py +0 -0
  81. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/config.py +0 -0
  82. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/context.py +0 -0
  83. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/deck.py +0 -0
  84. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/gameflow.py +0 -0
  85. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/input.py +0 -0
  86. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/main.py +0 -0
  87. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/replay.py +0 -0
  88. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/rules.py +0 -0
  89. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/stats.py +0 -0
  90. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/themes.py +0 -0
  91. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ui/__init__.py +0 -0
  92. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ui/anim.py +0 -0
  93. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ui/announce.py +0 -0
  94. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ui/fit_guard.py +0 -0
  95. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ui/layout.py +0 -0
  96. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ui/menu.py +0 -0
  97. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ui/prompts.py +0 -0
  98. {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ui/render.py +0 -0
  99. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/__init__.py +0 -0
  100. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/__init__.py +0 -0
  101. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_belatro.py +0 -0
  102. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_boss_contracts.py +0 -0
  103. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_boss_modifiers_integration.py +0 -0
  104. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_collection_logic.py +0 -0
  105. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_consumables_ui.py +0 -0
  106. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_contract_unlocks.py +0 -0
  107. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_dead_flag_fixes.py +0 -0
  108. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_deck_variants.py +0 -0
  109. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_decks_4_5.py +0 -0
  110. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_endless.py +0 -0
  111. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_event_bus.py +0 -0
  112. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_ghost_run.py +0 -0
  113. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_heist.py +0 -0
  114. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_history_overlay.py +0 -0
  115. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_hud_synergy.py +0 -0
  116. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_hud_toggle.py +0 -0
  117. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_inventory_overlay.py +0 -0
  118. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_joker_callouts.py +0 -0
  119. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_joker_contracts.py +0 -0
  120. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_jokers_4_5.py +0 -0
  121. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_partner_jokers.py +0 -0
  122. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_partner_trust.py +0 -0
  123. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_phase0_coverage.py +0 -0
  124. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_phase2_content.py +0 -0
  125. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_phase3_meta.py +0 -0
  126. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_progression.py +0 -0
  127. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_round_driver.py +0 -0
  128. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_run_summary.py +0 -0
  129. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_shop_animations.py +0 -0
  130. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_shop_empty_pools.py +0 -0
  131. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_slot_machine_tally.py +0 -0
  132. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_voucher_idempotency.py +0 -0
  133. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/perf_baselines.json +0 -0
  134. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_a11y.py +0 -0
  135. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_achievements.py +0 -0
  136. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_ai.py +0 -0
  137. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_alt_screen_scroll.py +0 -0
  138. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_anim_helpers.py +0 -0
  139. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_announce_stats.py +0 -0
  140. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_ansi_helpers.py +0 -0
  141. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_belote_stinger.py +0 -0
  142. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_benchmark_smoke.py +0 -0
  143. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_declaration_tiebreak.py +0 -0
  144. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_extended.py +0 -0
  145. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_game_logic.py +0 -0
  146. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_gameflow.py +0 -0
  147. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_hand_auto_sort.py +0 -0
  148. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_history_overlay_cache.py +0 -0
  149. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_input_eof.py +0 -0
  150. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_input_wasd.py +0 -0
  151. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_invariants.py +0 -0
  152. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_layout.py +0 -0
  153. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_new_coverage.py +0 -0
  154. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_no_color.py +0 -0
  155. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_official_rules.py +0 -0
  156. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_perf_regression.py +0 -0
  157. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_properties.py +0 -0
  158. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_render_diff.py +0 -0
  159. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_render_felt_polish.py +0 -0
  160. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_replay.py +0 -0
  161. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_undo.py +0 -0
  162. {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_winner_glow.py +0 -0
  163. {belote_cli-4.8.0 → belote_cli-4.8.2}/uv.lock +0 -0
@@ -5,6 +5,155 @@ 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
+
118
+ ## [4.8.1] - 2026-05-21
119
+
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.
121
+
122
+ ### Changed
123
+
124
+ - **`BelAtroAnnounce.boss_reveal` harmonized onto the post-4.8.0
125
+ `reader.read_timeout(...)` pattern** (`src/belote/belatro/ui/announce.py`).
126
+ Pre-4.8.1 used `interruptible_sleep(N, reader)` at three sites, which
127
+ routes through a module-level `select.select` call that doesn't go
128
+ through the `KeyReader` interface. `banner()` (just below `boss_reveal`
129
+ in the same file) was already on the new pattern; this aligns
130
+ `boss_reveal` so test stubs can interpose at the reader interface
131
+ without monkey-patching `belote.input.interruptible_sleep`. Runtime
132
+ behavior is byte-identical — any key still skips the reveal.
133
+ - **`AIPlayer._medium_play` precomputes `trick_rank` once per decision**
134
+ (`src/belote/ai.py`). The pre-4.8.1 code recomputed `trick_rank(c,
135
+ trump, self._se)` inside each `min(...)`/`max(...)` walk plus inside
136
+ the void-trump "winners" filter — five+ recomputations per AI play.
137
+ Now a single `ranks = {c: trick_rank(c, trump, self._se) for c in
138
+ legal}` is built up-front and reused via `ranks.__getitem__` and
139
+ `ranks[c]` lookups. Saves roughly 1200 `trick_rank` calls per round
140
+ (~60 µs) and reads cleaner. Decision output is unchanged.
141
+
142
+ ### Internal
143
+
144
+ - Audit found and documented six **verified-false** findings from agent
145
+ passes (LeNotaire/LeRebelle `is_rebelote` gate, ToutStreak streak
146
+ persistence, `pulse_winner_glow` banner persistence, Libra + auto_coinche
147
+ on chute, AI `_hard_play` SA-trump condition, `boss_reveal` print()
148
+ scrolling). All re-investigated against current code and confirmed
149
+ correct as designed. Plan file
150
+ `/home/mrrobot/.claude/plans/bug-hunt-code-performance-greedy-perlis.md`
151
+ has the full list so future audits don't re-raise them.
152
+ - Test count baseline unchanged: **1028**. No new tests; the changes are
153
+ behavior-preserving refactors and the existing suite covers them
154
+ (`test_ai.py` for the AI path, `test_phase2_content.py` for joker
155
+ flow; `boss_reveal` is exercised indirectly through ante setup).
156
+
8
157
  ## [4.8.0] - 2026-05-21
9
158
 
10
159
  Feature release: a twelfth theme, a coordinated animation polish pass for
@@ -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 (1007 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.0
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
@@ -203,7 +203,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
203
203
 
204
204
  ## Features
205
205
 
206
- - **BelAtro Roguelite Mode:** A massive expansion featuring 42 Jokers, 12 Tarot cards, 8 Planets, 12 Vouchers, and permanent upgrades.
206
+ - **BelAtro Roguelite Mode:** A massive expansion featuring 42 jokers, 12 tarots, 8 planets, 12 vouchers, 12 decks, 21 bosses, and permanent upgrades.
207
207
  - **Dix de Der Heist (4.7.0):** Take a contract and declare a heist before trick 1 — win trick 8 for a `×(1 + interest_rate)` Mult multiplier on your round score; lose it and forfeit your card chips from tricks 1–7. Gated on owning **La Voûte** voucher (`interest_rate > 0`). AI never declares.
208
208
  - **Slot-Machine Score Tally (4.7.0):** Per-trick odometer animation replaces the static popup. Chip-bucket fills, mult pulses, total ticks toward target, and a flame row crowns the odometer when you blow past 120% of the blind. Skippable on SPACE / ESC / ENTER. Final readout persists in the HUD between tricks (toggled by `I` alongside the top HUD). Suppressed under Le Brouillard (`hide_hud`) and La Compétition (`separate_scoring`).
209
209
  - **Inventory Overlay on V (4.7.0):** Press `V` mid-game to inspect everything you own — jokers (with edition tags + per-edition bonus blurb), vouchers, consumables, permanent chip / mult bonuses, and per-contract planet levels. List view → ↑/↓ navigate, Enter for detail, Esc/V/Q close. Read-only counterpart to the `C` consumables-action tray.
@@ -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 (1007 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.0).
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**: 1007/1007 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
 
@@ -160,7 +160,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
160
160
 
161
161
  ## Features
162
162
 
163
- - **BelAtro Roguelite Mode:** A massive expansion featuring 42 Jokers, 12 Tarot cards, 8 Planets, 12 Vouchers, and permanent upgrades.
163
+ - **BelAtro Roguelite Mode:** A massive expansion featuring 42 jokers, 12 tarots, 8 planets, 12 vouchers, 12 decks, 21 bosses, and permanent upgrades.
164
164
  - **Dix de Der Heist (4.7.0):** Take a contract and declare a heist before trick 1 — win trick 8 for a `×(1 + interest_rate)` Mult multiplier on your round score; lose it and forfeit your card chips from tricks 1–7. Gated on owning **La Voûte** voucher (`interest_rate > 0`). AI never declares.
165
165
  - **Slot-Machine Score Tally (4.7.0):** Per-trick odometer animation replaces the static popup. Chip-bucket fills, mult pulses, total ticks toward target, and a flame row crowns the odometer when you blow past 120% of the blind. Skippable on SPACE / ESC / ENTER. Final readout persists in the HUD between tricks (toggled by `I` alongside the top HUD). Suppressed under Le Brouillard (`hide_hud`) and La Compétition (`separate_scoring`).
166
166
  - **Inventory Overlay on V (4.7.0):** Press `V` mid-game to inspect everything you own — jokers (with edition tags + per-edition bonus blurb), vouchers, consumables, permanent chip / mult bonuses, and per-contract planet levels. List view → ↑/↓ navigate, Enter for detail, Esc/V/Q close. Read-only counterpart to the `C` consumables-action tray.
@@ -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 (1007 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.0).
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**: 1007/1007 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.0"
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.0"
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
@@ -399,6 +405,11 @@ class AIPlayer:
399
405
  # Leading
400
406
  return self._medium_lead(legal, trump, state)
401
407
 
408
+ # 4.8.1: cache trick_rank per card so each min/max walk is O(n) lookups
409
+ # rather than O(n) recomputations. Same for card_points_with_modifiers
410
+ # in the discard branches.
411
+ ranks = {c: trick_rank(c, trump, self._se) for c in legal}
412
+
402
413
  lead_card = trick[0].card
403
414
  lead_suit = lead_card.suit
404
415
  p = partner(self.seat)
@@ -412,7 +423,7 @@ class AIPlayer:
412
423
 
413
424
  if partner_winning and lead_suit != trump:
414
425
  # Partner winning, discard low
415
- return min(legal, key=lambda c: trick_rank(c, trump, self._se))
426
+ return min(legal, key=ranks.__getitem__)
416
427
 
417
428
  if lead_suit == trump:
418
429
  # Trump led
@@ -420,9 +431,9 @@ class AIPlayer:
420
431
  if my_trumps:
421
432
  if current_winner is not None and current_winner == p:
422
433
  # Partner winning trump, play lowest trump
423
- return min(my_trumps, key=lambda c: trick_rank(c, trump, self._se))
434
+ return min(my_trumps, key=ranks.__getitem__)
424
435
  # Try to win if we can afford it
425
- return max(my_trumps, key=lambda c: trick_rank(c, trump, self._se))
436
+ return max(my_trumps, key=ranks.__getitem__)
426
437
  # No trumps, discard low non-trump
427
438
  return min(legal, key=lambda c: card_points_with_modifiers(c, trump, bm))
428
439
 
@@ -431,8 +442,8 @@ class AIPlayer:
431
442
  if my_suit:
432
443
  # Must follow
433
444
  if current_winner is not None and current_winner == p:
434
- return min(my_suit, key=lambda c: trick_rank(c, trump, self._se))
435
- return max(my_suit, key=lambda c: trick_rank(c, trump, self._se))
445
+ return min(my_suit, key=ranks.__getitem__)
446
+ return max(my_suit, key=ranks.__getitem__)
436
447
 
437
448
  # Void - must trump or discard
438
449
  my_trumps = [c for c in legal if c.suit == trump]
@@ -442,10 +453,10 @@ class AIPlayer:
442
453
  highest_in_trick = max(
443
454
  (trick_rank(tc.card, trump, self._se) for tc in trick), default=-1
444
455
  )
445
- winners = [c for c in my_trumps if trick_rank(c, trump, self._se) > highest_in_trick]
456
+ winners = [c for c in my_trumps if ranks[c] > highest_in_trick]
446
457
  if winners:
447
- return min(winners, key=lambda c: trick_rank(c, trump, self._se))
448
- return min(my_trumps, key=lambda c: trick_rank(c, trump, self._se))
458
+ return min(winners, key=ranks.__getitem__)
459
+ return min(my_trumps, key=ranks.__getitem__)
449
460
 
450
461
  return min(legal, key=lambda c: card_points_with_modifiers(c, trump, bm))
451
462
 
@@ -592,8 +603,8 @@ class AIPlayer:
592
603
  # Precompute per-call counters used by every scoring branch — pre-3.1.0
593
604
  # these were recomputed per candidate card (n×4 walks of the hand and
594
605
  # memory.played for each legal card).
595
- from collections import Counter
596
-
606
+ # 4.8.2 (P2): dropped redundant `from collections import Counter`
607
+ # local import — `Counter` is already imported at module top.
597
608
  my_hand = state.hand_of(self.seat)
598
609
  hand_suit_counts: dict[Suit, int] = Counter(c.suit for c in my_hand)
599
610
  # Under Tout Atout every card is a trump; under a normal contract
@@ -643,6 +654,28 @@ class AIPlayer:
643
654
  next_opp = self.seat.next_seat()
644
655
  opp_voids = self.memory.known_voids.get(next_opp, set())
645
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
+
646
679
  # Score each legal card by expected outcome
647
680
  best_card = legal[0]
648
681
  best_score: float = -999.0
@@ -660,6 +693,8 @@ class AIPlayer:
660
693
  is_sa=is_sa,
661
694
  highest_rank=highest_rank,
662
695
  opp_voids=opp_voids,
696
+ card_rank=candidate_ranks[card],
697
+ partner_ranks=partner_ranks,
663
698
  )
664
699
  if score > best_score:
665
700
  best_score = score
@@ -680,8 +715,18 @@ class AIPlayer:
680
715
  is_sa: bool = False,
681
716
  highest_rank: int = -1,
682
717
  opp_voids: set[Suit] | None = None,
718
+ *,
719
+ card_rank: int | None = None,
720
+ partner_ranks: dict[Card, int] | None = None,
683
721
  ) -> float:
684
- """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
+ """
685
730
  score = 0.0
686
731
  # 4.1.1: per-card point value must honor zero-rank boss flags
687
732
  # (kings_zero / aces_zero / jacks_zero / tens_zero / ban_clubs).
@@ -713,6 +758,8 @@ class AIPlayer:
713
758
  is_sa=is_sa,
714
759
  highest_rank=highest_rank,
715
760
  opp_voids=opp_voids,
761
+ card_rank=card_rank,
762
+ partner_ranks=partner_ranks,
716
763
  )
717
764
 
718
765
  def _score_leading_strategy(
@@ -773,6 +820,8 @@ class AIPlayer:
773
820
  *,
774
821
  highest_rank: int | None = None,
775
822
  opp_voids: set[Suit] | None = None,
823
+ card_rank: int | None = None,
824
+ partner_ranks: dict[Card, int] | None = None,
776
825
  ) -> float:
777
826
  """Heuristics for trying to win the trick or ducking.
778
827
 
@@ -783,9 +832,31 @@ class AIPlayer:
783
832
  per card was pure waste. Both default to `None` so standalone
784
833
  callers (tests, ad-hoc heuristics) can omit them and the function
785
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.
786
843
  """
787
844
  score = 0.0
788
- 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)
789
860
  is_last_trick = len(state.completed_tricks) == 7
790
861
  if highest_rank is None:
791
862
  highest_rank = max(
@@ -794,13 +865,12 @@ class AIPlayer:
794
865
  if not is_sa
795
866
  else _NONTRUMP_ORDER[tc.card.rank]
796
867
  for tc in trick
797
- if not is_sa or tc.card.suit == trick[0].card.suit
868
+ if not is_sa or tc.card.suit == lead_suit
798
869
  ),
799
870
  default=-1,
800
871
  )
801
872
  if opp_voids is None:
802
873
  opp_voids = self.memory.known_voids.get(self.seat.next_seat(), set())
803
- lead_suit = trick[0].card.suit
804
874
  p = partner(self.seat)
805
875
 
806
876
  if rank > highest_rank:
@@ -808,7 +878,12 @@ class AIPlayer:
808
878
  score += win_bonus
809
879
 
810
880
  # 2-PLY: If we are winning now, will the next player beat us?
811
- 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:
812
887
  next_opp = self.seat.next_seat()
813
888
  if (
814
889
  next_opp != p
@@ -841,14 +916,25 @@ class AIPlayer:
841
916
  # happens to fall through to the same ordering today, but the SA
842
917
  # path is the canonical one and any future divergence in trick_rank
843
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.
844
927
  if self.memory.partner_hand:
845
- my_rank = (
846
- _NONTRUMP_ORDER[card.rank] if is_sa else trick_rank(card, trump, self._se)
847
- )
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)
848
932
  for partner_card in self.memory.partner_hand:
849
933
  if partner_card.suit != card.suit:
850
934
  continue
851
- 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:
852
938
  partner_rank = _NONTRUMP_ORDER[partner_card.rank]
853
939
  else:
854
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