belote-cli 4.9.4__tar.gz → 4.9.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. {belote_cli-4.9.4 → belote_cli-4.9.5}/CHANGELOG.md +57 -0
  2. {belote_cli-4.9.4 → belote_cli-4.9.5}/DEVELOPMENT.md +6 -4
  3. {belote_cli-4.9.4 → belote_cli-4.9.5}/PKG-INFO +3 -3
  4. {belote_cli-4.9.4 → belote_cli-4.9.5}/README.md +2 -2
  5. {belote_cli-4.9.4 → belote_cli-4.9.5}/pyproject.toml +1 -1
  6. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/__init__.py +1 -1
  7. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ai.py +28 -19
  8. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ansi.py +14 -3
  9. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/partner_jokers/passive.py +4 -1
  10. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/main.py +23 -10
  11. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/partner/trust.py +10 -6
  12. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/history.py +93 -74
  13. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/hud.py +11 -0
  14. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/trust_bar.py +4 -0
  15. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/config.py +6 -1
  16. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/game.py +19 -17
  17. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/gameflow.py +11 -5
  18. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/scoring.py +25 -15
  19. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ui/render.py +4 -1
  20. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_jokers_4_5.py +26 -0
  21. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_partner_jokers.py +11 -0
  22. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_alt_screen_scroll.py +6 -0
  23. belote_cli-4.9.5/tests/test_deluge_legal_cards.py +68 -0
  24. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_g1_coinche_classic.py +30 -0
  25. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_g2_ai_signals.py +49 -0
  26. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_new_coverage.py +3 -2
  27. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_official_rules.py +21 -0
  28. {belote_cli-4.9.4 → belote_cli-4.9.5}/.claude/settings.local.json +0 -0
  29. {belote_cli-4.9.4 → belote_cli-4.9.5}/.gitignore +0 -0
  30. {belote_cli-4.9.4 → belote_cli-4.9.5}/.python-version +0 -0
  31. {belote_cli-4.9.4 → belote_cli-4.9.5}/LICENSE +0 -0
  32. {belote_cli-4.9.4 → belote_cli-4.9.5}/scripts/benchmark.py +0 -0
  33. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/__init__.py +0 -0
  34. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/a11y.py +0 -0
  35. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/achievements.py +0 -0
  36. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/__init__.py +0 -0
  37. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/core/__init__.py +0 -0
  38. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/core/economy.py +0 -0
  39. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/core/round_ledger.py +0 -0
  40. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/core/run_state.py +0 -0
  41. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/core/scoring.py +0 -0
  42. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/engine/__init__.py +0 -0
  43. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/engine/event_bus.py +0 -0
  44. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/engine/modifier_patch.py +0 -0
  45. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/engine/round_driver.py +0 -0
  46. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ghost_run.py +0 -0
  47. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/__init__.py +0 -0
  48. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/base.py +0 -0
  49. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/jokers/__init__.py +0 -0
  50. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/jokers/annonces.py +0 -0
  51. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/jokers/coinche.py +0 -0
  52. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/jokers/contract.py +0 -0
  53. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  54. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/jokers/economy.py +0 -0
  55. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  56. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
  57. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  58. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  59. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  60. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/planets.py +0 -0
  61. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/registry.py +0 -0
  62. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/tarots.py +0 -0
  63. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/vouchers.py +0 -0
  64. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/partner/__init__.py +0 -0
  65. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/partner/partner_state.py +0 -0
  66. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/partner/personality.py +0 -0
  67. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/progression/__init__.py +0 -0
  68. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/progression/save.py +0 -0
  69. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/progression/unlocks.py +0 -0
  70. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/run/__init__.py +0 -0
  71. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/run/ante.py +0 -0
  72. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/run/ante_themes.py +0 -0
  73. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/run/boss.py +0 -0
  74. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/run/decks.py +0 -0
  75. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/run/shop.py +0 -0
  76. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/run_summary.py +0 -0
  77. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/__init__.py +0 -0
  78. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/announce.py +0 -0
  79. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/collection.py +0 -0
  80. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/consumables.py +0 -0
  81. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/inventory.py +0 -0
  82. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/menu.py +0 -0
  83. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/rules.py +0 -0
  84. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/shop.py +0 -0
  85. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/context.py +0 -0
  86. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/deck.py +0 -0
  87. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/input.py +0 -0
  88. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/main.py +0 -0
  89. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/replay.py +0 -0
  90. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/rules.py +0 -0
  91. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/stats.py +0 -0
  92. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/themes.py +0 -0
  93. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ui/__init__.py +0 -0
  94. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ui/anim.py +0 -0
  95. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ui/announce.py +0 -0
  96. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ui/fit_guard.py +0 -0
  97. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ui/layout.py +0 -0
  98. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ui/menu.py +0 -0
  99. {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ui/prompts.py +0 -0
  100. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/__init__.py +0 -0
  101. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/__init__.py +0 -0
  102. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_belatro.py +0 -0
  103. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_boss_contracts.py +0 -0
  104. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_boss_modifiers_integration.py +0 -0
  105. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_collection_logic.py +0 -0
  106. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_competition_malediction.py +0 -0
  107. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_consumables_ui.py +0 -0
  108. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_contract_unlocks.py +0 -0
  109. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_dead_flag_fixes.py +0 -0
  110. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_deck_variants.py +0 -0
  111. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_decks_4_5.py +0 -0
  112. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_endless.py +0 -0
  113. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_event_bus.py +0 -0
  114. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_ghost_run.py +0 -0
  115. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_handler_registry.py +0 -0
  116. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_heist.py +0 -0
  117. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_history_overlay.py +0 -0
  118. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_hud_synergy.py +0 -0
  119. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_hud_toggle.py +0 -0
  120. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_inventory_overlay.py +0 -0
  121. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_joker_callouts.py +0 -0
  122. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_joker_contracts.py +0 -0
  123. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_partner_trust.py +0 -0
  124. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_phase0_coverage.py +0 -0
  125. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_phase1_plumbing.py +0 -0
  126. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_phase2_content.py +0 -0
  127. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_phase3_meta.py +0 -0
  128. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_progression.py +0 -0
  129. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_round_driver.py +0 -0
  130. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_run_summary.py +0 -0
  131. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_shop_animations.py +0 -0
  132. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_shop_empty_pools.py +0 -0
  133. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_slot_machine_tally.py +0 -0
  134. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_voucher_idempotency.py +0 -0
  135. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/perf_baselines.json +0 -0
  136. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_a11y.py +0 -0
  137. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_achievements.py +0 -0
  138. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_ai.py +0 -0
  139. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_ai_deluge_voids.py +0 -0
  140. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_ai_sans_atout.py +0 -0
  141. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_anim_helpers.py +0 -0
  142. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_announce_stats.py +0 -0
  143. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_ansi_helpers.py +0 -0
  144. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_belote.py +0 -0
  145. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_belote_stinger.py +0 -0
  146. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_benchmark_smoke.py +0 -0
  147. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_bidding_all_pass.py +0 -0
  148. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_declaration_tiebreak.py +0 -0
  149. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_extended.py +0 -0
  150. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_game_logic.py +0 -0
  151. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_gameflow.py +0 -0
  152. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_hand_auto_sort.py +0 -0
  153. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_history_overlay_cache.py +0 -0
  154. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_input_eof.py +0 -0
  155. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_input_wasd.py +0 -0
  156. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_invariants.py +0 -0
  157. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_layout.py +0 -0
  158. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_no_color.py +0 -0
  159. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_perf_regression.py +0 -0
  160. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_properties.py +0 -0
  161. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_render_diff.py +0 -0
  162. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_render_felt_polish.py +0 -0
  163. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_render_hand_lift.py +0 -0
  164. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_replay.py +0 -0
  165. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_undo.py +0 -0
  166. {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_winner_glow.py +0 -0
  167. {belote_cli-4.9.4 → belote_cli-4.9.5}/uv.lock +0 -0
@@ -5,6 +5,63 @@ 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.9.5] - 2026-05-28
9
+
10
+ Bug-hunt, logic, and performance audit pass. Three parallel Explore agents
11
+ swept the classic engine, BelAtro mechanics, and the UI/render layer; every
12
+ finding below was traced to exact code and the high-severity ones reproduced at
13
+ runtime before fixing. Test count 1066 → 1079.
14
+
15
+ ### Fixed
16
+
17
+ - **Crash: `legal_cards` returned an empty set under La Déluge**
18
+ (`src/belote/game.py`). With the `seven_eight_trump` boss active, every 7/8
19
+ is a trump — but the trump-follow set was built from `c.suit == lead_suit`,
20
+ which dropped off-suit 7/8s. A player void of the trump *suit* but holding
21
+ only off-suit 7/8 (which ARE trumps) got zero legal cards on a trump lead,
22
+ tripping `AIPlayer.decide_card`'s empty-legal assertion. A single
23
+ `is_trump_card` predicate now drives both the trump-led and non-trump-led
24
+ follow/overtrump paths. Regression tests in `tests/test_deluge_legal_cards.py`.
25
+ - **AI partner-signal double-count after a mid-round undo** (`src/belote/ai.py`).
26
+ The undo-regression branch reset the void cache and `processed_tricks_count`
27
+ but not the partner-signal tally; because `_update_voids` re-walks surviving
28
+ tricks from zero, their signals were counted twice. Both reset paths now share
29
+ a single `_reset_round_memory()` helper so they can't drift again.
30
+ - **Belote + Rebelote scored 40 instead of the official 20** (`src/belote/config.py`).
31
+ Holding the King and Queen of trump is worth 20 points *total* in official
32
+ French Belote — and the in-game Rules screen already said 20. `REBELOTE_POINTS`
33
+ is now 20, matching both.
34
+ - **Coinche round-end animation showed the pre-multiplier score**
35
+ (`src/belote/gameflow.py`). The score counter animated to the raw breakdown
36
+ total while the stored score applied `2 ** coinche_level`. Both now use the
37
+ shared `scoring.coinche_adjusted_credits()` helper, so the animation lands on
38
+ the real total.
39
+ - **Joker money not reconciled on a failed-but-survived round**
40
+ (`src/belote/belatro/main.py`). Signed `JokerResult.add_money` (e.g.
41
+ LePrêteur's −$5 skim, baked into the round's ×Mult) was only applied on the
42
+ success path, handing a free multiplier when a chute was survived via Capot
43
+ Insurance. A shared `_reconcile_bonus_money()` helper now runs on both paths.
44
+ - **La Symbiose fired ×1.2 Mult on zero-point declarations under Le Mime**
45
+ (`src/belote/belatro/items/partner_jokers/passive.py`). Now gated on
46
+ `event.points > 0`, mirroring Le Mathématicien.
47
+
48
+ ### Changed
49
+
50
+ - **BelAtro HUD / TrustBar now invalidate the render diff** after their direct
51
+ stdout writes (`belatro/ui/hud.py`, `trust_bar.py`), matching the 4.0.0
52
+ overlay convention so a static-state `display()` can't leave a stale top bar.
53
+ The convention is now enforced for both files by
54
+ `tests/test_alt_screen_scroll.py`.
55
+ - **Performance**: `_pip_at`'s LRU cache raised 4096 → 8192 so wide-terminal
56
+ felt mats (>5000 cells/frame) stop thrashing it (`ui/render.py`);
57
+ `visible_len` no longer spends cache slots on plain (no-ANSI) strings
58
+ (`ansi.py`); the BelAtro [H] history overlay caches its line list on
59
+ `(term_w, entry_count)` instead of rebuilding every keystroke
60
+ (`belatro/ui/history.py`).
61
+ - **Docs**: corrected the trust tier-0 docstring (it claimed "effects halved";
62
+ the implementation applies the baseline once with no penalty) and the tier
63
+ bonus percentages to match the `(0, 0, 1, 1, 2)` extra-applies model.
64
+
8
65
  ## [4.9.4] - 2026-05-26
9
66
 
10
67
  Audit + maintenance pass. Three parallel Explore agents reviewed game-logic
@@ -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 (1066 tests expected)
87
+ # Full test suite (1079 tests expected)
88
88
  PYTHONPATH=src pytest
89
89
  ```
90
90
 
@@ -221,6 +221,8 @@ PYTHONPATH=src python scripts/benchmark.py
221
221
  If you suspect a per-frame allocation regression, the canonical hot paths to
222
222
  profile live (e.g. with `py-spy record --rate 100`) are `render()` in
223
223
  `src/belote/ui/render.py` and `_calculate_legal_cards_impl` in
224
- `src/belote/game.py`. The 4.9.4 audit pass found no quick wins beyond those
225
- already landed in the 4.6.x / 4.7.x perf cleanups start from a profiler
226
- trace, not from guesswork, before adding new caches.
224
+ `src/belote/game.py`. The 4.9.4 / 4.9.5 audit passes found only minor wins
225
+ beyond those already landed in the 4.6.x / 4.7.x perf cleanups (4.9.5 bumped
226
+ the `_pip_at` cache to 8192, trimmed `visible_len`'s cache to ANSI strings,
227
+ and memoised the BelAtro history overlay) — start from a profiler trace, not
228
+ from guesswork, before adding new caches.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: belote-cli
3
- Version: 4.9.4
3
+ Version: 4.9.5
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 (1069 tests)
267
+ ├── tests/ # Comprehensive test suite (1079 tests)
268
268
  ├── scripts/ # Performance benchmarks
269
269
  ├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
270
270
  ├── LICENSE # MIT License
@@ -279,7 +279,7 @@ belote/
279
279
  PYTHONPATH=src pytest
280
280
  ```
281
281
 
282
- Currently **1069 tests** passing with 100% coverage on game-logic modules (4.9.4).
282
+ Currently **1079 tests** passing with 100% coverage on game-logic modules (4.9.5).
283
283
 
284
284
  ## Technical Integrity
285
285
 
@@ -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 (1069 tests)
224
+ ├── tests/ # Comprehensive test suite (1079 tests)
225
225
  ├── scripts/ # Performance benchmarks
226
226
  ├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
227
227
  ├── LICENSE # MIT License
@@ -236,7 +236,7 @@ belote/
236
236
  PYTHONPATH=src pytest
237
237
  ```
238
238
 
239
- Currently **1069 tests** passing with 100% coverage on game-logic modules (4.9.4).
239
+ Currently **1079 tests** passing with 100% coverage on game-logic modules (4.9.5).
240
240
 
241
241
  ## Technical Integrity
242
242
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "belote-cli"
7
- version = "4.9.4"
7
+ version = "4.9.5"
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.9.4"
1
+ __version__ = "4.9.5"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -74,6 +74,27 @@ class AIPlayer:
74
74
  # All ranking helpers in this class read it via self._se.
75
75
  self._se = False
76
76
 
77
+ def _reset_round_memory(self) -> None:
78
+ """Clear all per-round derived memory back to a clean slate.
79
+
80
+ Shared by the new-round and mid-round-undo paths in ``update_memory`` so
81
+ the two can't drift: a partial reset (e.g. clearing the void cache but
82
+ not the partner-signal tally) would let ``_update_voids`` re-walk the
83
+ surviving tricks and double-count their signals.
84
+ """
85
+ self.memory.played.clear()
86
+ for s in Seat:
87
+ self.memory.known_voids[s].clear()
88
+ self.memory.partner_hand.clear()
89
+ self.memory.processed_tricks_count = 0
90
+ self.memory.last_voids_key = None
91
+ self.memory.last_partner_hand_key = None
92
+ # 4.9.0 / G2: partner-signal tally and our emit-counter follow
93
+ # the same reset pattern as the void cache.
94
+ for suit in Suit:
95
+ self.memory.signals[suit] = 0
96
+ self.memory.signals_emitted = 0
97
+
77
98
  def update_memory(self, state: GameState) -> None:
78
99
  """Update memory with currently visible information."""
79
100
  completed_count = len(state.completed_tricks)
@@ -84,18 +105,7 @@ class AIPlayer:
84
105
  # this a (0, 0) / (0, 1) key from the first decision of *this* round
85
106
  # could coincidentally match a leftover from the previous round and
86
107
  # cause _update_voids to skip processing entirely.
87
- self.memory.played.clear()
88
- for s in Seat:
89
- self.memory.known_voids[s].clear()
90
- self.memory.partner_hand.clear()
91
- self.memory.processed_tricks_count = 0
92
- self.memory.last_voids_key = None
93
- self.memory.last_partner_hand_key = None
94
- # 4.9.0 / G2: partner-signal tally and our emit-counter follow
95
- # the same triple-reset pattern as the void cache.
96
- for suit in Suit:
97
- self.memory.signals[suit] = 0
98
- self.memory.signals_emitted = 0
108
+ self._reset_round_memory()
99
109
  elif (
100
110
  self.memory.last_voids_key is not None
101
111
  and (completed_count, current_count) < self.memory.last_voids_key
@@ -104,13 +114,12 @@ class AIPlayer:
104
114
  # we've processed. `known_voids` and `processed_tricks_count`
105
115
  # are monotonic and would carry stale inferences forward
106
116
  # (a void inferred from a now-rolled-back trick). Rebuild from
107
- # the current state instead of trying to subtract.
108
- self.memory.played.clear()
109
- for s in Seat:
110
- self.memory.known_voids[s].clear()
111
- self.memory.processed_tricks_count = 0
112
- self.memory.last_voids_key = None
113
- self.memory.last_partner_hand_key = None
117
+ # the current state instead of trying to subtract. The signal
118
+ # tally is rebuilt by _update_voids' re-walk (processed count is
119
+ # reset to 0), so it MUST be cleared here too — otherwise the
120
+ # surviving tricks' signals are double-counted. Same full reset
121
+ # as a new round.
122
+ self._reset_round_memory()
114
123
 
115
124
  # Track all cards in completed tricks. N is small (max 32 cards), so
116
125
  # the O(N) re-walk is fine and keeps this in sync with the transient
@@ -101,12 +101,23 @@ _RESET_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
101
101
  RESET = "\x1b[0m"
102
102
 
103
103
 
104
- @lru_cache(maxsize=4096)
104
+ @lru_cache(maxsize=2048)
105
+ def _visible_len_ansi(s: str) -> int:
106
+ return len(_RESET_RE.sub("", s))
107
+
108
+
105
109
  def visible_len(s: str) -> int:
106
- """Return length of string with ANSI escape codes stripped."""
110
+ """Return length of string with ANSI escape codes stripped.
111
+
112
+ 4.9.5 (P3): only the regex-stripping path is cached. Plain (no-ESC) strings
113
+ return ``len(s)`` directly instead of occupying cache slots, so the bounded
114
+ cache is reserved for the ANSI strings that actually benefit from memoising
115
+ the regex sub (static glyphs, frames). Keeps the cache small and the
116
+ hit-rate high.
117
+ """
107
118
  if "\x1b" not in s:
108
119
  return len(s)
109
- return len(_RESET_RE.sub("", s))
120
+ return _visible_len_ansi(s)
110
121
 
111
122
 
112
123
  def ansi_center(s: str, width: int) -> str:
@@ -29,7 +29,10 @@ class LaSymbiose(Joker):
29
29
  is_partner_joker = True
30
30
 
31
31
  def on_declaration(self, event: DeclarationScoredEvent, state: dict[str, Any]) -> JokerResult | None:
32
- if event.seat == Seat.NORTH:
32
+ # Gate on points so Le Mime (declarations_zero) — which emits
33
+ # declarations worth 0 — doesn't grant the multiplier for a
34
+ # declaration that scored nothing. Mirrors LeMathematicien's guard.
35
+ if event.seat == Seat.NORTH and event.points > 0:
33
36
  return JokerResult(times_mult=1.2)
34
37
  return None
35
38
 
@@ -326,6 +326,22 @@ class BelAtroGame:
326
326
  for msg in self.unlock_tracker.drain_announcements():
327
327
  BelAtroAnnounce.banner(msg, self.reader, hold=1.5)
328
328
 
329
+ def _reconcile_bonus_money(self, bonus_money: int) -> None:
330
+ """Apply a round's signed joker money to the wallet.
331
+
332
+ ``final_state._bonus_money`` accumulates ``JokerResult.add_money`` over
333
+ the round (positive = credit, negative = debit such as LePreteur's -$5
334
+ skim). Positive amounts credit; negative amounts route through
335
+ ``spend_money`` (which no-ops if the wallet can't cover it) so the
336
+ Economy's non-negative guard on ``add_money`` is respected.
337
+ """
338
+ if self.run is None:
339
+ return
340
+ if bonus_money > 0:
341
+ self.run.economy.add_money(bonus_money)
342
+ elif bonus_money < 0:
343
+ self.run.economy.spend_money(-bonus_money)
344
+
329
345
  def _run_loop(self) -> None:
330
346
  """Main game loop: Blind -> Shop -> Next."""
331
347
  if self.run is None:
@@ -617,6 +633,12 @@ class BelAtroGame:
617
633
  color="red",
618
634
  hold=2.5,
619
635
  )
636
+ else:
637
+ # The run continues past this chute. Reconcile signed joker
638
+ # money (e.g. LePreteur's -$5 skim, which was baked into the
639
+ # round's ×Mult) so the player isn't handed a free multiplier
640
+ # for the surviving round. spend_money no-ops if broke.
641
+ self._reconcile_bonus_money(final_state._bonus_money)
620
642
  if not lock_trust:
621
643
  trust.blind_failed()
622
644
  else:
@@ -624,16 +646,7 @@ class BelAtroGame:
624
646
  payout = self.run.economy.process_round_end(total - self.run.target_score)
625
647
  if auto_coinche_active:
626
648
  self.run.economy.add_money(payout * 2) # L'Avocat: triple total payout
627
- # JokerResult.add_money is signed: positive = credit, negative = debit.
628
- # Pre-4.6.5 this branch was `> 0`, which silently dropped LePreteur's
629
- # `-5` cost (and any other negative-add_money joker) while still
630
- # applying the multiplier — free ×1.2 Mult on every $50+ round.
631
- if final_state._bonus_money > 0:
632
- self.run.economy.add_money(final_state._bonus_money)
633
- elif final_state._bonus_money < 0:
634
- # Route through spend_money so the Economy negative guard fires
635
- # if accumulated debit somehow exceeds wallet (caller's bug).
636
- self.run.economy.spend_money(-final_state._bonus_money)
649
+ self._reconcile_bonus_money(final_state._bonus_money)
637
650
  if final_state._joker_state.get("puriste_triggered"):
638
651
  extra = max(0, (total - self.run.target_score) // 10)
639
652
  self.run.economy.add_money(extra) # Le Puriste: double base payout
@@ -73,15 +73,19 @@ class TrustTrack:
73
73
  def tier(self) -> int:
74
74
  """Five-tier bucketing for partner-joker effect scaling.
75
75
 
76
- 0 (degraded, value 0–2) partner-joker effects halved
77
- 1 (base, value 3–4) — baseline
78
- 2 (boost, value 5–6) — +25%
79
- 3 (strong, value 7–8) — +50%
80
- 4 (elite, value 910) +100%
76
+ Scaling is implemented as N *extra* applications of the joker's
77
+ JokerResult on top of the baseline one (see
78
+ ``ScoreAccumulator._fire_jokers``; ``tier_extras = (0, 0, 1, 1, 2)``):
79
+
80
+ 0 (degraded, value 02) baseline only (no bonus, no penalty)
81
+ 1 (base, value 3–4) — baseline only
82
+ 2 (boost, value 5–6) — +1 apply (≈ ×2 effect)
83
+ 3 (strong, value 7–8) — +1 apply (≈ ×2 effect)
84
+ 4 (elite, value 9–10) — +2 applies (≈ ×3 effect)
81
85
 
82
86
  Used by partner_jokers/* to scale their JokerResult payloads. The
83
87
  legacy `partner_jokers_double` flag (value≥7) still exists for backward
84
- compatibility but tier_for is the new path.
88
+ compatibility but tier is the new path.
85
89
  """
86
90
  if self.value <= 2:
87
91
  return 0
@@ -90,90 +90,109 @@ def _money_cell(delta: int) -> str:
90
90
  return f"{DIM}$0{RESET}"
91
91
 
92
92
 
93
+ def _build_history_lines(entries: list[BelAtroHistoryEntry], term_w: int) -> list[str]:
94
+ """Build the full (pre-scroll) line list for the overlay.
95
+
96
+ Pure function of ``(entries, term_w)`` — the scroll position doesn't affect
97
+ it — so ``show_belatro_history`` memoises the result across keystrokes
98
+ instead of rebuilding ~3 lines per entry on every ↑/↓ (mirrors the classic
99
+ ``belote.ui.prompts._build_history_lines`` caching).
100
+ """
101
+ lines: list[str] = []
102
+ lines.append(f"{BOLD}{gold_fg()}BELATRO RUN HISTORY{RESET}")
103
+ lines.append("=" * 19)
104
+ lines.append("")
105
+
106
+ if not entries:
107
+ lines.append(f"{DIM}No blinds completed yet.{RESET}")
108
+ else:
109
+ # Wide layout: single-row record. ~84 visible chars at minimum.
110
+ wide = term_w >= 90
111
+ if wide:
112
+ w_no, w_ante, w_bl, w_tgt, w_boss, w_tkr, w_con, w_trk, w_score, w_st, w_money = (
113
+ 3, 4, 5, 5, 14, 7, 4, 7, 7, 8, 6,
114
+ )
115
+ header_cells = [
116
+ _ljust_visible("#", w_no),
117
+ _ljust_visible("ANTE", w_ante),
118
+ _ljust_visible("BLIND", w_bl),
119
+ _ljust_visible("TGT", w_tgt),
120
+ _ljust_visible("BOSS", w_boss),
121
+ _ljust_visible("TAKER", w_tkr),
122
+ _ljust_visible("CON", w_con),
123
+ _ljust_visible("TRICKS", w_trk),
124
+ _ljust_visible("SCORE", w_score),
125
+ _ljust_visible("STATUS", w_st),
126
+ _ljust_visible("$Δ", w_money),
127
+ ]
128
+ header = " │ ".join(header_cells)
129
+ lines.append(f"{BOLD}{white_fg()}{header}{RESET}")
130
+ lines.append("─" * visible_len(header))
131
+
132
+ for i, e in enumerate(entries):
133
+ boss = e.boss_name or "─"
134
+ if visible_len(boss) > w_boss:
135
+ boss = boss[: w_boss - 1] + "…"
136
+ row_cells = [
137
+ _ljust_visible(f"{i + 1:02d}", w_no),
138
+ _ljust_visible(str(e.ante), w_ante),
139
+ _ljust_visible(e.blind_label, w_bl),
140
+ _ljust_visible(str(e.target), w_tgt),
141
+ _ljust_visible(boss, w_boss),
142
+ _ljust_visible(e.taker_label, w_tkr),
143
+ _ljust_visible(e.contract, w_con),
144
+ _ljust_visible(f"{e.tricks_ns}/{e.tricks_ew}", w_trk),
145
+ _ljust_visible(f"{BOLD}{e.score}{RESET}", w_score),
146
+ _ljust_visible(_status_cell(e.status), w_st),
147
+ _ljust_visible(_money_cell(e.money_delta), w_money),
148
+ ]
149
+ lines.append(" │ ".join(row_cells))
150
+ else:
151
+ # Compact three-line-per-row layout for narrow terminals.
152
+ lines.append(
153
+ f"{BOLD}{white_fg()}{'#':<3} {'A.B':<4} {'TGT':<5} "
154
+ f"{'SCORE':<7} STATUS{RESET}"
155
+ )
156
+ lines.append("─" * 40)
157
+ for i, e in enumerate(entries):
158
+ a_b = f"{e.ante}.{e.blind_label[0]}"
159
+ lines.append(
160
+ f"{i + 1:02d} {a_b:<4} {e.target:<5} "
161
+ f"{BOLD}{e.score:<7}{RESET}{_status_cell(e.status)}"
162
+ )
163
+ boss = e.boss_name or "─"
164
+ lines.append(
165
+ f" boss: {boss} taker: {e.taker_label} con: {e.contract} "
166
+ f"tricks: {e.tricks_ns}/{e.tricks_ew} {_money_cell(e.money_delta)}"
167
+ )
168
+ decl_n = _decl_str(e.decl_summary_ns, 14)
169
+ decl_e = _decl_str(e.decl_summary_ew, 14)
170
+ lines.append(f" decl: {decl_n} / {decl_e}")
171
+ lines.append("")
172
+
173
+ lines.append("")
174
+ lines.append(f"{DIM}[↑↓] Scroll [Any Key] Return{RESET}")
175
+ return lines
176
+
177
+
93
178
  def show_belatro_history(reader: KeyReader, entries: list[BelAtroHistoryEntry]) -> None:
94
179
  """Scrollable BelAtro round-by-round overlay; called via the [H] hook."""
95
180
  from belote.ui.fit_guard import require_minimum
96
181
 
97
182
  scroll = 0
183
+ cached_key: tuple[int, int] | None = None
184
+ lines: list[str] = []
98
185
 
99
186
  while True:
100
187
  require_minimum(reader)
101
188
  term_w, term_h = get_term_size()
102
189
 
103
- lines: list[str] = []
104
- lines.append(f"{BOLD}{gold_fg()}BELATRO RUN HISTORY{RESET}")
105
- lines.append("=" * 19)
106
- lines.append("")
107
-
108
- if not entries:
109
- lines.append(f"{DIM}No blinds completed yet.{RESET}")
110
- else:
111
- # Wide layout: single-row record. ~84 visible chars at minimum.
112
- wide = term_w >= 90
113
- if wide:
114
- w_no, w_ante, w_bl, w_tgt, w_boss, w_tkr, w_con, w_trk, w_score, w_st, w_money = (
115
- 3, 4, 5, 5, 14, 7, 4, 7, 7, 8, 6,
116
- )
117
- header_cells = [
118
- _ljust_visible("#", w_no),
119
- _ljust_visible("ANTE", w_ante),
120
- _ljust_visible("BLIND", w_bl),
121
- _ljust_visible("TGT", w_tgt),
122
- _ljust_visible("BOSS", w_boss),
123
- _ljust_visible("TAKER", w_tkr),
124
- _ljust_visible("CON", w_con),
125
- _ljust_visible("TRICKS", w_trk),
126
- _ljust_visible("SCORE", w_score),
127
- _ljust_visible("STATUS", w_st),
128
- _ljust_visible("$Δ", w_money),
129
- ]
130
- header = " │ ".join(header_cells)
131
- lines.append(f"{BOLD}{white_fg()}{header}{RESET}")
132
- lines.append("─" * visible_len(header))
133
-
134
- for i, e in enumerate(entries):
135
- boss = e.boss_name or "─"
136
- if visible_len(boss) > w_boss:
137
- boss = boss[: w_boss - 1] + "…"
138
- row_cells = [
139
- _ljust_visible(f"{i + 1:02d}", w_no),
140
- _ljust_visible(str(e.ante), w_ante),
141
- _ljust_visible(e.blind_label, w_bl),
142
- _ljust_visible(str(e.target), w_tgt),
143
- _ljust_visible(boss, w_boss),
144
- _ljust_visible(e.taker_label, w_tkr),
145
- _ljust_visible(e.contract, w_con),
146
- _ljust_visible(f"{e.tricks_ns}/{e.tricks_ew}", w_trk),
147
- _ljust_visible(f"{BOLD}{e.score}{RESET}", w_score),
148
- _ljust_visible(_status_cell(e.status), w_st),
149
- _ljust_visible(_money_cell(e.money_delta), w_money),
150
- ]
151
- lines.append(" │ ".join(row_cells))
152
- else:
153
- # Compact three-line-per-row layout for narrow terminals.
154
- lines.append(
155
- f"{BOLD}{white_fg()}{'#':<3} {'A.B':<4} {'TGT':<5} "
156
- f"{'SCORE':<7} STATUS{RESET}"
157
- )
158
- lines.append("─" * 40)
159
- for i, e in enumerate(entries):
160
- a_b = f"{e.ante}.{e.blind_label[0]}"
161
- lines.append(
162
- f"{i + 1:02d} {a_b:<4} {e.target:<5} "
163
- f"{BOLD}{e.score:<7}{RESET}{_status_cell(e.status)}"
164
- )
165
- boss = e.boss_name or "─"
166
- lines.append(
167
- f" boss: {boss} taker: {e.taker_label} con: {e.contract} "
168
- f"tricks: {e.tricks_ns}/{e.tricks_ew} {_money_cell(e.money_delta)}"
169
- )
170
- decl_n = _decl_str(e.decl_summary_ns, 14)
171
- decl_e = _decl_str(e.decl_summary_ew, 14)
172
- lines.append(f" decl: {decl_n} / {decl_e}")
173
- lines.append("")
174
-
175
- lines.append("")
176
- lines.append(f"{DIM}[↑↓] Scroll [Any Key] Return{RESET}")
190
+ # The line list only depends on (term_w, entry count); rebuild it only
191
+ # when one of those changes, not on every scroll keystroke.
192
+ key = (term_w, len(entries))
193
+ if key != cached_key:
194
+ lines = _build_history_lines(entries, term_w)
195
+ cached_key = key
177
196
 
178
197
  view_h = max(1, term_h - 4)
179
198
  max_scroll = max(0, len(lines) - view_h)
@@ -287,6 +287,10 @@ class BelAtroHUD:
287
287
 
288
288
  sys.stdout.write("".join(parts))
289
289
  sys.stdout.flush()
290
+ # This write bypasses display(); invalidate the render-diff baseline so
291
+ # the next display() repaints rows the HUD overwrote (4.0.0 convention).
292
+ from belote.ui.render import invalidate_diff
293
+ invalidate_diff()
290
294
 
291
295
  def _render_compact(
292
296
  self,
@@ -362,6 +366,9 @@ class BelAtroHUD:
362
366
 
363
367
  sys.stdout.write("".join(parts))
364
368
  sys.stdout.flush()
369
+ # Bypasses display(); invalidate the render-diff baseline (4.0.0 conv).
370
+ from belote.ui.render import invalidate_diff
371
+ invalidate_diff()
365
372
 
366
373
 
367
374
  # ── 3.4.0: joker pip strip + synergy tooltip ────────────────────────────────
@@ -450,6 +457,8 @@ def render_joker_pip_strip(run: BelAtroRun, term_w: int, row: int = 1) -> None:
450
457
  return
451
458
  sys.stdout.write(strip)
452
459
  sys.stdout.flush()
460
+ from belote.ui.render import invalidate_diff
461
+ invalidate_diff()
453
462
 
454
463
 
455
464
  def build_synergy_tooltip(jokers: Sequence[object], term_w: int, row: int = 5) -> str:
@@ -492,3 +501,5 @@ def render_synergy_tooltip(jokers: Sequence[object], term_w: int, row: int = 5)
492
501
  return
493
502
  sys.stdout.write(out)
494
503
  sys.stdout.flush()
504
+ from belote.ui.render import invalidate_diff
505
+ invalidate_diff()
@@ -74,6 +74,10 @@ class TrustBar:
74
74
  + white_fg() + "] " + RESET
75
75
  + status
76
76
  )
77
+ # Bypasses display(); invalidate the render-diff baseline so the next
78
+ # display() repaints row 4 (4.0.0 convention).
79
+ from belote.ui.render import invalidate_diff
80
+ invalidate_diff()
77
81
 
78
82
  # 4.8.0 / B4: tick-up / tick-down animation between two trust values.
79
83
  # ──────────────────────────────────────────────────────────────────
@@ -10,8 +10,13 @@ class Config:
10
10
  # Game rules
11
11
  TARGET_SCORE: int = 1000
12
12
  LAST_TRICK_BONUS: int = 10
13
+ # Official French Belote: holding the King AND Queen of trump (announced
14
+ # "Belote" on the first played, "Rebelote" on the second) is worth 20
15
+ # points TOTAL — not 20 each. BELOTE_POINTS is the provisional credit once
16
+ # the first card is announced; REBELOTE_POINTS is the final value once the
17
+ # pair is complete. Both are 20 so the completed pair scores the official 20.
13
18
  BELOTE_POINTS: int = 20
14
- REBELOTE_POINTS: int = 40
19
+ REBELOTE_POINTS: int = 20
15
20
  CAPOT_BASE: int = 252
16
21
  # Capot bases for special contracts. Standard FFB practice scales the
17
22
  # Capot reward to the contract's total card points: Capot pays the deck
@@ -606,35 +606,41 @@ def _calculate_legal_cards_impl(
606
606
  res_cards = hand
607
607
  return tuple(_CARD_TO_ID[c] for c in res_cards)
608
608
 
609
+ # A card is trump if it's the trump suit, or — under La Déluge
610
+ # (seven_eight_trump) — any 7 or 8 regardless of suit. Following a trump
611
+ # lead means following with ANY trump, not just lead-suit cards: an off-suit
612
+ # 7/8 is a trump too. Using `c.suit == lead_suit` here would drop those and
613
+ # wrongly report the player as void (empty legal set → crash).
614
+ def is_trump_card(c: Card) -> bool:
615
+ return c.suit == trump or (
616
+ seven_eight_trump and c.rank in (Rank.SEVEN, Rank.EIGHT)
617
+ )
618
+
609
619
  is_trump_lead = (lead_suit == trump) or (
610
620
  seven_eight_trump and lead_card.rank in (Rank.SEVEN, Rank.EIGHT)
611
621
  )
612
622
 
613
623
  if is_trump_lead:
614
- # Trump led: must follow if possible, must overtrump if possible
615
- if my_suit_cards:
624
+ # Trump led: must follow with a trump if possible, must overtrump if possible
625
+ my_trumps = [c for c in hand if is_trump_card(c)]
626
+ if my_trumps:
616
627
  highest_in_trick = max(
617
628
  (trick_rank(tc.card, trump, seven_eight_trump) for tc in played_cards), default=-1
618
629
  )
619
630
  must_overtrump = any(
620
- trick_rank(c, trump, seven_eight_trump) > highest_in_trick for c in my_suit_cards
631
+ trick_rank(c, trump, seven_eight_trump) > highest_in_trick for c in my_trumps
621
632
  )
622
633
  if must_overtrump:
623
634
  res_cards = tuple(
624
635
  c
625
- for c in my_suit_cards
636
+ for c in my_trumps
626
637
  if trick_rank(c, trump, seven_eight_trump) > highest_in_trick
627
638
  )
628
639
  else:
629
- res_cards = tuple(my_suit_cards)
640
+ res_cards = tuple(my_trumps)
630
641
  else:
631
- # Void in trump (led suit) - can only play non-trump
632
- res_cards = tuple(
633
- c
634
- for c in hand
635
- if c.suit != trump
636
- and not (seven_eight_trump and c.rank in (Rank.SEVEN, Rank.EIGHT))
637
- )
642
+ # No trumps at all - can play anything (every card is non-trump)
643
+ res_cards = hand
638
644
  else:
639
645
  # Non-trump led
640
646
  if my_suit_cards:
@@ -647,11 +653,7 @@ def _calculate_legal_cards_impl(
647
653
  res_cards = hand
648
654
  else:
649
655
  # Must trump if possible
650
- my_trumps = [
651
- c
652
- for c in hand
653
- if c.suit == trump or (seven_eight_trump and c.rank in (Rank.SEVEN, Rank.EIGHT))
654
- ]
656
+ my_trumps = [c for c in hand if is_trump_card(c)]
655
657
  if my_trumps:
656
658
  # Must trump; also must overtrump if possible
657
659
  highest_trump_in_trick = max(