belote-cli 4.9.4__tar.gz → 4.9.6__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 (171) hide show
  1. {belote_cli-4.9.4 → belote_cli-4.9.6}/CHANGELOG.md +97 -0
  2. {belote_cli-4.9.4 → belote_cli-4.9.6}/DEVELOPMENT.md +8 -4
  3. {belote_cli-4.9.4 → belote_cli-4.9.6}/PKG-INFO +4 -4
  4. {belote_cli-4.9.4 → belote_cli-4.9.6}/README.md +3 -3
  5. {belote_cli-4.9.4 → belote_cli-4.9.6}/pyproject.toml +1 -1
  6. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/__init__.py +1 -1
  7. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/ai.py +28 -19
  8. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/ansi.py +76 -5
  9. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/partner_jokers/passive.py +4 -1
  10. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/main.py +23 -10
  11. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/partner/trust.py +10 -6
  12. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/ui/history.py +93 -74
  13. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/ui/hud.py +18 -4
  14. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/ui/trust_bar.py +4 -0
  15. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/config.py +6 -1
  16. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/game.py +19 -17
  17. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/gameflow.py +11 -5
  18. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/scoring.py +25 -15
  19. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/ui/render.py +4 -1
  20. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_jokers_4_5.py +26 -0
  21. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_partner_jokers.py +11 -0
  22. belote_cli-4.9.6/tests/belatro/test_registry_integrity.py +61 -0
  23. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_alt_screen_scroll.py +6 -0
  24. belote_cli-4.9.6/tests/test_deluge_legal_cards.py +68 -0
  25. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_g1_coinche_classic.py +30 -0
  26. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_g2_ai_signals.py +49 -0
  27. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_new_coverage.py +3 -2
  28. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_official_rules.py +21 -0
  29. belote_cli-4.9.6/tests/test_render_golden.py +80 -0
  30. belote_cli-4.9.6/tests/test_scoring_totals.py +62 -0
  31. belote_cli-4.9.6/tests/test_wcwidth.py +88 -0
  32. {belote_cli-4.9.4 → belote_cli-4.9.6}/uv.lock +1 -1
  33. {belote_cli-4.9.4 → belote_cli-4.9.6}/.claude/settings.local.json +0 -0
  34. {belote_cli-4.9.4 → belote_cli-4.9.6}/.gitignore +0 -0
  35. {belote_cli-4.9.4 → belote_cli-4.9.6}/.python-version +0 -0
  36. {belote_cli-4.9.4 → belote_cli-4.9.6}/LICENSE +0 -0
  37. {belote_cli-4.9.4 → belote_cli-4.9.6}/scripts/benchmark.py +0 -0
  38. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/__init__.py +0 -0
  39. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/a11y.py +0 -0
  40. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/achievements.py +0 -0
  41. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/__init__.py +0 -0
  42. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/core/__init__.py +0 -0
  43. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/core/economy.py +0 -0
  44. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/core/round_ledger.py +0 -0
  45. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/core/run_state.py +0 -0
  46. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/core/scoring.py +0 -0
  47. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/engine/__init__.py +0 -0
  48. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/engine/event_bus.py +0 -0
  49. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/engine/modifier_patch.py +0 -0
  50. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/engine/round_driver.py +0 -0
  51. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/ghost_run.py +0 -0
  52. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/__init__.py +0 -0
  53. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/base.py +0 -0
  54. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/jokers/__init__.py +0 -0
  55. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/jokers/annonces.py +0 -0
  56. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/jokers/coinche.py +0 -0
  57. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/jokers/contract.py +0 -0
  58. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  59. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/jokers/economy.py +0 -0
  60. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  61. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
  62. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  63. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  64. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  65. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/planets.py +0 -0
  66. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/registry.py +0 -0
  67. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/tarots.py +0 -0
  68. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/items/vouchers.py +0 -0
  69. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/partner/__init__.py +0 -0
  70. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/partner/partner_state.py +0 -0
  71. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/partner/personality.py +0 -0
  72. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/progression/__init__.py +0 -0
  73. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/progression/save.py +0 -0
  74. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/progression/unlocks.py +0 -0
  75. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/run/__init__.py +0 -0
  76. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/run/ante.py +0 -0
  77. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/run/ante_themes.py +0 -0
  78. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/run/boss.py +0 -0
  79. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/run/decks.py +0 -0
  80. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/run/shop.py +0 -0
  81. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/run_summary.py +0 -0
  82. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/ui/__init__.py +0 -0
  83. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/ui/announce.py +0 -0
  84. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/ui/collection.py +0 -0
  85. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/ui/consumables.py +0 -0
  86. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/ui/inventory.py +0 -0
  87. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/ui/menu.py +0 -0
  88. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/ui/rules.py +0 -0
  89. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/belatro/ui/shop.py +0 -0
  90. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/context.py +0 -0
  91. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/deck.py +0 -0
  92. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/input.py +0 -0
  93. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/main.py +0 -0
  94. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/replay.py +0 -0
  95. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/rules.py +0 -0
  96. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/stats.py +0 -0
  97. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/themes.py +0 -0
  98. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/ui/__init__.py +0 -0
  99. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/ui/anim.py +0 -0
  100. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/ui/announce.py +0 -0
  101. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/ui/fit_guard.py +0 -0
  102. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/ui/layout.py +0 -0
  103. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/ui/menu.py +0 -0
  104. {belote_cli-4.9.4 → belote_cli-4.9.6}/src/belote/ui/prompts.py +0 -0
  105. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/__init__.py +0 -0
  106. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/__init__.py +0 -0
  107. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_belatro.py +0 -0
  108. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_boss_contracts.py +0 -0
  109. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_boss_modifiers_integration.py +0 -0
  110. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_collection_logic.py +0 -0
  111. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_competition_malediction.py +0 -0
  112. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_consumables_ui.py +0 -0
  113. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_contract_unlocks.py +0 -0
  114. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_dead_flag_fixes.py +0 -0
  115. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_deck_variants.py +0 -0
  116. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_decks_4_5.py +0 -0
  117. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_endless.py +0 -0
  118. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_event_bus.py +0 -0
  119. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_ghost_run.py +0 -0
  120. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_handler_registry.py +0 -0
  121. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_heist.py +0 -0
  122. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_history_overlay.py +0 -0
  123. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_hud_synergy.py +0 -0
  124. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_hud_toggle.py +0 -0
  125. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_inventory_overlay.py +0 -0
  126. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_joker_callouts.py +0 -0
  127. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_joker_contracts.py +0 -0
  128. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_partner_trust.py +0 -0
  129. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_phase0_coverage.py +0 -0
  130. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_phase1_plumbing.py +0 -0
  131. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_phase2_content.py +0 -0
  132. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_phase3_meta.py +0 -0
  133. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_progression.py +0 -0
  134. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_round_driver.py +0 -0
  135. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_run_summary.py +0 -0
  136. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_shop_animations.py +0 -0
  137. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_shop_empty_pools.py +0 -0
  138. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_slot_machine_tally.py +0 -0
  139. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/belatro/test_voucher_idempotency.py +0 -0
  140. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/perf_baselines.json +0 -0
  141. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_a11y.py +0 -0
  142. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_achievements.py +0 -0
  143. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_ai.py +0 -0
  144. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_ai_deluge_voids.py +0 -0
  145. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_ai_sans_atout.py +0 -0
  146. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_anim_helpers.py +0 -0
  147. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_announce_stats.py +0 -0
  148. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_ansi_helpers.py +0 -0
  149. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_belote.py +0 -0
  150. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_belote_stinger.py +0 -0
  151. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_benchmark_smoke.py +0 -0
  152. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_bidding_all_pass.py +0 -0
  153. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_declaration_tiebreak.py +0 -0
  154. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_extended.py +0 -0
  155. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_game_logic.py +0 -0
  156. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_gameflow.py +0 -0
  157. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_hand_auto_sort.py +0 -0
  158. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_history_overlay_cache.py +0 -0
  159. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_input_eof.py +0 -0
  160. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_input_wasd.py +0 -0
  161. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_invariants.py +0 -0
  162. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_layout.py +0 -0
  163. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_no_color.py +0 -0
  164. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_perf_regression.py +0 -0
  165. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_properties.py +0 -0
  166. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_render_diff.py +0 -0
  167. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_render_felt_polish.py +0 -0
  168. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_render_hand_lift.py +0 -0
  169. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_replay.py +0 -0
  170. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_undo.py +0 -0
  171. {belote_cli-4.9.4 → belote_cli-4.9.6}/tests/test_winner_glow.py +0 -0
