belote-cli 3.8.2__tar.gz → 3.9.3__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 (135) hide show
  1. {belote_cli-3.8.2 → belote_cli-3.9.3}/CHANGELOG.md +93 -0
  2. {belote_cli-3.8.2 → belote_cli-3.9.3}/DEVELOPMENT.md +21 -4
  3. {belote_cli-3.8.2 → belote_cli-3.9.3}/PKG-INFO +5 -5
  4. {belote_cli-3.8.2 → belote_cli-3.9.3}/README.md +4 -4
  5. {belote_cli-3.8.2 → belote_cli-3.9.3}/pyproject.toml +1 -1
  6. {belote_cli-3.8.2 → belote_cli-3.9.3}/scripts/benchmark.py +87 -19
  7. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/__init__.py +1 -1
  8. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/ai.py +32 -6
  9. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/ansi.py +30 -2
  10. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/core/run_state.py +10 -5
  11. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/base.py +22 -2
  12. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/jokers/economy.py +12 -5
  13. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/vouchers.py +12 -12
  14. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/main.py +17 -1
  15. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/progression/unlocks.py +21 -3
  16. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/run/shop.py +6 -8
  17. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/ui/announce.py +2 -2
  18. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/ui/hud.py +55 -22
  19. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/game.py +42 -13
  20. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/gameflow.py +33 -4
  21. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/main.py +23 -34
  22. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/scoring.py +23 -6
  23. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/ui/prompts.py +0 -1
  24. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/ui/render.py +86 -3
  25. belote_cli-3.9.3/tests/belatro/test_endless.py +95 -0
  26. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_phase2_content.py +67 -0
  27. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_progression.py +90 -0
  28. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_voucher_idempotency.py +43 -0
  29. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/test_ai.py +59 -1
  30. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/test_belote.py +82 -0
  31. belote_cli-3.9.3/tests/test_benchmark_smoke.py +29 -0
  32. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/test_input_eof.py +14 -0
  33. belote_cli-3.9.3/tests/test_no_color.py +62 -0
  34. belote_cli-3.9.3/tests/test_render_diff.py +144 -0
  35. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/test_undo.py +65 -0
  36. belote_cli-3.9.3/uv.lock +526 -0
  37. {belote_cli-3.8.2 → belote_cli-3.9.3}/.claude/settings.local.json +0 -0
  38. {belote_cli-3.8.2 → belote_cli-3.9.3}/.gitignore +0 -0
  39. {belote_cli-3.8.2 → belote_cli-3.9.3}/.python-version +0 -0
  40. {belote_cli-3.8.2 → belote_cli-3.9.3}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
  41. {belote_cli-3.8.2 → belote_cli-3.9.3}/LICENSE +0 -0
  42. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/__init__.py +0 -0
  43. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/a11y.py +0 -0
  44. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/achievements.py +0 -0
  45. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/__init__.py +0 -0
  46. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/core/__init__.py +0 -0
  47. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/core/economy.py +0 -0
  48. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/core/scoring.py +0 -0
  49. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/engine/__init__.py +0 -0
  50. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/engine/event_bus.py +0 -0
  51. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/engine/modifier_patch.py +0 -0
  52. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/engine/round_driver.py +0 -0
  53. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/ghost_run.py +0 -0
  54. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/__init__.py +0 -0
  55. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/jokers/__init__.py +0 -0
  56. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/jokers/annonces.py +0 -0
  57. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/jokers/coinche.py +0 -0
  58. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/jokers/contract.py +0 -0
  59. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  60. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  61. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
  62. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  63. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  64. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  65. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  66. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/planets.py +0 -0
  67. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/registry.py +0 -0
  68. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/items/tarots.py +0 -0
  69. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/partner/__init__.py +0 -0
  70. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/partner/partner_state.py +0 -0
  71. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/partner/personality.py +0 -0
  72. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/partner/trust.py +0 -0
  73. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/progression/__init__.py +0 -0
  74. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/progression/save.py +0 -0
  75. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/run/__init__.py +0 -0
  76. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/run/ante.py +0 -0
  77. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/run/ante_themes.py +0 -0
  78. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/run/boss.py +0 -0
  79. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/run/decks.py +0 -0
  80. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/run_summary.py +0 -0
  81. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/ui/__init__.py +0 -0
  82. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/ui/collection.py +0 -0
  83. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/ui/consumables.py +0 -0
  84. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/ui/history.py +0 -0
  85. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/ui/menu.py +0 -0
  86. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/ui/rules.py +0 -0
  87. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/ui/shop.py +0 -0
  88. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/belatro/ui/trust_bar.py +0 -0
  89. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/config.py +0 -0
  90. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/context.py +0 -0
  91. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/deck.py +0 -0
  92. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/input.py +0 -0
  93. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/replay.py +0 -0
  94. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/rules.py +0 -0
  95. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/stats.py +0 -0
  96. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/themes.py +0 -0
  97. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/ui/__init__.py +0 -0
  98. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/ui/announce.py +0 -0
  99. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/ui/fit_guard.py +0 -0
  100. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/ui/layout.py +0 -0
  101. {belote_cli-3.8.2 → belote_cli-3.9.3}/src/belote/ui/menu.py +0 -0
  102. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/__init__.py +0 -0
  103. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/__init__.py +0 -0
  104. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_belatro.py +0 -0
  105. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_boss_modifiers_integration.py +0 -0
  106. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_collection_logic.py +0 -0
  107. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_consumables_ui.py +0 -0
  108. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_contract_unlocks.py +0 -0
  109. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_dead_flag_fixes.py +0 -0
  110. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_deck_variants.py +0 -0
  111. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_event_bus.py +0 -0
  112. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_ghost_run.py +0 -0
  113. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_history_overlay.py +0 -0
  114. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_hud_synergy.py +0 -0
  115. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_partner_jokers.py +0 -0
  116. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_partner_trust.py +0 -0
  117. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_phase0_coverage.py +0 -0
  118. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_phase1_plumbing.py +0 -0
  119. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_phase3_meta.py +0 -0
  120. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_round_driver.py +0 -0
  121. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_run_summary.py +0 -0
  122. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/belatro/test_shop_empty_pools.py +0 -0
  123. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/test_a11y.py +0 -0
  124. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/test_achievements.py +0 -0
  125. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/test_ansi_helpers.py +0 -0
  126. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/test_bidding_all_pass.py +0 -0
  127. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/test_declaration_tiebreak.py +0 -0
  128. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/test_extended.py +0 -0
  129. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/test_game_logic.py +0 -0
  130. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/test_gameflow.py +0 -0
  131. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/test_layout.py +0 -0
  132. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/test_new_coverage.py +0 -0
  133. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/test_official_rules.py +0 -0
  134. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/test_properties.py +0 -0
  135. {belote_cli-3.8.2 → belote_cli-3.9.3}/tests/test_replay.py +0 -0
@@ -5,6 +5,99 @@ 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
+ ## [3.9.3] - 2026-05-15
9
+
10
+ Multi-agent audit pass (six Explore agents — three broad-sweep + three targeted deep-dives on AI, scoring edge cases, per-joker mechanics, endless mode, meta-progression). Every load-bearing finding verified against the live code before patching; **8 false positives** caught and documented (corrupted-joker seat semantics being intentional, ToutStreak persistence working correctly via `card_enhancements`, `_card_back` LRU cache being theme-keyed, etc.). **Six real fixes (R1–R7)** landed plus three larger phases: render-diff layer, endless-mode boss-variety guard, and a Quinte Royale unlock that had been marked `is_unlockable` for releases without a trigger. **Rematch entirely removed** at user request — game-over screen keeps only `[Enter/Q] Menu` and `[H] History`; players who want to play again use the menu. **+30 regression tests** (661 → 691).
11
+
12
+ ### Fixed
13
+
14
+ - **`src/belote/ai.py::_hard_special` + `_hard_bid` (R1, MED) — AI bidding now honors zero-rank boss flags.** Pre-3.9.3 the heuristic summed raw `card_points` even when `jacks_zero` / `aces_zero` / `kings_zero` / `tens_zero` / `ban_clubs` were active, so the AI overbid Tout Atout on a jack-heavy hand under Le Sauvage even though those jacks would score 0. The fix routes evaluation through the new public `scoring.card_points_with_modifiers(card, trump, bm)` helper (a thin wrapper over the existing `_card_points_with_zero_ranks` — same canonical zero-rank logic the live HUD and final scoring use). Honor-counting in the regular-suit branch also drops a rank if its boss flag is active. Regression: `tests/test_ai.py::test_hard_ai_does_not_bid_ta_when_jacks_zero_suppresses_jacks`.
15
+ - **`src/belote/game.py::_record_belote_announcement` + new `GameState.belote_trump` field (R2, LOW) — L'Anarchie preserves rebelote across mid-belote trump rotation.** When trump rotates after trick 2/4/6 (`dynamic_trump`), a K-trump played in trick 2 and Q-of-original-trump in trick 3 would silently drop the rebelote because the K/Q match used `card.suit == current_trump`. The fix captures the trump at first-belote into a new `belote_trump: Suit | None` GameState field (mirrors the existing `belote_announcer` pattern) and matches the rebelote K/Q against the captured suit. Regression: `tests/test_belote.py::test_anarchie_rebelote_survives_cross_rotation_play` + non-rotation sanity test.
16
+ - **`src/belote/belatro/ui/hud.py::BelAtroHUD.render` + `_render_compact` (R3, LOW) — HUD score line gated under La Compétition boss.** The live running total (sequential trick-point sum) diverges from the final score under `separate_scoring` (which picks per-seat max). The HUD now shows a "Compétition: score par siège — total final caché" disclaimer instead of a misleading running total when this boss is active.
17
+ - **`src/belote/belatro/items/jokers/economy.py::LeBanquier` (R4, LOW) — bonus cash now suppressed on failed rounds and on EW-taker rounds.** Description says "Earn $1 for every 10 card points you score above the Blind target"; that framing presumes NS held a successful contract. Pre-3.9.3 the joker paid out unconditionally — even on chute, even when EW was taker. Now gates on `not breakdown.is_failed and taker_seat in (SOUTH, NORTH)`. Other on-round-end jokers (LAvare, LeFantome, LaSentinelle, LAccumulateur, LaSentinelleP) were audited and intentionally remain ungated — their descriptions are state-based (cards in hand, jack never used to win, etc.), not win-conditional.
18
+
19
+ ### Changed
20
+
21
+ - **`src/belote/belatro/items/base.py::Voucher` + `belatro/items/vouchers.py` + `belatro/run/shop.py` (R5) — voucher idempotency guard relocated from `Shop._apply_item` into the `Voucher` base class.** Subclasses now implement `_apply_once(run)`; the base's `apply(run)` consults `run._applied_voucher_ids` and short-circuits a second invocation. Any future caller (replay reconstruction, deck-builder preview, hypothetical save/load) inherits the same protection automatically — pre-3.9.3 the guard only fired when the shop was the call site, so a different caller would silently double-stack `+=` vouchers (LaTelescope, LaDoubleDonne, LesCartesDorees, LeCouteau). Regression: `tests/belatro/test_voucher_idempotency.py::test_direct_voucher_apply_is_idempotent_without_shop`.
22
+ - **`src/belote/belatro/ui/hud.py` (R7) — every bare `print()` call batched into one `sys.stdout.write` + `flush`** per render entry point (`render`, `_render_compact`, `render_joker_pip_strip`, `render_synergy_tooltip`). ~9 fewer syscalls per BelAtro HUD frame. Mirrors the canonical `render.py:1002` pattern.
23
+ - **`src/belote/ai.py:627` (R6) — comment fix.** Said "prefer keeping high value cards if not winning" but the code adds points to the play-score (i.e. biases toward *playing* high cards). Rewritten as "Small per-card tiebreaker: when win/loss heuristics are otherwise equal, slightly bias toward playing the higher-value card."
24
+
25
+ ### Added
26
+
27
+ - **`src/belote/ui/render.py` (Phase 6) — diff-based render emit.** `display()` now compares each frame's per-row line list against the previously committed frame and writes only the rows that actually changed. Idle re-renders (polling input between keystrokes, hovering on a card without moving) collapse from ~28 row writes to zero. New module-level `_last_emitted_lines` baseline; reset on layout change (via the existing `_last_render_key` invalidation), theme change (via the existing `theme_manager.register_callback(clear_card_cache)` callback — `clear_card_cache` now also clears `_card_back` LRU + the diff baseline), and explicit `display(state, force=True)`. Escape hatch: `BELOTE_NO_DIFF=1`. The full `render()` string contract is unchanged — tests calling `render(state)` directly still get the complete frame. Regression: `tests/test_render_diff.py` (6 tests).
28
+ - **`src/belote/scoring.py::card_points_with_modifiers` (public)** — thin wrapper exposing the existing `_card_points_with_zero_ranks` for callers outside `scoring.py` (notably `ai.py` for R1).
29
+ - **`src/belote/game.py::GameState.belote_trump: Suit | None`** — new field set when `belote_tracker[0]` first flips True, read by the rebelote check (R2).
30
+ - **`src/belote/belatro/core/run_state.py::BelAtroRun._recent_boss_ids: list[str]` (Phase 5) — endless-mode boss variety guard.** The boss selector in `belatro/main.py` now suppresses immediate boss repeats in endless by rerolling against the last-2 window (capped at 8 reroll attempts to never loop on degenerate pools / monkeypatched single-boss tests). Normal-run boss selection is unchanged — variety is only enforced when `run.endless` is True. Regression: `tests/belatro/test_endless.py::test_recent_boss_window_is_bounded_to_two`.
31
+ - **`src/belote/belatro/progression/unlocks.py::UnlockTracker._handle_declaration` (Phase 8) — Quinte Royale now unlocks on a Quinte announcement.** The Legendary joker was marked `is_unlockable = True` in `annonces.py:71` for several releases but had no path to actually unlock — `_handle_round_end` never set the flag, so the joker was filtered out of every shop pool. `UnlockTracker.on_event` now also dispatches `DeclarationScoredEvent`; when NS announces a sequence ≥ 100 pts, `quinte_royale` unlocks and the pending-announcement banner fires. Regression: `tests/belatro/test_progression.py::test_quinte_declaration_unlocks_quinte_royale` + negative tests (short sequence / opponent quinte).
32
+ - **`src/belote/gameflow.py::_undo_pop_to_south` + visible undo banner (Phase 7b user request).** Pressing `Z` previously popped one history-stack entry — but the stack records *every* play including AI moves, so a Z after the AI finished a trick landed on an AI mid-trick state, and the AI re-played deterministically with no visible effect. The new helper pops until the restored state has `turn == SOUTH` (the user's actual prior decision point), bounded by `stack_base` so undo can't cross round boundaries. After each successful pop, `announce("↶ Undo", duration=0.8, reader=reader)` paints a 0.8 s banner before the rolled-back position is redrawn so the player sees the undo take effect. Same UX applied to the bidding-undo path. Regression: `tests/test_undo.py::test_undo_pop_to_south_skips_intermediate_ai_states` + 2 boundary tests.
33
+
34
+ ### Removed
35
+
36
+ - **Rematch feature (user request).** `src/belote/main.py` no longer offers `[R] Rematch` on the game-over screen; the only post-game choices are `[Enter/Q] Menu` and `[H] History`. The `rematch` variable, the conditional-menu-skip flag, and the dedicated reset path are all gone. `src/belote/ui/prompts.py::show_help` line `"[R] Rematch (Game Over)"` deleted.
37
+
38
+ ### Audit verdict — verified clean, false positives caught
39
+
40
+ Six Explore agents flagged a number of "critical" issues that were verified against the actual code and rejected:
41
+
42
+ 1. **Corrupted jokers (Le Traître / Le Démon / L'Égoïste / L'Agent Double) using `event.winner == Seat.SOUTH` instead of team scope** — intentional design. The class names and descriptions ("partner is irrelevant", "partner plays for opponents", "partner throws one trick per round") explicitly mark these as themed sabotage jokers. South-only is the design contract.
43
+ 2. **Tout Streak streak persistence at `main.py:367-369` being "dead code"** — false. Verified at `round_driver.py:121-123`: `card_enhancements` is merged into `state._joker_state` at the start of every round, so the persisted `tout_streak_streak` flows back in. The persistence works.
44
+ 3. **`_card_back` LRU cache going stale on theme change** — false. The cache is keyed by `theme_name` so a new theme produces a new key and a fresh render; old entries are LRU-evicted naturally. (3.9.3 still adds `_card_back.cache_clear()` to `clear_card_cache()` for memory hygiene since the diff-layer change now invalidates the frame baseline on theme change too — but the original "stale render" concern was wrong.)
45
+ 4. **Negative-edition jokers "bypassing" the slot check** — intentional. `_can_accept` at `shop.py:152-154` returns True for Negative jokers by design; that's the whole point of the Negative edition.
46
+ 5. **The Sun (Tout Atout) planet firing regardless of taker** — false. The parent block at `belatro/core/scoring.py:198+` gates on `event.winner in _NS_TEAM` already.
47
+ 6. **BelAtro HUD `print()` calls causing "torn frames"** — theoretical only. Python stdout is single-threaded line-buffered; a bare `print()` is atomic per call in a TTY. We batched anyway (R7) as a micro-perf win, but there was no visual bug.
48
+ 7. **L'Anarchie rebelote always broken** — partially false. Within the standard sequence (K then Q in the same trick or in trick 1+2 before the first rotation), rebelote already worked. Only the cross-rotation case was real (fixed as R2).
49
+ 8. **`clear_card_cache()` being "incomplete"** — cosmetic at worst. Both LRU caches are theme-keyed, so staleness is impossible.
50
+
51
+ The "fake risky" agent finding (jokers in `risky.py` / `shaper.py` needing `is_failed` gates) cited files that don't exist — the real joker files are `annonces` / `coinche` / `contract` / `corrupted` / `economy` / `hand_comp` / `trick_timing` + `partner_jokers/{passive,shaper}`. Audit re-done against the actual files; only LeBanquier needed gating (R4).
52
+
53
+ ### Performance verdict
54
+
55
+ - Benchmark smoke: 203–235 rounds/sec end-to-end (was 235 pre-3.9.3; well within budget, variance is system load).
56
+ - New render diff layer: idle re-render byte count drops to < 25% of the first frame (regression test pins this).
57
+ - No measured hotspot in scoring, AI decision, or trick-play paths. The 3.6 / 3.7.1 / 3.8.0 audits already covered the obvious cost centers.
58
+
59
+ ### Internal
60
+
61
+ - **Tests**: 661 → 691 (+30). R1 (×1), R2 (×2), R4 (×3), R5 (×2), Phase 6 (×6), Phase 5 (×6), Phase 7b (×3), Phase 8 (×7).
62
+ - **Strict gates**: pytest 691/691 green; benchmark smoke green.
63
+ - **Version markers bumped**: `pyproject.toml`, `src/belote/__init__.py`.
64
+ - **Plan file**: `/home/mrrobot/.claude/plans/bug-hunt-code-performance-mutable-blanket.md`.
65
+
66
+
67
+ ## [3.9.0] - 2026-05-14
68
+
69
+ Comprehensive bug-hunt, logic, and performance audit pass. Three parallel Explore agents covered the classic engine (`game.py` / `scoring.py` / `ai.py` / `gameflow.py` / `deck.py`), the BelAtro engine (`round_driver.py` / `modifier_patch.py` / `event_bus.py` / `belatro/core/scoring.py` / `boss.py` / `run_state.py` / `shop.py` / `belatro/main.py`), and the items catalogue + UI hot paths (`registry.py` + every joker / planet / tarot / voucher + `render.py` / `hud.py` / shop UI). Every load-bearing claim was spot-checked against the current code. **One real bug** (LOW), **one feature gap** filled, and a defensive cosmetic cleanup. Performance verdict: no measured hotspot — prior 3.6 / 3.7.1 / 3.8.0 audits already addressed the obvious paths. All 21 boss modifiers, 36 jokers, 8 planets, 12 tarots, 12 vouchers verified wired end-to-end. **+6 regression tests** (655 → 661). Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-wise-puzzle.md`.
70
+
71
+ ### Fixed
72
+
73
+ - **`src/belote/belatro/ui/announce.py:98` (LOW) — `BelAtroAnnounce.yes_no()` no longer hangs on `Key.EOF`.** Pre-3.9.0 the prompt loop exited on `Key.ENTER`, `Key.ESC`, `Key.QUIT`, or character `y`/`n`/`o`, but had no `Key.EOF` return path. Two call sites in `belatro/main.py` (the post-Ante-8 endless prompt at line 137 and the player-side surcoinche prompt at line 267) would hang the process on Ctrl-D / piped-empty stdin / closed terminal. Established inconsistency: sibling `banner()` (line 75) and `score_popup()` (line 126) in the same file already handled EOF. Regression test in `tests/test_input_eof.py::test_announce_yes_no_returns_false_on_eof`.
74
+
75
+ ### Added
76
+
77
+ - **`src/belote/ansi.py` — `NO_COLOR` env-var support** per the [no-color.org](https://no-color.org/) spec. When `NO_COLOR` is set to any non-empty value, `fg()` / `bg()` return the empty string. SGR formatting (`BOLD`, `DIM`, `REVERSE`, `UNDERLINE`, `STRIKETHROUGH`, `RESET`) and cursor/clear sequences are unchanged — they aren't color. Read once at import (mirrors `a11y._ENABLED`); `_refresh_no_color_from_env()` exported for tests. 4 new tests in `tests/test_no_color.py`.
78
+ - **`scripts/benchmark.py::benchmark_belatro_round` (new)** — end-to-end `drive_round` rounds/sec probe under a deterministic seed (mean + p95 + rounds/sec). Regression sentinel for round-driver throughput. New `--smoke` flag runs every benchmark at iterations=2 for a fast CI-friendly check; pinned by `tests/test_benchmark_smoke.py`.
79
+
80
+ ### Changed
81
+
82
+ - **`src/belote/scoring.py::resolve_declarations` (cosmetic)** — the 4-seat announce-order tuple (clockwise from taker) is now built once in the enclosing function and shared by `_resolve_tie_carre` and `_resolve_tie_seq` via closure. Pre-3.9.0 each helper rebuilt the same tuple inline. Behavior-preserving; the regression guard is the existing `tests/test_declaration_tiebreak.py` suite.
83
+ - **`scripts/benchmark.py` cleanup** — auto-fixed 17 long-standing ruff issues (W293/W291/I001/F401) in the file as part of the enhancement-pass scope.
84
+
85
+ ### Audit verdict — verified clean, no fix needed
86
+
87
+ - **Classic engine** (game.py / scoring.py / ai.py / gameflow.py / deck.py): all 19 game mechanics wired end-to-end (bidding pass/normal/TA/SA/coinche/surcoinche, trump-play follow/trump/overtrump/partner-master exception, declarations + tie-break, belote/rebelote, contract-aware Capot 220/348/252, Dix-de-der, litige, taker-failed redistribution). AI memoization (`processed_tricks_count` / `last_voids_key` / `last_partner_hand_key`) resets correctly on both new-round and undo paths.
88
+ - **BelAtro engine**: all 21 boss modifiers patch → state → read end-to-end; no `boss.id == "…"` string branching anywhere; event bus correctly round-scoped; `ScoreAccumulator.update_state` already coalesces into one `replace()` per event; voucher idempotency guard live; endless mode (×2.2) scaling correct.
89
+ - **Items + UI**: all 36 jokers' `on_event` signatures match bus dispatch; team-not-seat convention applied correctly (jokers checking "did our team win" use `team_of(event.winner) == 0`, partner jokers deliberately key on `Seat.NORTH`). Theme cache invalidates on theme change; `_card_face` and `visible_len` both `lru_cache(4096)` with proper invalidation; HUD opt-in rebuild via `force_hud=False` since 3.8.0. Shop layout: card frame 16 cells, gap 2→1→0 degradation works.
90
+
91
+ ### Performance verdict
92
+
93
+ No measured hotspot. `dataclasses.replace()` on the frozen `GameState` runs ~256 calls/round at sub-µs each — well under the 1 ms/round budget. Joker triggers linear-scan over ≤ 5 jokers (max slots). The "enhancement plan" here is the regression sentinel (`benchmark_belatro_round`), not a speculative rewrite.
94
+
95
+ ### Internal
96
+
97
+ - **Tests**: 655 → 661 (+6). yes_no EOF ×1, NO_COLOR ×4, benchmark smoke ×1.
98
+ - **Strict gates**: pytest 661/661 green, mypy `--strict` 0 errors (77 files), ruff 0 violations.
99
+ - **Version markers bumped**: `pyproject.toml`, `src/belote/__init__.py`.
100
+
8
101
  ## [3.8.2] - 2026-05-14
9
102
 
10
103
  Final logic audit and performance hardening. This release addresses the remaining edge cases identified during the deep-dive audit, focusing on BelAtro joker persistence, declaration scoring correctness, and test suite optimization. All 655 tests passing.
@@ -84,14 +84,27 @@ PYTHONPATH=src mypy --strict src/
84
84
  # Linting (0 violations expected)
85
85
  ruff check src/ tests/
86
86
 
87
- # Full test suite (655 tests expected)
87
+ # Full test suite (691 tests expected)
88
88
  PYTHONPATH=src pytest
89
89
  ```
90
90
 
91
- Current baseline (3.8.1):
92
- Current baseline (3.8.2):
91
+ Current baseline (3.9.3):
92
+
93
+ - **691 tests** passing (3.9.0 had 661; +30 in 3.9.3 across R1/R2/R4/R5 regressions + 3 new test files: `test_render_diff.py`, `test_endless.py`, expanded `test_progression.py` / `test_undo.py` / `test_voucher_idempotency.py` / `test_phase2_content.py`).
94
+ - 3.9.3 ships a multi-agent audit pass (six Explore agents — three broad-sweep + three targeted deep-dives on AI, scoring edge cases, per-joker mechanics, endless mode, meta-progression). **8 false positives caught and documented** (corrupted-joker seat semantics intentional, ToutStreak persistence working via `card_enhancements`, `_card_back` LRU theme-keyed, etc.). **Six real fixes (R1–R7)** plus three larger phases (render-diff layer, endless boss-variety guard, Quinte Royale unlock). **Rematch removed** at user request — game-over screen now `[Enter/Q] Menu [H] History` only. Headline changes:
95
+ - **R1 — AI bidding zero-rank awareness** (`src/belote/ai.py::_hard_special` / `_hard_bid`): new public `scoring.card_points_with_modifiers(card, trump, bm)` helper threads the same canonical zero-rank logic the live HUD uses into the AI's bid evaluation; AI no longer overbids Tout Atout on jack-heavy hands under Le Sauvage. Honor-counting in regular-suit branch also boss-aware.
96
+ - **R2 — L'Anarchie rebelote preservation** (`src/belote/game.py:919-920` + new `GameState.belote_trump: Suit | None` field): rebelote check matches against the captured belote trump, not `state.trump`, so a K-trump in trick 2 + Q in trick 3 still fires rebelote across the post-trick-2 rotation.
97
+ - **R5 — voucher idempotency relocated to `Voucher.apply()`** (`src/belote/belatro/items/base.py`): subclasses now implement `_apply_once(run)`; the base wrapper consults `run._applied_voucher_ids` so any future caller (replay, save/load) gets idempotency, not just the shop.
98
+ - **Phase 6 — diff-based render emit** (`src/belote/ui/render.py::display`): idle re-renders drop from ~28 row writes to zero by diffing the post-vcenter line list against `_last_emitted_lines`. Layout/theme changes invalidate the baseline automatically. `BELOTE_NO_DIFF=1` escape hatch.
99
+ - **Phase 5 — endless boss-variety guard** (`belatro/main.py:285+`): in endless, reroll if the picked boss is in the last-2 window (`run._recent_boss_ids`).
100
+ - **Phase 7b — undo improvements** (`gameflow.py::_undo_pop_to_south`): `Z` now pops past AI snapshots to the player's actual prior decision point and displays a 0.8 s "↶ Undo" banner so the action is visible.
101
+ - **Phase 8 — Quinte Royale unlock** (`belatro/progression/unlocks.py::_handle_declaration`): the Legendary joker was marked `is_unlockable` but had no path to actually unlock; `UnlockTracker.on_event` now dispatches `DeclarationScoredEvent` and grants on NS sequence ≥ 100 pts.
102
+ Performance verdict: 203–235 rounds/sec end-to-end (unchanged); render-diff idle byte count < 25% of full frame. Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-mutable-blanket.md`.
103
+
104
+ Past baselines:
105
+
106
+ - 3.9.0 shipped a clean three-agent audit pass (classic engine / BelAtro engine / items+UI). One LOW bug — `BelAtroAnnounce.yes_no()` hung on `Key.EOF`. Added `NO_COLOR` env-var support and the `--smoke` benchmark flag. Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-wise-puzzle.md`.
93
107
 
94
- - **655 tests** passing.
95
108
  - 3.8.2 completes the five-agent audit pass. Final hardening includes Tout Atout streak persistence in BelAtro, Quinte trigger refinement, belote-pair timing fixes for jokers, and declaration scoring correctness for carrés and long sequences.
96
109
  - Performance: test suite speed increased by mocking `interruptible_sleep`.
97
110
  - Regression coverage maintained at 100% for game-logic modules.
@@ -162,6 +175,10 @@ once at startup; toggling mid-run has no effect.
162
175
  `~/.local/share/belote/ghosts/<label>-<seed>.json`. The file is written
163
176
  once when the run ends. Useful for sharing or replaying interesting
164
177
  runs. Backed by `src/belote/belatro/ghost_run.py`.
178
+ - `NO_COLOR=<any-non-empty>` — suppress truecolor SGR escapes from
179
+ `fg()` / `bg()` per the [no-color.org](https://no-color.org/) spec.
180
+ Bold/dim/underline/reverse/strikethrough and cursor sequences remain
181
+ (they aren't color). Added in 3.9.0. Backed by `src/belote/ansi.py`.
165
182
 
166
183
  ## Releasing a New Version
167
184
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: belote-cli
3
- Version: 3.8.2
3
+ Version: 3.9.3
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
@@ -215,7 +215,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
215
215
  - **Alternate Screen Buffer:** Both classic Belote and BelAtro run in a dedicated terminal buffer for a clean, non-overlapping interface — your shell scrollback stays untouched after you quit.
216
216
  - **Declarations:** Automatic detection and announcement of sequences (Tierce, Quarte, etc.) and Carrés after the first trick.
217
217
  - **Live HUD:** Real-time round scoring displays points won during the current round, with a smooth "rolling" numerical animation for total scores.
218
- - **High Fidelity:** Implementation of French Belote rules according to the [official rules of the Fédération Française de Belote](https://www.ffbelote.org/regles-officielle-belote/), including a two-round bidding system, "Dix de Der", "Capot" (252 pts), and "Litige" (tie-break). All six contracts are bidable in round 2: the four card suits, **Tout Atout** (every suit acts as trump within its own led-suit group; press `a`), and **Sans Atout** (no trump, lead-suit highest wins; press `s`).
218
+ - **High Fidelity:** Implementation of French Belote rules according to the [official rules of the Fédération Française de Belote](https://www.ffbelote.org/regles-officielle-belote/), including a two-round bidding system, "Dix de Der", contract-aware **Capot** (252 normal / 220 Sans Atout / 348 Tout Atout), and "Litige" (tie-break). All six contracts are bidable in round 2: the four card suits, **Tout Atout** (every suit acts as trump within its own led-suit group; press `a`), and **Sans Atout** (no trump, lead-suit highest wins; press `s`).
219
219
  - **Rules & History Viewer:** A scrollable, bilingual (English/French) in-game reference for the game's heritage and mechanics.
220
220
 
221
221
  ## AI
@@ -254,7 +254,7 @@ belote/
254
254
  │ ├── input.py # Platform-dispatched key reader and interruptible sleep
255
255
  │ ├── stats.py # Global and session statistics tracking
256
256
  │ └── rules.py # Game rules content
257
- ├── tests/ # Comprehensive test suite (655 tests)
257
+ ├── tests/ # Comprehensive test suite (691 tests)
258
258
  ├── scripts/ # Performance benchmarks
259
259
  ├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
260
260
  ├── LICENSE # MIT License
@@ -270,14 +270,14 @@ belote/
270
270
  PYTHONPATH=src pytest
271
271
  ```
272
272
 
273
- Currently **655 tests** passing with 100% coverage on game-logic modules (3.8.2).
273
+ Currently **691 tests** passing with 100% coverage on game-logic modules (3.9.3).
274
274
 
275
275
  ## Technical Integrity
276
276
 
277
277
  The codebase is strictly validated with the following tools:
278
278
  - **mypy**: 0 errors (strict type safety)
279
279
  - **ruff**: 0 violations (linting & formatting)
280
- - **pytest**: 655/655 passed
280
+ - **pytest**: 691/691 passed
281
281
  - **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
282
282
  - **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
283
283
 
@@ -172,7 +172,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
172
172
  - **Alternate Screen Buffer:** Both classic Belote and BelAtro run in a dedicated terminal buffer for a clean, non-overlapping interface — your shell scrollback stays untouched after you quit.
173
173
  - **Declarations:** Automatic detection and announcement of sequences (Tierce, Quarte, etc.) and Carrés after the first trick.
174
174
  - **Live HUD:** Real-time round scoring displays points won during the current round, with a smooth "rolling" numerical animation for total scores.
175
- - **High Fidelity:** Implementation of French Belote rules according to the [official rules of the Fédération Française de Belote](https://www.ffbelote.org/regles-officielle-belote/), including a two-round bidding system, "Dix de Der", "Capot" (252 pts), and "Litige" (tie-break). All six contracts are bidable in round 2: the four card suits, **Tout Atout** (every suit acts as trump within its own led-suit group; press `a`), and **Sans Atout** (no trump, lead-suit highest wins; press `s`).
175
+ - **High Fidelity:** Implementation of French Belote rules according to the [official rules of the Fédération Française de Belote](https://www.ffbelote.org/regles-officielle-belote/), including a two-round bidding system, "Dix de Der", contract-aware **Capot** (252 normal / 220 Sans Atout / 348 Tout Atout), and "Litige" (tie-break). All six contracts are bidable in round 2: the four card suits, **Tout Atout** (every suit acts as trump within its own led-suit group; press `a`), and **Sans Atout** (no trump, lead-suit highest wins; press `s`).
176
176
  - **Rules & History Viewer:** A scrollable, bilingual (English/French) in-game reference for the game's heritage and mechanics.
177
177
 
178
178
  ## AI
@@ -211,7 +211,7 @@ belote/
211
211
  │ ├── input.py # Platform-dispatched key reader and interruptible sleep
212
212
  │ ├── stats.py # Global and session statistics tracking
213
213
  │ └── rules.py # Game rules content
214
- ├── tests/ # Comprehensive test suite (655 tests)
214
+ ├── tests/ # Comprehensive test suite (691 tests)
215
215
  ├── scripts/ # Performance benchmarks
216
216
  ├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
217
217
  ├── LICENSE # MIT License
@@ -227,14 +227,14 @@ belote/
227
227
  PYTHONPATH=src pytest
228
228
  ```
229
229
 
230
- Currently **655 tests** passing with 100% coverage on game-logic modules (3.8.2).
230
+ Currently **691 tests** passing with 100% coverage on game-logic modules (3.9.3).
231
231
 
232
232
  ## Technical Integrity
233
233
 
234
234
  The codebase is strictly validated with the following tools:
235
235
  - **mypy**: 0 errors (strict type safety)
236
236
  - **ruff**: 0 violations (linting & formatting)
237
- - **pytest**: 655/655 passed
237
+ - **pytest**: 691/691 passed
238
238
  - **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
239
239
  - **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
240
240
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "belote-cli"
7
- version = "3.8.2"
7
+ version = "3.9.3"
8
8
  description = "A 4-player terminal card game"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -68,17 +68,16 @@ def benchmark_ai(difficulty: Difficulty, iterations: int = 50) -> float:
68
68
 
69
69
  def benchmark_belatro_bus(num_jokers: int = 5, iterations: int = 1000) -> float:
70
70
  from belote.belatro.core.scoring import ScoreAccumulator
71
- from belote.belatro.engine.event_bus import EventBus, TrickWonEvent
71
+ from belote.belatro.engine.event_bus import TrickWonEvent
72
72
  from belote.belatro.items.jokers.contract import LeDiplomate
73
73
  from belote.deck import Card, Rank
74
- from belote.game import GameState
75
74
 
76
75
  print(f"Benchmarking BelAtro State Update ({num_jokers} Jokers) over {iterations} iterations...")
77
76
 
78
77
  jokers = [LeDiplomate() for _ in range(num_jokers)]
79
78
  acc = ScoreAccumulator()
80
79
  acc.attach_jokers(jokers)
81
-
80
+
82
81
  state = new_game()
83
82
  event = TrickWonEvent(
84
83
  winner=Seat.SOUTH,
@@ -102,28 +101,28 @@ def benchmark_belatro_bus(num_jokers: int = 5, iterations: int = 1000) -> float:
102
101
 
103
102
 
104
103
  def benchmark_scoring(iterations: int = 1000) -> float:
105
- from belote.scoring import score_round
106
104
  from belote.deck import Card, Rank
107
- from belote.game import TrickCard, Phase, replace
108
-
105
+ from belote.game import Phase, TrickCard, replace
106
+ from belote.scoring import score_round
107
+
109
108
  print(f"Benchmarking score_round() over {iterations} iterations...")
110
-
109
+
111
110
  state = new_game()
112
111
  state = replace(
113
- state,
114
- phase=Phase.SCORING,
115
- trump=Suit.SPADES,
112
+ state,
113
+ phase=Phase.SCORING,
114
+ trump=Suit.SPADES,
116
115
  taker=Seat.SOUTH,
117
116
  completed_tricks=tuple([(TrickCard(Seat.SOUTH, Card(Suit.SPADES, Rank.ACE)),) * 4] * 8),
118
117
  last_trick_winner=Seat.SOUTH
119
118
  )
120
-
119
+
121
120
  times = []
122
121
  for _ in range(iterations):
123
122
  start = time.perf_counter()
124
123
  _ = score_round(state)
125
124
  times.append(time.perf_counter() - start)
126
-
125
+
127
126
  avg = statistics.mean(times) * 1000
128
127
  print(f" Scoring Time: {avg:.3f}ms")
129
128
  return avg
@@ -132,23 +131,23 @@ def benchmark_scoring(iterations: int = 1000) -> float:
132
131
  def benchmark_deal(iterations: int = 1000) -> float:
133
132
  from belote.game import start_round
134
133
  print(f"Benchmarking start_round() (deal) over {iterations} iterations...")
135
-
134
+
136
135
  state = new_game()
137
136
  rng = random.Random(42)
138
-
137
+
139
138
  times = []
140
139
  for _ in range(iterations):
141
140
  start = time.perf_counter()
142
141
  _ = start_round(state, rng)
143
142
  times.append(time.perf_counter() - start)
144
-
143
+
145
144
  avg = statistics.mean(times) * 1000
146
145
  print(f" Deal Time: {avg:.3f}ms")
147
146
  return avg
148
147
 
149
148
 
150
149
  def benchmark_legal_cards(iterations: int = 1000) -> float:
151
- from belote.game import legal_cards, clear_legal_cards_cache, replace
150
+ from belote.game import clear_legal_cards_cache, legal_cards, replace
152
151
  print(f"Benchmarking legal_cards() (cache cleared per call) over {iterations} iterations...")
153
152
 
154
153
  state = new_game()
@@ -173,7 +172,7 @@ def benchmark_legal_cards_cached(iterations: int = 1000) -> float:
173
172
  `benchmark_legal_cards` above invalidates every iteration and so reflects
174
173
  worst-case-only time.
175
174
  """
176
- from belote.game import legal_cards, clear_legal_cards_cache, replace
175
+ from belote.game import clear_legal_cards_cache, legal_cards, replace
177
176
  print(f"Benchmarking legal_cards() (warm cache) over {iterations} iterations...")
178
177
 
179
178
  state = new_game()
@@ -198,8 +197,8 @@ def benchmark_trick_scoring(iterations: int = 1000) -> float:
198
197
  (HUD running total) and again from `scoring.py::_calculate_base_points`
199
198
  (final round score). One of the hottest functions in a played round.
200
199
  """
201
- from belote.game import TrickCard, replace
202
200
  from belote.deck import Card, Rank
201
+ from belote.game import TrickCard, replace
203
202
  from belote.scoring import trick_card_points
204
203
 
205
204
  print(f"Benchmarking trick_card_points() over {iterations} iterations...")
@@ -249,6 +248,55 @@ def benchmark_ai_legality_filter(iterations: int = 500) -> float:
249
248
  return avg
250
249
 
251
250
 
251
+ def benchmark_belatro_round(rounds: int = 30, seed: int = 42) -> float:
252
+ """End-to-end BelAtro round throughput under a deterministic seed.
253
+
254
+ Drives a full round (bid → 8 tricks → score) headlessly via the same
255
+ round_driver path the game uses, with AI on every seat. The seed is
256
+ threaded into drive_round so the per-round work is reproducible — a
257
+ regression sentinel, not a wall-clock target.
258
+ """
259
+ from belote.belatro.core.scoring import ScoreAccumulator
260
+ from belote.belatro.engine.event_bus import EventBus
261
+ from belote.belatro.engine.round_driver import RoundUICallbacks, drive_round
262
+ from belote.belatro.partner.partner_state import PartnerState
263
+ from belote.deck import Card
264
+ from belote.game import GameState, legal_cards
265
+
266
+ class _HeadlessUI(RoundUICallbacks):
267
+ def prompt_bid(self, state: GameState) -> Suit | None:
268
+ return None # pass; AI seats may take
269
+
270
+ def prompt_card(self, state: GameState) -> tuple[Card, GameState]:
271
+ return legal_cards(state, Seat.SOUTH)[0], state
272
+
273
+ def on_card_played(self, state: GameState, seat: Seat, card: Card) -> None:
274
+ pass
275
+
276
+ def on_trick_end(self, state: GameState, winner: Seat, points: int) -> None:
277
+ pass
278
+
279
+ def on_round_end(self, breakdown: object) -> None:
280
+ pass
281
+
282
+ print(f"Benchmarking drive_round() E2E over {rounds} rounds (seed={seed})...")
283
+
284
+ times = []
285
+ for i in range(rounds):
286
+ bus = EventBus()
287
+ partner = PartnerState()
288
+ acc = ScoreAccumulator()
289
+ start = time.perf_counter()
290
+ drive_round(bus=bus, partner=partner, ui_callbacks=_HeadlessUI(), acc=acc, seed=seed + i)
291
+ times.append(time.perf_counter() - start)
292
+
293
+ mean = statistics.mean(times) * 1000
294
+ p95 = sorted(times)[int(len(times) * 0.95) - 1] * 1000 if len(times) > 1 else mean
295
+ rps = 1.0 / statistics.mean(times) if times else 0.0
296
+ print(f" E2E Round Time: mean {mean:.2f}ms, p95 {p95:.2f}ms ({rps:.1f} rounds/sec)")
297
+ return mean
298
+
299
+
252
300
  def run_benchmarks() -> None:
253
301
  print("=== Belote-CLI Performance Benchmark ===")
254
302
  benchmark_render()
@@ -265,9 +313,29 @@ def run_benchmarks() -> None:
265
313
  benchmark_legal_cards_cached()
266
314
  benchmark_trick_scoring()
267
315
  benchmark_ai_legality_filter()
316
+ print()
317
+ benchmark_belatro_round()
268
318
  print("========================================")
269
319
 
270
320
 
321
+ def run_smoke() -> None:
322
+ """Tiny smoke pass: every benchmark runs once at minimum iteration count.
323
+ Used by the test suite to keep the script from rotting.
324
+ """
325
+ benchmark_render(iterations=2)
326
+ benchmark_ai(Difficulty.EASY, iterations=2)
327
+ benchmark_belatro_bus(iterations=2)
328
+ benchmark_scoring(iterations=2)
329
+ benchmark_deal(iterations=2)
330
+ benchmark_legal_cards(iterations=2)
331
+ benchmark_legal_cards_cached(iterations=2)
332
+ benchmark_trick_scoring(iterations=2)
333
+ benchmark_ai_legality_filter(iterations=2)
334
+ benchmark_belatro_round(rounds=2)
335
+
271
336
 
272
337
  if __name__ == "__main__":
273
- run_benchmarks()
338
+ if "--smoke" in sys.argv:
339
+ run_smoke()
340
+ else:
341
+ run_benchmarks()
@@ -1,3 +1,3 @@
1
- __version__ = "3.8.2"
1
+ __version__ = "3.9.3"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -16,6 +16,7 @@ from .game import (
16
16
  legal_cards,
17
17
  partner,
18
18
  )
19
+ from .scoring import card_points_with_modifiers
19
20
 
20
21
  # trick_rank(Card(trump, Rank.NINE), trump) == 8 + 6 == 14 for any trump
21
22
  _NINE_TRUMP_RANK = 14
@@ -249,12 +250,25 @@ class AIPlayer:
249
250
  TA: every card scores on the trump scale; threshold against the average
250
251
  taker total (~62 raw). Plus a Jack-count bonus.
251
252
  SA: every card scores on the non-trump scale; flat-Aces hand favored.
253
+
254
+ 3.9.3: card values are evaluated through `card_points_with_modifiers`
255
+ so active zero-rank bosses (jacks_zero / aces_zero / kings_zero /
256
+ tens_zero / ban_clubs) suppress those ranks in the bid heuristic.
257
+ Pre-3.9.3 the raw `card_points` totals made the AI overbid TA on a
258
+ jack-heavy hand under Le Sauvage even though those jacks would score
259
+ zero in actual play.
252
260
  """
253
- ta_pts = sum(card_points_fn(c, Suit.TOUT_ATOUT) for c in hand)
254
- jack_bonus = sum(1 for c in hand if c.rank == Rank.JACK) * 6 # Jacks dominate TA
261
+ bm = state.boss_modifiers
262
+ ta_pts = sum(card_points_with_modifiers(c, Suit.TOUT_ATOUT, bm) for c in hand)
263
+ # The Jack-count bonus is the AI's TA strength signal — drop it if
264
+ # jacks are suppressed.
265
+ jack_bonus = (
266
+ 0 if bm.jacks_zero
267
+ else sum(1 for c in hand if c.rank == Rank.JACK) * 6
268
+ )
255
269
  ta_score = ta_pts + jack_bonus
256
270
 
257
- sa_pts = sum(card_points_fn(c, None) for c in hand)
271
+ sa_pts = sum(card_points_with_modifiers(c, None, bm) for c in hand)
258
272
  # Long suits are bad under SA — opponents won't follow your suit.
259
273
  long_suit_penalty = sum(max(0, n - 3) ** 2 for n in lengths.values()) * 4
260
274
  sa_score = sa_pts - long_suit_penalty
@@ -487,10 +501,19 @@ class AIPlayer:
487
501
  if c.suit in suit_cards:
488
502
  suit_cards[c.suit].append(c)
489
503
 
504
+ bm = state.boss_modifiers
490
505
  for suit in card_suits:
491
506
  trump_cards = suit_cards[suit]
492
- honor_count = sum(1 for c in trump_cards if c.rank in (Rank.JACK, Rank.NINE, Rank.ACE))
493
- point_total = sum(card_points_fn(c, suit, self._se) for c in trump_cards)
507
+ # Honors are J/9/A; drop a rank from the count if it's zeroed by a
508
+ # boss flag (3.9.3 honor-counting was previously boss-blind).
509
+ def _is_honor(c: Card) -> bool:
510
+ if c.rank == Rank.JACK and bm.jacks_zero:
511
+ return False
512
+ if c.rank == Rank.ACE and bm.aces_zero:
513
+ return False
514
+ return c.rank in (Rank.JACK, Rank.NINE, Rank.ACE)
515
+ honor_count = sum(1 for c in trump_cards if _is_honor(c))
516
+ point_total = sum(card_points_with_modifiers(c, suit, bm) for c in trump_cards)
494
517
 
495
518
  suit_scores[suit] = point_total * 0.5 + honor_count * 3
496
519
 
@@ -601,7 +624,10 @@ class AIPlayer:
601
624
  """Score a card play decision with advanced heuristics."""
602
625
  score = 0.0
603
626
  points = card_points_fn(card, trump, self._se)
604
- # Base: card point value (prefer keeping high value cards if not winning)
627
+ # Small per-card tiebreaker: when win/loss heuristics are otherwise
628
+ # equal, slightly bias toward playing the higher-value card. The
629
+ # 0.1 coefficient keeps this strictly subordinate to the win/loss
630
+ # bonuses (~+5 to ~-9) below.
605
631
  score += points * 0.1
606
632
 
607
633
  if not trick:
@@ -1,10 +1,26 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import re
4
5
  from functools import lru_cache
5
6
 
6
7
  from .themes import Theme, theme_manager
7
8
 
9
+ # Respect https://no-color.org/. Read once at import; tests use
10
+ # _refresh_no_color_from_env() after monkeypatch.setenv. Only color escapes
11
+ # are suppressed; SGR formatting (BOLD/DIM/etc.) and cursor sequences remain,
12
+ # per the spec.
13
+ _NO_COLOR: bool = bool(os.environ.get("NO_COLOR", ""))
14
+
15
+
16
+ def _refresh_no_color_from_env() -> None:
17
+ global _NO_COLOR
18
+ _NO_COLOR = bool(os.environ.get("NO_COLOR", ""))
19
+
20
+
21
+ def no_color_active() -> bool:
22
+ return _NO_COLOR
23
+
8
24
  # ── Theme cache ────────────────────────────────────────────────────────────
9
25
  # Each color flavor (felt_bg, red_fg, etc.) is hit dozens of times per render.
10
26
  # Pre-3.0.0 each call walked into theme_manager.get_current() (a dict lookup);
@@ -58,15 +74,27 @@ def ansi_ljust(s: str, width: int) -> str:
58
74
 
59
75
 
60
76
  @lru_cache(maxsize=512)
61
- def fg(r: int, g: int, b: int) -> str:
77
+ def _fg_seq(r: int, g: int, b: int) -> str:
62
78
  return f"\x1b[38;2;{r};{g};{b}m"
63
79
 
64
80
 
65
81
  @lru_cache(maxsize=512)
66
- def bg(r: int, g: int, b: int) -> str:
82
+ def _bg_seq(r: int, g: int, b: int) -> str:
67
83
  return f"\x1b[48;2;{r};{g};{b}m"
68
84
 
69
85
 
86
+ def fg(r: int, g: int, b: int) -> str:
87
+ if _NO_COLOR:
88
+ return ""
89
+ return _fg_seq(r, g, b)
90
+
91
+
92
+ def bg(r: int, g: int, b: int) -> str:
93
+ if _NO_COLOR:
94
+ return ""
95
+ return _bg_seq(r, g, b)
96
+
97
+
70
98
  BOLD = "\x1b[1m"
71
99
  DIM = "\x1b[2m"
72
100
  REVERSE = "\x1b[7m"
@@ -99,13 +99,18 @@ class BelAtroRun:
99
99
 
100
100
  # ── Idempotency guard for voucher.apply() ──────────────
101
101
  # Several vouchers (LaTelescope, LaDoubleDonne, LesCartesDorees, LeCouteau)
102
- # use `+=` against run-level counters in their `apply()`. Today the only
103
- # call site is `Shop.buy_item`, which fires apply() exactly once per
104
- # purchase. This set lets the shop short-circuit re-application if a
105
- # future save/load path ever re-invokes apply() on a voucher already in
106
- # `vouchers` — preventing silent double-stacking.
102
+ # use `+=` against run-level counters in their `apply()`. The guard now
103
+ # lives on `Voucher.apply()` (3.9.3 R5) so any caller — shop, replay, a
104
+ # future save/load round-trip gets the same protection automatically.
107
105
  _applied_voucher_ids: set[str] = field(default_factory=set)
108
106
 
107
+ # ── Recent-boss tracker (3.9.3 Phase 5) ────────────────
108
+ # Used by the BelAtro main loop to suppress immediate boss repeats in
109
+ # endless mode. The deque holds at most 2 recent boss ids; the selector
110
+ # rerolls if its pick is in this window. Empty by default — bounded so
111
+ # the run-state JSON snapshot stays small.
112
+ _recent_boss_ids: list[str] = field(default_factory=list)
113
+
109
114
  def consume(self, item: Any, context: object = None) -> None:
110
115
  """Centralised consumable activation.
111
116
 
@@ -144,6 +144,19 @@ class Tarot(ABC):
144
144
 
145
145
 
146
146
  class Voucher(ABC):
147
+ """Permanent run-level upgrade.
148
+
149
+ Subclasses implement `_apply_once(run)` for the actual effect. The base
150
+ class wraps it with `apply(run)` which consults `run._applied_voucher_ids`
151
+ to guarantee a no-op on a second invocation — important for vouchers that
152
+ use `+=` semantics (LaTelescope's `+=` on joker_slots, LeCouteau's `+=`
153
+ on consumable_slots, etc.) that would silently double-stack on a future
154
+ save/load or replay round-trip.
155
+
156
+ 3.9.3 — guard relocated here from `Shop._apply_item` so any future
157
+ caller of `voucher.apply()` (replays, deck-builder previews, etc.)
158
+ inherits the same protection.
159
+ """
147
160
  id: str
148
161
  name: str
149
162
  description: str
@@ -151,9 +164,16 @@ class Voucher(ABC):
151
164
  rarity: Rarity = Rarity.COMMON
152
165
  purchased: bool = False
153
166
 
154
- @abstractmethod
155
167
  def apply(self, run: BelAtroRun) -> None:
156
- """Apply permanent effect to the run."""
168
+ """Idempotent wrapper. Calls `_apply_once` only the first time per run."""
169
+ if self.id in run._applied_voucher_ids:
170
+ return
171
+ run._applied_voucher_ids.add(self.id)
172
+ self._apply_once(run)
173
+
174
+ @abstractmethod
175
+ def _apply_once(self, run: BelAtroRun) -> None:
176
+ """Subclass-defined permanent effect. Called exactly once per run."""
157
177
  ...
158
178
 
159
179