@@ -5,6 +5,103 @@ 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.6] - 2026-06-01
9
+
10
+ Follow-up bug-hunt, logic, and performance audit. Three parallel Explore agents
11
+ re-swept the classic engine, BelAtro mechanics, and the UI/render layer; every
12
+ "major" finding was then traced to exact code and verified before acting. The
13
+ result: **no critical or major bug survived verification.** The flagged items
14
+ were all either false positives (Le Passeur "double-payout" — the post-coinche
15
+ `BidMadeEvent` re-emit carries the taker's seat + a real trump, so the
16
+ North-pass gate never matches; Le Carnet's +1 Mult *is* wired via
17
+ `acc.carnet_active`; Tout Streak / Quinte Royale re-emit guards are already
18
+ present; `modifier_patch` is a fresh per-instance proxy each round) or
19
+ intentional, test-pinned behaviour (Le Patriote's `// 2`). Benchmarks confirmed
20
+ the render/scoring/AI paths are already well within budget (render ≈0.4 ms, E2E
21
+ round ≈4–6 ms). This release therefore lands the one genuine latent gap the
22
+ audit surfaced plus a layer of behaviour-locking tests. Test count 1079 → 1111.
23
+
24
+ ### Added
25
+
26
+ - **Minimal wide-character (wcwidth) support** (`src/belote/ansi.py`). `visible_len()`
27
+ previously counted codepoints, so `ansi_center()` / `ansi_ljust()` and the
28
+ render diff's per-row width would mis-align by one cell per wide glyph (emoji,
29
+ CJK). A new dependency-free `char_width()` (stdlib `unicodedata`) returns 0
30
+ for combining/zero-width marks, 2 for East-Asian Wide/Fullwidth, 1 otherwise,
31
+ and `visible_len()` now measures display **cells**. A pure-ASCII escape-free
32
+ fast path keeps the hot render path allocation-free and byte-identical, so
33
+ there is no performance regression. A new `ansi_truncate()` (cell-aware,
34
+ escape-skipping) replaces the raw byte-slices that trimmed BelAtro HUD joker
35
+ names and synergy lines (`belatro/ui/hud.py`), which could previously split a
36
+ wide glyph.
37
+ - **Behaviour-locking tests** that freeze what the audit confirmed correct:
38
+ `tests/test_render_golden.py` (full-frame snapshot + per-row cell-width
39
+ fingerprint — the regression guard for the wcwidth change),
40
+ `tests/test_wcwidth.py` (per-character widths and helper cell-correctness),
41
+ `tests/test_scoring_totals.py` (the canonical 152 / 120 / 248 point totals,
42
+ belote 20, capot bases 252 / 220 / 348, dix de der +10), and
43
+ `tests/belatro/test_registry_integrity.py` (item-census counts 42 jokers /
44
+ 8 planets / 12 tarots / 12 vouchers, id↔key consistency, no duplicate display
45
+ names — so a duplicate-id registration that silently overwrites an item now
46
+ fails a test).
47
+
48
+ ## [4.9.5] - 2026-05-28
49
+
50
+ Bug-hunt, logic, and performance audit pass. Three parallel Explore agents
51
+ swept the classic engine, BelAtro mechanics, and the UI/render layer; every
52
+ finding below was traced to exact code and the high-severity ones reproduced at
53
+ runtime before fixing. Test count 1066 → 1079.
54
+
55
+ ### Fixed
56
+
57
+ - **Crash: `legal_cards` returned an empty set under La Déluge**
58
+ (`src/belote/game.py`). With the `seven_eight_trump` boss active, every 7/8
59
+ is a trump — but the trump-follow set was built from `c.suit == lead_suit`,
60
+ which dropped off-suit 7/8s. A player void of the trump *suit* but holding
61
+ only off-suit 7/8 (which ARE trumps) got zero legal cards on a trump lead,
62
+ tripping `AIPlayer.decide_card`'s empty-legal assertion. A single
63
+ `is_trump_card` predicate now drives both the trump-led and non-trump-led
64
+ follow/overtrump paths. Regression tests in `tests/test_deluge_legal_cards.py`.
65
+ - **AI partner-signal double-count after a mid-round undo** (`src/belote/ai.py`).
66
+ The undo-regression branch reset the void cache and `processed_tricks_count`
67
+ but not the partner-signal tally; because `_update_voids` re-walks surviving
68
+ tricks from zero, their signals were counted twice. Both reset paths now share
69
+ a single `_reset_round_memory()` helper so they can't drift again.
70
+ - **Belote + Rebelote scored 40 instead of the official 20** (`src/belote/config.py`).
71
+ Holding the King and Queen of trump is worth 20 points *total* in official
72
+ French Belote — and the in-game Rules screen already said 20. `REBELOTE_POINTS`
73
+ is now 20, matching both.
74
+ - **Coinche round-end animation showed the pre-multiplier score**
75
+ (`src/belote/gameflow.py`). The score counter animated to the raw breakdown
76
+ total while the stored score applied `2 ** coinche_level`. Both now use the
77
+ shared `scoring.coinche_adjusted_credits()` helper, so the animation lands on
78
+ the real total.
79
+ - **Joker money not reconciled on a failed-but-survived round**
80
+ (`src/belote/belatro/main.py`). Signed `JokerResult.add_money` (e.g.
81
+ LePrêteur's −$5 skim, baked into the round's ×Mult) was only applied on the
82
+ success path, handing a free multiplier when a chute was survived via Capot
83
+ Insurance. A shared `_reconcile_bonus_money()` helper now runs on both paths.
84
+ - **La Symbiose fired ×1.2 Mult on zero-point declarations under Le Mime**
85
+ (`src/belote/belatro/items/partner_jokers/passive.py`). Now gated on
86
+ `event.points > 0`, mirroring Le Mathématicien.
87
+
88
+ ### Changed
89
+
90
+ - **BelAtro HUD / TrustBar now invalidate the render diff** after their direct
91
+ stdout writes (`belatro/ui/hud.py`, `trust_bar.py`), matching the 4.0.0
92
+ overlay convention so a static-state `display()` can't leave a stale top bar.
93
+ The convention is now enforced for both files by
94
+ `tests/test_alt_screen_scroll.py`.
95
+ - **Performance**: `_pip_at`'s LRU cache raised 4096 → 8192 so wide-terminal
96
+ felt mats (>5000 cells/frame) stop thrashing it (`ui/render.py`);
97
+ `visible_len` no longer spends cache slots on plain (no-ANSI) strings
98
+ (`ansi.py`); the BelAtro [H] history overlay caches its line list on
99
+ `(term_w, entry_count)` instead of rebuilding every keystroke
100
+ (`belatro/ui/history.py`).
101
+ - **Docs**: corrected the trust tier-0 docstring (it claimed "effects halved";
102
+ the implementation applies the baseline once with no penalty) and the tier
103
+ bonus percentages to match the `(0, 0, 1, 1, 2)` extra-applies model.
104
+
8
105
  ## [4.9.4] - 2026-05-26
9
106
 
10
107
  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 (1111 tests expected)
88
88
  PYTHONPATH=src pytest
89
89
  ```
90
90
 
@@ -221,6 +221,10 @@ 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 / 4.9.6 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; 4.9.6 made `visible_len` measure
228
+ terminal *cells* via a minimal `char_width`, keeping the allocation-free
229
+ ASCII fast path so the hot path is unchanged) — start from a profiler trace,
230
+ not 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.6
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 (1111 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 **1069 tests** passing with 100% coverage on game-logic modules (4.9.4).
282
+ Currently **1111 tests** passing with 100% coverage on game-logic modules (4.9.6).
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**: 1044/1044 passed
289
+ - **pytest**: 1111/1111 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 (1069 tests)
224
+ ├── tests/ # Comprehensive test suite (1111 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 **1069 tests** passing with 100% coverage on game-logic modules (4.9.4).
239
+ Currently **1111 tests** passing with 100% coverage on game-logic modules (4.9.6).
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**: 1044/1044 passed
246
+ - **pytest**: 1111/1111 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.9.4"
7
+ version = "4.9.6"
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.6"
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
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import re
5
+ import unicodedata
5
6
  from dataclasses import dataclass
6
7
  from functools import lru_cache
7
8
 
@@ -102,15 +103,52 @@ RESET = "\x1b[0m"
102
103
 
103
104
 
104
105
  @lru_cache(maxsize=4096)
106
+ def char_width(ch: str) -> int:
107
+ """Terminal display width of a single character, in cells.
108
+
109
+ 4.9.6: minimal, dependency-free wcwidth using only the stdlib
110
+ ``unicodedata``. Combining marks and zero-width codepoints occupy 0 cells;
111
+ East-Asian Wide/Fullwidth glyphs (CJK, many emoji) occupy 2; everything
112
+ else 1. Not a full Unicode-grapheme/emoji-ZWJ implementation — it is the
113
+ pragmatic subset the UI needs so ``visible_len`` reports *cells*, not
114
+ codepoints, and centering/padding/truncation stay aligned.
115
+ """
116
+ # C0/C1 controls (other than handled escapes) have no width here.
117
+ if ch == "\x00":
118
+ return 0
119
+ if unicodedata.combining(ch) or unicodedata.category(ch) in ("Mn", "Me", "Cf"):
120
+ return 0
121
+ if unicodedata.east_asian_width(ch) in ("W", "F"):
122
+ return 2
123
+ return 1
124
+
125
+
126
+ @lru_cache(maxsize=2048)
127
+ def _visible_len_ansi(s: str) -> int:
128
+ return sum(char_width(c) for c in _RESET_RE.sub("", s))
129
+
130
+
105
131
  def visible_len(s: str) -> int:
106
- """Return length of string with ANSI escape codes stripped."""
107
- if "\x1b" not in s:
132
+ """Return the terminal *cell* width of a string, ignoring ANSI escapes.
133
+
134
+ 4.9.5 (P3): only the regex-stripping path is cached — plain (no-ESC)
135
+ strings skip the bounded cache so it stays reserved for ANSI strings that
136
+ benefit from memoising the regex sub (static glyphs, frames).
137
+
138
+ 4.9.6: width is measured in display cells (see ``char_width``), not
139
+ codepoints. Pure-ASCII *escape-free* strings — the overwhelmingly common
140
+ case — take the allocation-free ``len(s)`` fast path since every such char
141
+ is exactly one cell. Anything with an ESC (note: ESC is itself ASCII, so it
142
+ must be excluded explicitly) or any non-ASCII char pays for the strip +
143
+ per-char width sum.
144
+ """
145
+ if "\x1b" not in s and s.isascii():
108
146
  return len(s)
109
- return len(_RESET_RE.sub("", s))
147
+ return _visible_len_ansi(s)
110
148
 
111
149
 
112
150
  def ansi_center(s: str, width: int) -> str:
113
- """Center a string by visible width, padding both sides."""
151
+ """Center a string by visible cell width, padding both sides."""
114
152
  vlen = visible_len(s)
115
153
  pad = max(0, width - vlen)
116
154
  left = pad // 2
@@ -119,7 +157,7 @@ def ansi_center(s: str, width: int) -> str:
119
157
 
120
158
 
121
159
  def ansi_ljust(s: str, width: int) -> str:
122
- """Left-justify a string to `width` visible characters, ANSI-aware.
160
+ """Left-justify a string to `width` visible cells, ANSI-aware.
123
161
  Never use str.ljust() on ANSI strings — it counts escape bytes as chars.
124
162
  """
125
163
  vlen = visible_len(s)
@@ -127,6 +165,39 @@ def ansi_ljust(s: str, width: int) -> str:
127
165
  return s + " " * pad
128
166
 
129
167
 
168
+ def ansi_truncate(s: str, width: int) -> str:
169
+ """Truncate `s` to at most `width` visible cells, skipping ANSI escapes.
170
+
171
+ Cell-aware: a wide (2-cell) glyph that would straddle the boundary is
172
+ dropped rather than split. ASCII strings without escapes take a plain slice
173
+ fast path. Returns the original string untouched when it already fits.
174
+ """
175
+ if s.isascii() and "\x1b" not in s:
176
+ return s if len(s) <= width else s[:width]
177
+ if visible_len(s) <= width:
178
+ return s
179
+ out: list[str] = []
180
+ used = 0
181
+ i = 0
182
+ n = len(s)
183
+ while i < n:
184
+ ch = s[i]
185
+ if ch == "\x1b":
186
+ # Copy the whole escape sequence verbatim (zero width).
187
+ m = _RESET_RE.match(s, i)
188
+ if m:
189
+ out.append(m.group())
190
+ i = m.end()
191
+ continue
192
+ w = char_width(ch)
193
+ if used + w > width:
194
+ break
195
+ out.append(ch)
196
+ used += w
197
+ i += 1
198
+ return "".join(out)
199
+
200
+
130
201
  @lru_cache(maxsize=512)
131
202
  def _fg_seq(r: int, g: int, b: int) -> str:
132
203
  return f"\x1b[38;2;{r};{g};{b}m"
@@ -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