belote-cli 3.2.0__tar.gz → 3.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. {belote_cli-3.2.0 → belote_cli-3.3.2}/CHANGELOG.md +75 -0
  2. {belote_cli-3.2.0 → belote_cli-3.3.2}/DEVELOPMENT.md +3 -3
  3. {belote_cli-3.2.0 → belote_cli-3.3.2}/PKG-INFO +17 -1
  4. {belote_cli-3.2.0 → belote_cli-3.3.2}/README.md +16 -0
  5. {belote_cli-3.2.0 → belote_cli-3.3.2}/pyproject.toml +1 -1
  6. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/__init__.py +1 -1
  7. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/ai.py +36 -4
  8. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/core/run_state.py +24 -1
  9. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/core/scoring.py +5 -1
  10. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/engine/modifier_patch.py +5 -4
  11. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/engine/round_driver.py +20 -4
  12. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/base.py +23 -0
  13. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/main.py +147 -3
  14. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/partner/personality.py +9 -5
  15. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/progression/unlocks.py +26 -5
  16. belote_cli-3.3.2/src/belote/belatro/ui/history.py +192 -0
  17. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ui/rules.py +1 -1
  18. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ui/trust_bar.py +9 -1
  19. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/game.py +67 -0
  20. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/gameflow.py +1 -1
  21. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/replay.py +11 -2
  22. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/scoring.py +56 -40
  23. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/stats.py +4 -0
  24. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/ui/prompts.py +21 -0
  25. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_boss_modifiers_integration.py +51 -1
  26. belote_cli-3.3.2/tests/belatro/test_history_overlay.py +222 -0
  27. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_replay.py +40 -0
  28. belote_cli-3.2.0/AGENT.md +0 -12
  29. {belote_cli-3.2.0 → belote_cli-3.3.2}/.claude/settings.local.json +0 -0
  30. {belote_cli-3.2.0 → belote_cli-3.3.2}/.gitignore +0 -0
  31. {belote_cli-3.2.0 → belote_cli-3.3.2}/.python-version +0 -0
  32. {belote_cli-3.2.0 → belote_cli-3.3.2}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
  33. {belote_cli-3.2.0 → belote_cli-3.3.2}/LICENSE +0 -0
  34. {belote_cli-3.2.0 → belote_cli-3.3.2}/scripts/benchmark.py +0 -0
  35. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/__init__.py +0 -0
  36. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/a11y.py +0 -0
  37. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/achievements.py +0 -0
  38. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/ansi.py +0 -0
  39. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/__init__.py +0 -0
  40. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/core/__init__.py +0 -0
  41. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/core/economy.py +0 -0
  42. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/engine/__init__.py +0 -0
  43. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/engine/event_bus.py +0 -0
  44. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ghost_run.py +0 -0
  45. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/__init__.py +0 -0
  46. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/__init__.py +0 -0
  47. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/annonces.py +0 -0
  48. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/coinche.py +0 -0
  49. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/contract.py +0 -0
  50. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  51. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/economy.py +0 -0
  52. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  53. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
  54. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  55. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  56. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  57. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  58. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/planets.py +0 -0
  59. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/registry.py +0 -0
  60. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/tarots.py +0 -0
  61. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/vouchers.py +0 -0
  62. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/partner/__init__.py +0 -0
  63. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/partner/partner_state.py +0 -0
  64. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/partner/trust.py +0 -0
  65. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/progression/__init__.py +0 -0
  66. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/progression/save.py +0 -0
  67. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/run/__init__.py +0 -0
  68. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/run/ante.py +0 -0
  69. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/run/ante_themes.py +0 -0
  70. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/run/boss.py +0 -0
  71. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/run/decks.py +0 -0
  72. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/run/shop.py +0 -0
  73. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/run_summary.py +0 -0
  74. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ui/__init__.py +0 -0
  75. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ui/announce.py +0 -0
  76. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ui/collection.py +0 -0
  77. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ui/hud.py +0 -0
  78. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ui/menu.py +0 -0
  79. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ui/shop.py +0 -0
  80. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/config.py +0 -0
  81. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/context.py +0 -0
  82. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/deck.py +0 -0
  83. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/input.py +0 -0
  84. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/main.py +0 -0
  85. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/rules.py +0 -0
  86. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/themes.py +0 -0
  87. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/ui/__init__.py +0 -0
  88. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/ui/announce.py +0 -0
  89. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/ui/layout.py +0 -0
  90. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/ui/menu.py +0 -0
  91. {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/ui/render.py +0 -0
  92. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/__init__.py +0 -0
  93. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/__init__.py +0 -0
  94. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_belatro.py +0 -0
  95. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_collection_logic.py +0 -0
  96. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_contract_unlocks.py +0 -0
  97. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_dead_flag_fixes.py +0 -0
  98. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_deck_variants.py +0 -0
  99. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_ghost_run.py +0 -0
  100. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_hud_synergy.py +0 -0
  101. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_partner_trust.py +0 -0
  102. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_phase0_coverage.py +0 -0
  103. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_phase1_plumbing.py +0 -0
  104. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_phase2_content.py +0 -0
  105. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_phase3_meta.py +0 -0
  106. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_progression.py +0 -0
  107. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_round_driver.py +0 -0
  108. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_a11y.py +0 -0
  109. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_achievements.py +0 -0
  110. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_ai.py +0 -0
  111. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_ansi_helpers.py +0 -0
  112. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_belote.py +0 -0
  113. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_extended.py +0 -0
  114. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_game_logic.py +0 -0
  115. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_gameflow.py +0 -0
  116. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_layout.py +0 -0
  117. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_new_coverage.py +0 -0
  118. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_official_rules.py +0 -0
  119. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_properties.py +0 -0
  120. {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_undo.py +0 -0
@@ -5,6 +5,81 @@ 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.3.2] - 2026-05-10
9
+
10
+ Residual-audit release — a fresh full-codebase pass after 3.3.1 turned up three real findings (a HIGH live-HUD divergence under La Rupture, a MEDIUM determinism leak in `replay.analyze_round`, and a LOW cosmetic chips display). The same pass flagged ~5 plausible-sounding "performance wins" and other claims that fell apart on verification — catalogued in the plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-cheeky-globe.md` so they aren't re-investigated. 537 tests passing (up from 535), ruff and mypy strict still clean.
11
+
12
+ ### Fixed
13
+
14
+ - **`src/belote/scoring.py::is_capot` + `src/belote/game.py::compute_trick_winners` (F1)** — `is_capot(state, tricks=[…])` now honors La Rupture (`no_consecutive_team_wins`) in the explicit-tricks branch, matching the default-branch behaviour the 3.3.1 La Rupture fix established. The 8th-trick live-HUD CAPOT announcement (`gameflow.py:211-217`) passes an explicit list (`completed_tricks + [current_trick]`) and previously re-derived winners with raw `trick_winner_seat`, falsely shouting "CAPOT!" under La Rupture even though the final score correctly resolved as non-capot. Fix: `compute_trick_winners` now accepts an optional `tricks` override and `is_capot` delegates both branches through it — single source of truth for Rupture-aware winner resolution. Regression test in `tests/belatro/test_boss_modifiers_integration.py::test_is_capot_honors_rupture_in_explicit_tricks_branch`.
15
+ - **`src/belote/replay.py::analyze_round` + `src/belote/gameflow.py` (F2)** — The 3.3.1 fix made `AIPlayer.__init__` accept a seeded `rng` parameter; the round driver threaded it through, but `replay.analyze_round` kept the legacy unseeded fallback. Post-round replay analysis on the 'R' key thus ran the Hard AI with an unseeded `random.Random()`, so "Optimal plays: 6/8 (75%)" could become "5/8 (62%)" between consecutive runs on the same data — most visibly under Sans Atout, where `_hard_play` falls through to `_easy_play` and `rng.choice(legal)` is the sole arbiter. Fix: `analyze_round` takes an optional `rng`, and the gameflow caller passes `current._rng` from the final round state. Regression test in `tests/test_replay.py::test_analyze_round_deterministic_under_seeded_rng`.
16
+ - **`src/belote/belatro/core/scoring.py::get_popup_lines` (F3)** — Score popup now displays clamped chips (`max(0, state._chips)`) to match `get_total()`'s clamp boundary. Pre-3.3.2 L'Égoïste partner-win-heavy rounds rendered "Chips -12 × Mult 2.0 = 0" — internally consistent but visually a bug. Cosmetic only; no logic change.
17
+
18
+ ### Internal
19
+
20
+ - **Tests**: 535 → 537 (+2 regressions for F1 and F2).
21
+ - **Strict gates**: pytest 537/537, mypy 0 errors (76 files), ruff 0 violations.
22
+ - **`compute_trick_winners` signature widened**: optional `tricks` parameter (default `None` preserves the existing behaviour). Single source of truth for La Rupture-aware winner resolution across both live-HUD and final-scoring paths.
23
+
24
+ ### Rejected — performance "wins" catalogued (so they aren't re-investigated)
25
+
26
+ - **"`_hard_play` recomputes `Counter` per candidate card"** — falsified. `hand_suit_counts` is hoisted at `ai.py:531` *before* the `for card in legal:` loop and threaded into `_score_card_play` as a parameter.
27
+ - **"`score_round` walks tricks 4×"** — falsified post-3.2.0. `winners` is computed once at `scoring.py:600` and threaded into `_calculate_base_points` and `_apply_scoring_modifiers`. Remaining trick-count passes are two cheap `sum(1 for …)` walks of an 8-element list.
28
+ - **"`play_card` does a wholesale `replace()`"** — true but irreducible. Frozen+slotted GameState is a deliberate design choice; the "fix" would re-introduce the mutation class of bugs the 2.x rewrites eliminated.
29
+ - **"`stats.py` per-round full-rewrite is a regression"** — falsified. It's the B2 (3.3.1) fix for crash-safety, intentional.
30
+ - **"Event-bus `list(self._handlers)` per emit is wasteful"** — defensive copy enabling sub/unsub during emit. Handler counts are static and tiny.
31
+
32
+ ## [3.3.1] - 2026-05-10
33
+
34
+ Audit-of-audit release — an inbound LLM audit produced a 18-bug list with mixed accuracy (B1/B2/B7/B8/B9/B10/B14/B16/B17 real; B3/B5/B12/B18 and the ruff-violation claim either self-refuted or hallucinated). The verified subset was fixed, then a fresh independent pass turned up seven additional high-confidence bugs the original audit missed — chiefly La Rupture and L'Anarchie scoring divergences, an unseeded AI RNG that broke ghost-run determinism, and a stale-void inference leak across mid-round undo. All 17 fixes ship in this release. 535 tests passing, ruff and mypy strict still clean.
35
+
36
+ ### Fixed — audit findings
37
+
38
+ - **`src/belote/scoring.py::trick_card_points` (B1)** — `ban_clubs` zero rule now matches `_calculate_base_points`: the whole trick zeros when *any* card is a club, not just when the lead is. Pre-3.3.1 the live HUD running total diverged from the final round score whenever a non-lead card was a club under the `LesClubsBannis` boss.
39
+ - **`src/belote/stats.py::StatisticsManager.update_stats_round` (B2)** — Now calls `flush_stats()` after every round, not just at end-of-game. A crash between rounds no longer silently loses round-level stats and achievement unlocks.
40
+ - **`src/belote/belatro/items/base.py::fuse_jokers` (B7)** — Fused joker now carries over the better edition of the two inputs (POLY > HOLO > FOIL > NONE; NEGATIVE collapses to NONE since its slot bonus was already granted at purchase) and inherits `is_corrupted` if either input was corrupted. Pre-3.3.1 `type(a)()` returned a class-default instance, silently erasing any Foil/Holo/Polychrome the player had paid for.
41
+ - **`src/belote/belatro/ui/rules.py` (B8)** — Reroll cost doc text now reads `$5` to match `Shop.reroll_cost = 5` in code.
42
+ - **`src/belote/belatro/engine/modifier_patch.py::patch` (B9)** — Replaced `assert not attr.startswith("_")` with an explicit `if … raise ValueError(...)`. The `assert` was strippable with `python -O`.
43
+ - **`src/belote/belatro/progression/unlocks.py` + `src/belote/belatro/main.py::BelAtroGame._drain_unlock_announcements` (B10)** — Unlock notifications no longer `print()` raw to stdout (which scrolled and corrupted the alt-screen). Notices are queued on `UnlockTracker.pending_announcements` and drained by the host loop through `BelAtroAnnounce.banner`.
44
+ - **`src/belote/belatro/run/ante_themes.py` + `src/belote/belatro/main.py::_play_blind` + `src/belote/belatro/core/run_state.py::target_score` (B14)** — The Phase 3.1 ante-themes module is now wired into the live game loop: `roll_theme(rng_value)` fires at the start of each ante (`blind_index == 0`) using the run's seeded RNG, `target_score` applies the theme's `target_multiplier(blind_index)`, and `on_blind_won` runs after each successful blind. Tests already covered the module in isolation; production code never invoked it.
45
+ - **`src/belote/belatro/ui/trust_bar.py` (B16)** — Three-tier color: ≤3 red, 4–6 gold (neutral), ≥7 green. Default trust value 5 used to render red under the old `> 5` threshold, falsely signalling distrust at the start of every run.
46
+ - **`src/belote/belatro/partner/personality.py::should_coinche` (B17 + wiring gap)** — Signature now takes a `Random` parameter; `LeFlambeur` consumes the round-driver's seeded RNG instead of the bare module-level `random.random()`. The round driver (`engine/round_driver.py:215-235`) also now calls `partner.personality.should_coinche(state, rng)` when the human player declines a coinche on an EW taker, giving the AI partner a chance to act on its own initiative (gated by `partner.trust.ai_degraded`). Pre-3.3.1 `should_coinche` had no production caller at all.
47
+
48
+ ### Fixed — independent bug-hunt pass (not in original audit)
49
+
50
+ - **`src/belote/scoring.py::compute_trick_winners` (new helper in `game.py`) — La Rupture scoring divergence** — `play_card` reassigned the trick winner for the live HUD whenever `no_consecutive_team_wins` (La Rupture boss) would flip the win, but `score_round`, `_calculate_base_points`, `_apply_scoring_modifiers`, and `is_capot` all re-derived winners via raw `trick_winner_seat` calls — silently restoring the un-flipped winner and producing impossible capots / double-credited rounds. A new `compute_trick_winners(state, trump, is_sa)` helper in `game.py` carries the Rupture rule once and is used by every scoring path. Live HUD and final score now agree under La Rupture.
51
+ - **`src/belote/game.py::GameState.belote_announcer` + `src/belote/scoring.py::score_round` — L'Anarchie + Belote/Rebelote** — Under L'Anarchie (dynamic trump), `state.trump` rotates mid-round. Scoring's `belote_holders.get(state.trump)` lookup then keyed on the *post-rotation* trump and missed any Belote announced on the original trump, silently zeroing the 20/40 bonus. New `belote_announcer: Seat | None` field on `GameState` captures the announcing seat at the moment `belote_tracker[0]` flips True; scoring reads it directly instead of going through the rotated-trump lookup.
52
+ - **`src/belote/scoring.py::score_round` chute branch — `no_dix_de_der` ignored on chute** — The chute formula at line ~774 unconditionally added `LAST_TRICK_BONUS` (+10) to the defender total, even when the `Le Zéro Final` boss was active. The in-round path at line ~606 already gated the bonus on `no_dix_de_der`; the chute branch is now gated symmetrically.
53
+ - **`src/belote/scoring.py::_apply_scoring_modifiers` — La Compétition (`separate_scoring`) parity** — Two parallel bugs to B1 / the chute fix above: (a) the separate-scoring branch zeroed only the *lead-clubs* trick under `ban_clubs` (same divergence we fixed in `trick_card_points`); (b) it unconditionally added +10 de der to the individual last-trick winner, ignoring `no_dix_de_der`. Both now mirror the main scoring path.
54
+ - **`src/belote/ai.py::AIPlayer.__init__` + `src/belote/belatro/engine/round_driver.py` — AI RNG was unseeded** — `AIPlayer.__init__` constructed `random.Random()` (no seed) regardless of the round's seed. Easy-AI random plays, personality jitter, and any other stochastic AI decision randomised per process even at a fixed run seed — silently breaking ghost-run reproducibility, replay determinism, and seeded benchmarks. The round driver now passes its seeded `rng` into every AIPlayer it constructs; the constructor accepts an optional `rng` arg with the old unseeded `Random()` as fallback for legacy callers.
55
+ - **`src/belote/ai.py::AIPlayer.update_memory` — stale void inference across undo** — `known_voids` and `processed_tricks_count` were monotonic; a mid-round undo (which reverts `state` from `gameflow.history`) left voids inferred from now-rolled-back tricks in place, causing the AI to misplay based on cards that no longer existed in the game. `update_memory` now detects regression (current `(completed_count, current_trick_len)` strictly less than `last_voids_key`) and rebuilds the inference set from the live state.
56
+ - **`src/belote/ai.py::_hard_play` — first-legal-card under Sans Atout** — `_hard_play` bailed to `return legal[0]` when `state.trump is None` (the legitimate SA contract), making hard AI strictly worse than medium under SA — `_medium_play` falls through to `_easy_play` (uniform random) in the same case. Hard now does the same; the deterministic-worst-case path is gone.
57
+
58
+ ### Internal
59
+
60
+ - **Tests**: 535 / 535 still passing.
61
+ - **Strict gates**: mypy 0 errors (75 files), ruff 0 violations.
62
+ - **One new GameState field**: `belote_announcer: Seat | None = None`, threaded through `play_card` and `reset_round_fields`. Default-None matches pre-3.3.1 serialisation for the legacy non-Anarchie path.
63
+ - **One new public helper**: `belote.game.compute_trick_winners(state, trump, is_sans_atout) -> list[Seat | None]` — the single source of truth for La Rupture-aware winner resolution. `play_card`'s own Rupture branch is retained for the live HUD; the helper is what scoring now uses.
64
+
65
+ ## [3.3.0] - 2026-05-10
66
+
67
+ BelAtro history overlay release — the [H] key in BelAtro mode now opens a populated, run-aware overlay instead of always showing "No rounds completed yet." Classic Belote's H-key path is unchanged. 535 tests passing (up from 528), ruff and mypy strict still clean.
68
+
69
+ ### Fixed
70
+
71
+ - **`src/belote/belatro/ui/history.py` (new) + `src/belote/belatro/core/run_state.py::BelAtroRun.history` + `src/belote/belatro/main.py::BelAtroGame._record_history_entry`** — Pressing **H** in BelAtro now shows a per-blind ledger (ante, blind label, target, boss, taker, contract, score, status, money Δ, declarations) instead of an empty "No rounds completed yet." screen. Root cause: the classic [H] overlay (`belote.ui.prompts.show_history`) reads `state.score_history`, but the BelAtro round driver (`belatro.engine.round_driver.drive_round`) never invokes `apply_round_score` — the sole writer of `score_history` — and the existing BelAtro `on_round_end` callback was just `pass`. Fix: `BelAtroRun` now carries a parallel `history: list[BelAtroHistoryEntry]`, populated after each round in `_play_blind` from the score breakdown + run snapshot, and rendered by the new `show_belatro_history` overlay (wide table on ≥90-col terminals, three-line-per-row compact layout below).
72
+
73
+ ### Added
74
+
75
+ - **`src/belote/ui/prompts.py::set_history_override`** — Module-level hook the BelAtro launcher installs in `BelAtroGame.start` (closure over `self.run.history`) and clears in its `finally` block. `show_history` short-circuits to the override when set, otherwise falls through to the classic `state.score_history` renderer. This is the seam that lets BelAtro own its overlay without forking `prompt_card` or threading a renderer through every UI call site.
76
+ - **`tests/belatro/test_history_overlay.py`** — 7 new tests covering `BelAtroRun.history` default-empty, the four status branches (WON / FAILED / CAPOT / SURVIVED), and the override hook's routing + cleanup contract. An autouse fixture clears `_history_override` between tests so leaks across the test session are impossible.
77
+
78
+ ### Internal
79
+
80
+ - **Tests**: 528 → 535 (+7).
81
+ - **Strict gates**: pytest 535/535, mypy 0 errors, ruff 0 violations across `src/` and `tests/`.
82
+
8
83
  ## [3.2.0] - 2026-05-10
9
84
 
10
85
  Two-audit reconciliation release — the prioritized fix list distilled from Qwen 3.6 27B + Ring 1T audits (~30 raw claims, ~half held up under verification). Twelve real bugs fixed across joker logic, registry hygiene, RNG determinism, and UI offsets; one new finding (Tarot RNG was also unseeded) caught by the fresh-hunt pass. Eleven audit claims rejected as false positives are catalogued in the plan file so they aren't re-investigated. 528 tests passing (up from 525), ruff and mypy strict still clean.
@@ -84,14 +84,14 @@ PYTHONPATH=src mypy --strict src/
84
84
  # Linting (0 violations expected)
85
85
  ruff check src/ tests/
86
86
 
87
- # Full test suite (528 tests expected)
87
+ # Full test suite (535 tests expected)
88
88
  PYTHONPATH=src pytest
89
89
  ```
90
90
 
91
- Current baseline (3.2.0):
91
+ Current baseline (3.3.1):
92
92
  - **mypy**: 0 errors (strict mode)
93
93
  - **ruff**: 0 violations
94
- - **pytest**: 528 tests, 0 failures
94
+ - **pytest**: 535 tests, 0 failures
95
95
 
96
96
  Run all gates before committing:
97
97
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: belote-cli
3
- Version: 3.2.0
3
+ Version: 3.3.2
4
4
  Summary: A 4-player terminal card game
5
5
  Project-URL: Homepage, https://github.com/ElysiumDisc/belote
6
6
  Project-URL: Repository, https://github.com/ElysiumDisc/belote
@@ -45,6 +45,22 @@ Description-Content-Type: text/markdown
45
45
 
46
46
  Complete implementation of the French card game Belote for the terminal, with a full-screen green felt table and full card graphics at compass positions (N/W/E/S).
47
47
 
48
+ ## What's new in 3.3.1
49
+
50
+ - **Scoring correctness under bosses** — La Rupture (`no_consecutive_team_wins`) used to be applied to the live HUD but silently ignored by `score_round`, so the running total and final score diverged and impossible capots could be reported. L'Anarchie (dynamic trump) rotated `state.trump` mid-round, after which scoring's `belote_holders.get(trump)` lookup missed any Belote announced on the original trump and silently zeroed the 20/40 bonus. Both are fixed: a new `compute_trick_winners` helper is the single source of truth for La Rupture-aware winner resolution, and a new `belote_announcer: Seat` field captures the announcing seat at declaration time so the rotated trump no longer matters.
51
+ - **Boss interactions** — Le Zéro Final (`no_dix_de_der`) is now honored by the chute branch and by La Compétition's per-player tally (both used to unconditionally add +10 de der). La Compétition's `ban_clubs` check now matches the main scoring path (`any` rather than `lead-only`).
52
+ - **Determinism** — `AIPlayer` now accepts and threads a seeded `Random` from the round driver. Pre-3.3.1 it constructed an unseeded `Random()` regardless of the run seed, breaking ghost-run reproducibility and replay determinism. Partner's `should_coinche` (Le Flambeur) was also using the global `random` module; now uses the driver's seeded RNG and is actually wired into the coinche flow (it had no production caller before).
53
+ - **AI memory across undo** — Hard AI's void inference is no longer stuck with stale voids after a mid-round undo: `update_memory` detects state regression and rebuilds inferences from the live tricks.
54
+ - **Hard AI under Sans Atout** — Used to deterministically return `legal[0]`; now falls back to easy (random) like medium, removing the worst-case under SA.
55
+ - **Audit fixes** — `ban_clubs` HUD/final divergence (was real); per-round stats now flush to disk (achievement unlocks no longer lost on crash); `fuse_jokers` now carries over edition (FOIL/HOLO/POLY) and corruption; `assert` in `modifier_patch` replaced with `raise ValueError` (was strippable by `python -O`); unlock notifications routed through the TUI banner instead of raw `print()` (no more alt-screen scroll); ante themes (Café/Tournoi) wired into the live game loop (the Phase 3.1 module was previously test-only).
56
+ - **UI** — Trust bar is three-tier (red ≤3, gold 4–6, green ≥7). Pre-3.3.1 the default trust of 5 rendered red, falsely signalling distrust at run start.
57
+ - **Test coverage** — 535 tests still passing. Strict gates clean: pytest 535/535, mypy 0 errors, ruff 0 violations.
58
+
59
+ ## What's new in 3.3.0
60
+
61
+ - **BelAtro [H] history overlay** — Pressing **H** during a BelAtro run now opens a populated, run-aware ledger: one row per blind with ante, blind label (Small/Big/Boss), target, boss name (when active), taker, contract, NS/EW trick split, BelAtro score, status (`WON` / `FAILED` / `CAPOT` / `SURVIVED`), and money delta. Before 3.3, [H] always showed "No rounds completed yet." in BelAtro because the round driver never invoked `apply_round_score` — the classic-mode writer of `state.score_history`. The fix keeps a parallel `BelAtroRun.history` ledger and routes [H] to a new BelAtro renderer via a small override hook in `belote.ui.prompts`. Classic Belote's [H] path is unchanged.
62
+ - **Test coverage** — 535 tests (up from 528). Strict gates still clean: pytest 535/535, mypy 0 errors, ruff 0 violations.
63
+
48
64
  ## What's new in 3.2.0
49
65
 
50
66
  - **Joker correctness** — La Sentinelle and Le Dernier Mot both used to key on `Seat.SOUTH` instead of the NS team, so the joker silently no-op'd when North (the AI partner) held the trump Jack or won the last trick. Both now correctly fire on team membership.
@@ -2,6 +2,22 @@
2
2
 
3
3
  Complete implementation of the French card game Belote for the terminal, with a full-screen green felt table and full card graphics at compass positions (N/W/E/S).
4
4
 
5
+ ## What's new in 3.3.1
6
+
7
+ - **Scoring correctness under bosses** — La Rupture (`no_consecutive_team_wins`) used to be applied to the live HUD but silently ignored by `score_round`, so the running total and final score diverged and impossible capots could be reported. L'Anarchie (dynamic trump) rotated `state.trump` mid-round, after which scoring's `belote_holders.get(trump)` lookup missed any Belote announced on the original trump and silently zeroed the 20/40 bonus. Both are fixed: a new `compute_trick_winners` helper is the single source of truth for La Rupture-aware winner resolution, and a new `belote_announcer: Seat` field captures the announcing seat at declaration time so the rotated trump no longer matters.
8
+ - **Boss interactions** — Le Zéro Final (`no_dix_de_der`) is now honored by the chute branch and by La Compétition's per-player tally (both used to unconditionally add +10 de der). La Compétition's `ban_clubs` check now matches the main scoring path (`any` rather than `lead-only`).
9
+ - **Determinism** — `AIPlayer` now accepts and threads a seeded `Random` from the round driver. Pre-3.3.1 it constructed an unseeded `Random()` regardless of the run seed, breaking ghost-run reproducibility and replay determinism. Partner's `should_coinche` (Le Flambeur) was also using the global `random` module; now uses the driver's seeded RNG and is actually wired into the coinche flow (it had no production caller before).
10
+ - **AI memory across undo** — Hard AI's void inference is no longer stuck with stale voids after a mid-round undo: `update_memory` detects state regression and rebuilds inferences from the live tricks.
11
+ - **Hard AI under Sans Atout** — Used to deterministically return `legal[0]`; now falls back to easy (random) like medium, removing the worst-case under SA.
12
+ - **Audit fixes** — `ban_clubs` HUD/final divergence (was real); per-round stats now flush to disk (achievement unlocks no longer lost on crash); `fuse_jokers` now carries over edition (FOIL/HOLO/POLY) and corruption; `assert` in `modifier_patch` replaced with `raise ValueError` (was strippable by `python -O`); unlock notifications routed through the TUI banner instead of raw `print()` (no more alt-screen scroll); ante themes (Café/Tournoi) wired into the live game loop (the Phase 3.1 module was previously test-only).
13
+ - **UI** — Trust bar is three-tier (red ≤3, gold 4–6, green ≥7). Pre-3.3.1 the default trust of 5 rendered red, falsely signalling distrust at run start.
14
+ - **Test coverage** — 535 tests still passing. Strict gates clean: pytest 535/535, mypy 0 errors, ruff 0 violations.
15
+
16
+ ## What's new in 3.3.0
17
+
18
+ - **BelAtro [H] history overlay** — Pressing **H** during a BelAtro run now opens a populated, run-aware ledger: one row per blind with ante, blind label (Small/Big/Boss), target, boss name (when active), taker, contract, NS/EW trick split, BelAtro score, status (`WON` / `FAILED` / `CAPOT` / `SURVIVED`), and money delta. Before 3.3, [H] always showed "No rounds completed yet." in BelAtro because the round driver never invoked `apply_round_score` — the classic-mode writer of `state.score_history`. The fix keeps a parallel `BelAtroRun.history` ledger and routes [H] to a new BelAtro renderer via a small override hook in `belote.ui.prompts`. Classic Belote's [H] path is unchanged.
19
+ - **Test coverage** — 535 tests (up from 528). Strict gates still clean: pytest 535/535, mypy 0 errors, ruff 0 violations.
20
+
5
21
  ## What's new in 3.2.0
6
22
 
7
23
  - **Joker correctness** — La Sentinelle and Le Dernier Mot both used to key on `Seat.SOUTH` instead of the NS team, so the joker silently no-op'd when North (the AI partner) held the trump Jack or won the last trick. Both now correctly fire on team membership.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "belote-cli"
7
- version = "3.2.0"
7
+ version = "3.3.2"
8
8
  description = "A 4-player terminal card game"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
- __version__ = "3.2.0"
1
+ __version__ = "3.3.1"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -41,18 +41,31 @@ class AIMemory:
41
41
 
42
42
 
43
43
  class AIPlayer:
44
- def __init__(self, seat: Seat, difficulty: Difficulty = Difficulty.MEDIUM) -> None:
44
+ def __init__(
45
+ self,
46
+ seat: Seat,
47
+ difficulty: Difficulty = Difficulty.MEDIUM,
48
+ rng: random.Random | None = None,
49
+ ) -> None:
45
50
  self.seat = seat
46
51
  self.difficulty = difficulty
47
52
  self.memory = AIMemory()
48
- self._rng = random.Random()
53
+ # Accept the caller's seeded RNG (round driver / replay tooling) so
54
+ # easy-AI plays, personality jitter, and any other stochastic AI
55
+ # decisions are reproducible under a fixed seed. Falls back to an
56
+ # unseeded Random() for legacy callers that construct an AIPlayer
57
+ # directly (e.g. test fixtures).
58
+ self._rng = rng if rng is not None else random.Random()
49
59
  # Set per decide_card() call from state.boss_modifiers.seven_eight_trump.
50
60
  # All ranking helpers in this class read it via self._se.
51
61
  self._se = False
52
62
 
53
63
  def update_memory(self, state: GameState) -> None:
54
64
  """Update memory with currently visible information."""
55
- if len(state.completed_tricks) == 0 and len(state.current_trick) == 0:
65
+ completed_count = len(state.completed_tricks)
66
+ current_count = len(state.current_trick)
67
+
68
+ if completed_count == 0 and current_count == 0:
56
69
  # New round - reset memory. Including the void-cache key — without
57
70
  # this a (0, 0) / (0, 1) key from the first decision of *this* round
58
71
  # could coincidentally match a leftover from the previous round and
@@ -63,6 +76,20 @@ class AIPlayer:
63
76
  self.memory.partner_hand.clear()
64
77
  self.memory.processed_tricks_count = 0
65
78
  self.memory.last_voids_key = None
79
+ elif (
80
+ self.memory.last_voids_key is not None
81
+ and (completed_count, current_count) < self.memory.last_voids_key
82
+ ):
83
+ # Mid-round undo: the state regressed below the highest point
84
+ # we've processed. `known_voids` and `processed_tricks_count`
85
+ # are monotonic and would carry stale inferences forward
86
+ # (a void inferred from a now-rolled-back trick). Rebuild from
87
+ # the current state instead of trying to subtract.
88
+ self.memory.played.clear()
89
+ for s in Seat:
90
+ self.memory.known_voids[s].clear()
91
+ self.memory.processed_tricks_count = 0
92
+ self.memory.last_voids_key = None
66
93
 
67
94
  # Track all cards in completed tricks
68
95
  for trick in state.completed_tricks:
@@ -473,7 +500,12 @@ class AIPlayer:
473
500
  trick = state.current_trick
474
501
 
475
502
  if not trump:
476
- return legal[0]
503
+ # Sans Atout: the lookahead scoring uses `trick_rank(c, trump, ...)`
504
+ # which is meaningless without a trump suit. Fall back to easy
505
+ # (random over legal) rather than `legal[0]` so we don't degrade
506
+ # to a fully deterministic worst-case under SA — matches what
507
+ # `_medium_play` does at its own trump==None guard.
508
+ return self._easy_play(state, legal)
477
509
 
478
510
  # Update void inferences from completed tricks
479
511
  self._update_voids(state)
@@ -7,6 +7,7 @@ if TYPE_CHECKING:
7
7
  from ..items.base import Joker, Voucher
8
8
  from ..progression.save import Profile
9
9
  from ..run.ante import Ante
10
+ from ..ui.history import BelAtroHistoryEntry
10
11
 
11
12
  from ..partner.partner_state import PartnerState
12
13
  from .economy import Economy
@@ -76,6 +77,12 @@ class BelAtroRun:
76
77
  # this; tests assert it. Cleared whenever a new tarot is used.
77
78
  last_tarot_message: str | None = None
78
79
 
80
+ # ── Per-blind history (powers the [H] overlay) ─────────
81
+ # Appended by `BelAtroGame._play_blind` after each Belote round. The
82
+ # classic `state.score_history` is never written under BelAtro (see
83
+ # `belatro/ui/history.py` header for the full rationale).
84
+ history: list[BelAtroHistoryEntry] = field(default_factory=list)
85
+
79
86
  # ── Determinism ────────────────────────────────────────
80
87
  seed: int | None = None
81
88
  _rng: Any = None
@@ -167,7 +174,23 @@ class BelAtroRun:
167
174
 
168
175
  @property
169
176
  def target_score(self) -> int:
170
- return self.current_blind.target
177
+ base = self.current_blind.target
178
+ theme = self.get_ante_theme()
179
+ if theme is None:
180
+ return base
181
+ # Soft target adjustments (e.g. Café reduces boss target by 5%).
182
+ # Round to int after the float multiply so downstream scoring
183
+ # comparisons stay integral.
184
+ return max(1, int(round(base * theme.target_multiplier(self.blind_index))))
185
+
186
+ def get_ante_theme(self) -> Any:
187
+ """Resolve `ante_theme` (id) back to an AnteTheme instance, or None."""
188
+ if not self.ante_theme:
189
+ return None
190
+ from ..run.ante_themes import THEME_BY_ID
191
+
192
+ cls = THEME_BY_ID.get(self.ante_theme)
193
+ return cls() if cls is not None else None
171
194
 
172
195
  def advance_blind(self) -> None:
173
196
  if self.blind_index < 2:
@@ -253,5 +253,9 @@ class ScoreAccumulator:
253
253
  return int(chips * mult)
254
254
 
255
255
  def get_popup_lines(self, state: GameState) -> list[str]:
256
- return [*self._log, f"Chips {state._chips} × Mult {state._mult:.1f} = {self.get_total(state)}"]
256
+ # Match the clamp in get_total(): L'Égoïste can push _chips negative
257
+ # mid-round; the popup line would otherwise read "Chips -12 × Mult …
258
+ # = 0" which looks like a UI bug rather than the intended clamp.
259
+ chips_display = max(0, state._chips)
260
+ return [*self._log, f"Chips {chips_display} × Mult {state._mult:.1f} = {self.get_total(state)}"]
257
261
 
@@ -33,10 +33,11 @@ class PatchedGameState:
33
33
  against in tests/belatro/test_boss_modifiers_integration.py
34
34
  `test_invariant_no_underscore_boss_attrs`.
35
35
  """
36
- assert not attr.startswith("_"), (
37
- f"patch() received leading-underscore attr {attr!r}; the 3.0.x shim "
38
- "was removed in 3.1.0 use the unprefixed boss field name."
39
- )
36
+ if attr.startswith("_"):
37
+ raise ValueError(
38
+ f"patch() received leading-underscore attr {attr!r}; the 3.0.x shim "
39
+ "was removed in 3.1.0 — use the unprefixed boss field name."
40
+ )
40
41
 
41
42
  if attr in _BOSS_FIELDS:
42
43
  current_bm = self.boss_modifiers
@@ -134,10 +134,14 @@ def drive_round(
134
134
  "medium": Difficulty.MEDIUM,
135
135
  "hard": Difficulty.HARD,
136
136
  }.get(_north_diff_str, Difficulty.MEDIUM)
137
+ # Thread the round's seeded RNG into every AI seat so easy-AI plays and
138
+ # personality jitter stay reproducible under a fixed seed. Ghost-run and
139
+ # replay tooling rely on this — without it, AIPlayer's old unseeded
140
+ # default RNG randomised behavior per process even at a fixed seed.
137
141
  ai_players = {
138
- Seat.EAST: AIPlayer(Seat.EAST, Difficulty.MEDIUM),
139
- Seat.NORTH: AIPlayer(Seat.NORTH, _north_diff),
140
- Seat.WEST: AIPlayer(Seat.WEST, Difficulty.MEDIUM),
142
+ Seat.EAST: AIPlayer(Seat.EAST, Difficulty.MEDIUM, rng=rng),
143
+ Seat.NORTH: AIPlayer(Seat.NORTH, _north_diff, rng=rng),
144
+ Seat.WEST: AIPlayer(Seat.WEST, Difficulty.MEDIUM, rng=rng),
141
145
  }
142
146
 
143
147
  if acc is not None:
@@ -219,7 +223,19 @@ def drive_round(
219
223
  surcoinche_unlocked = bool(state._joker_state.get("surcoinche_unlocked"))
220
224
  if surcoinche_unlocked and rng.random() < 0.3:
221
225
  coinche_level = 2
222
- # L'Avocat boss forces at least coinche=1 (existing auto_coinche flag).
226
+ else:
227
+ # Player declined — give the AI partner (North, same defending team)
228
+ # a chance to coinche on its own initiative. Personality-driven and
229
+ # gated by trust: a degraded partner won't act independently.
230
+ if (
231
+ not partner.trust.ai_degraded
232
+ and partner.personality.should_coinche(state, rng)
233
+ ):
234
+ coinche_level = 1
235
+ surcoinche_unlocked = bool(state._joker_state.get("surcoinche_unlocked"))
236
+ if surcoinche_unlocked and rng.random() < 0.3:
237
+ coinche_level = 2
238
+ # L'Avocat boss forces at least coinche=1 (existing auto_coinche flag).
223
239
  if state.boss_modifiers.auto_coinche:
224
240
  coinche_level = max(coinche_level, 1)
225
241
  # Re-emit the final BidMadeEvent so jokers/HUD see the coinche level.
@@ -175,6 +175,29 @@ def fuse_jokers(a: Joker, b: Joker) -> Joker:
175
175
  new_idx = min(base_idx + 1, _RARITY_LADDER.index(Rarity.RARE))
176
176
  fused.rarity = _RARITY_LADDER[new_idx]
177
177
  fused.fusable = False # one-time fusion only
178
+ # Carry over the better edition. type(a)() returns a fresh instance with
179
+ # the class default (NONE), so without this the player would silently lose
180
+ # any Foil/Holo/Polychrome they paid for. NEGATIVE is purchase-time only
181
+ # (the extra slot was already granted) so it doesn't propagate through
182
+ # fusion — fall back to NONE in that case.
183
+ fused.edition = _better_edition(a.edition, b.edition)
184
+ # Corruption is sticky — if either input was corrupted, so is the fusion.
185
+ fused.is_corrupted = a.is_corrupted or b.is_corrupted
178
186
  # Stamp a marker so callers can identify fused jokers
179
187
  fused.name = f"{a.name} + {b.name}"
180
188
  return fused
189
+
190
+
191
+ _EDITION_RANK: dict[Edition, int] = {
192
+ Edition.NONE: 0,
193
+ Edition.NEGATIVE: 0, # purchase-time only, doesn't survive fusion
194
+ Edition.FOIL: 1,
195
+ Edition.HOLO: 2,
196
+ Edition.POLYCHROME: 3,
197
+ }
198
+
199
+
200
+ def _better_edition(a: Edition, b: Edition) -> Edition:
201
+ pick = a if _EDITION_RANK[a] >= _EDITION_RANK[b] else b
202
+ # NEGATIVE collapses to NONE post-fusion (slot already counted).
203
+ return Edition.NONE if pick == Edition.NEGATIVE else pick
@@ -61,11 +61,24 @@ class BelAtroGame:
61
61
  seed=self.run.seed if self.run.seed is not None else 0,
62
62
  deck_id=self.run.deck_id,
63
63
  )
64
+ # 3.3.0: route the in-game [H] key to the BelAtro history
65
+ # overlay (reading `self.run.history`) for the duration of
66
+ # the run. Cleared in the finally block so the classic
67
+ # Belote menu returns to the default `state.score_history`
68
+ # path on exit.
69
+ from ..ui.prompts import set_history_override
70
+ from .ui.history import show_belatro_history
71
+ run = self.run # capture for the closure (mypy: narrowed)
72
+ set_history_override(
73
+ lambda reader: show_belatro_history(reader, run.history)
74
+ )
64
75
  self._run_loop()
65
76
  except KeyboardInterrupt:
66
77
  # Catch exit signals to return to the Belote main menu
67
78
  return
68
79
  finally:
80
+ from ..ui.prompts import set_history_override
81
+ set_history_override(None)
69
82
  # 3.0.0: append a one-line summary of the just-ended run for the
70
83
  # player's own analysis. Best-effort; swallowed on failure.
71
84
  if self.run is not None:
@@ -75,6 +88,19 @@ class BelAtroGame:
75
88
  label = "won" if self.run.run_won else f"ante{self.run.ante_number}"
76
89
  self._ghost_recorder.save(label=label)
77
90
 
91
+ def _drain_unlock_announcements(self) -> None:
92
+ """Render any queued unlock notices through the TUI banner.
93
+
94
+ Replaces the old raw-stdout `print()` notices in UnlockTracker, which
95
+ scrolled and corrupted the alt-screen buffer.
96
+ """
97
+ if self.reader is None:
98
+ self.unlock_tracker.drain_announcements()
99
+ return
100
+ from .ui.announce import BelAtroAnnounce
101
+ for msg in self.unlock_tracker.drain_announcements():
102
+ BelAtroAnnounce.banner(msg, self.reader, hold=1.5)
103
+
78
104
  def _run_loop(self) -> None:
79
105
  """Main game loop: Blind -> Shop -> Next."""
80
106
  if self.run is None:
@@ -84,6 +110,7 @@ class BelAtroGame:
84
110
  while not self.run.run_over:
85
111
  # 1. Round (Blind)
86
112
  self._play_blind()
113
+ self._drain_unlock_announcements()
87
114
 
88
115
  if self.run.run_over:
89
116
  break
@@ -97,8 +124,10 @@ class BelAtroGame:
97
124
  # 3. Advance
98
125
  self.run.advance_blind()
99
126
  self.unlock_tracker.check_ante_unlocks(self.run.ante_number)
127
+ self._drain_unlock_announcements()
100
128
  if self.run.run_won:
101
129
  self.unlock_tracker.notify_run_won()
130
+ self._drain_unlock_announcements()
102
131
  if self.reader is not None:
103
132
  BelAtroAnnounce.banner("YOU WON!", self.reader, hold=2.5)
104
133
  # 3.0.0: offer Endless mode after the canonical 8 antes.
@@ -121,6 +150,20 @@ class BelAtroGame:
121
150
  """Execute one Belote round for the current blind."""
122
151
  if self.run is None or self.reader is None:
123
152
  return
153
+ # Phase 3.1: roll an Ante theme at the start of each ante (blind 0).
154
+ # Uses the run's seeded RNG so themes are deterministic per seed.
155
+ # The roll runs once per ante; subsequent blinds re-use the same theme.
156
+ if self.run.blind_index == 0:
157
+ from .run.ante_themes import roll_theme
158
+ theme = roll_theme(self.run._get_rng().random())
159
+ self.run.ante_theme = theme.id if theme is not None else None
160
+ if theme is not None:
161
+ theme.on_ante_start(self.run)
162
+ # 3.3.0: snapshots used at end of round to build the [H] history entry.
163
+ history_ante = self.run.ante_number
164
+ history_blind_index = self.run.blind_index
165
+ history_target = self.run.target_score
166
+ money_before = self.run.economy.money
124
167
  bus = EventBus()
125
168
  self.unlock_tracker.subscribe_to(bus)
126
169
  acc = ScoreAccumulator()
@@ -306,12 +349,12 @@ class BelAtroGame:
306
349
  self.run.partner_mood = trust.mood()
307
350
 
308
351
  effective_target = acc.target_score # doubled for L'Avocat, normal otherwise
352
+ survived_via_insurance = False
309
353
  if total < effective_target:
310
354
  # Phase 2.1: Capot Insurance halves the chute loss (one-shot).
311
- failure_softened = False
312
355
  if bd.is_failed and self.run.capot_insurance:
313
356
  self.run.capot_insurance = False
314
- failure_softened = True
357
+ survived_via_insurance = True
315
358
  # Defer run-over by one blind: the player paid for a safety net.
316
359
  # We treat the round as a survived chute (no run-over flag).
317
360
  BelAtroAnnounce.banner(
@@ -319,7 +362,7 @@ class BelAtroGame:
319
362
  self.reader,
320
363
  hold=2.0,
321
364
  )
322
- if not failure_softened:
365
+ if not survived_via_insurance:
323
366
  self.run.run_over = True
324
367
  BelAtroAnnounce.banner(
325
368
  f"RUN OVER — Failed to meet target {effective_target} (scored {total}).",
@@ -360,6 +403,12 @@ class BelAtroGame:
360
403
  else:
361
404
  trust.blind_beaten()
362
405
 
406
+ # Phase 3.1: fire the ante theme's per-blind-won hook (e.g. Tournoi
407
+ # awards bonus money, Café gives +1 trust on big-blind wins).
408
+ theme = self.run.get_ante_theme()
409
+ if theme is not None:
410
+ theme.on_blind_won(self.run, self.run.blind_index)
411
+
363
412
  # Partner-specific trust events (skipped under Le Divorce)
364
413
  if not lock_trust:
365
414
  if bd.taker_team == 0 and bd.is_failed:
@@ -367,6 +416,101 @@ class BelAtroGame:
367
416
  elif bd.is_capot and bd.taker_team == 0:
368
417
  trust.capot_together()
369
418
 
419
+ # 3.3.0: append a BelAtro-side history entry (the [H] overlay reads
420
+ # `self.run.history` via the override hook installed in `start()`).
421
+ self._record_history_entry(
422
+ ante=history_ante,
423
+ blind_index=history_blind_index,
424
+ target=history_target,
425
+ boss=boss,
426
+ final_state=final_state,
427
+ bd=bd,
428
+ total=total,
429
+ money_delta=self.run.economy.money - money_before,
430
+ survived_via_insurance=survived_via_insurance,
431
+ )
432
+
433
+ def _record_history_entry(
434
+ self,
435
+ *,
436
+ ante: int,
437
+ blind_index: int,
438
+ target: int,
439
+ boss: object,
440
+ final_state: object,
441
+ bd: object,
442
+ total: int,
443
+ money_delta: int,
444
+ survived_via_insurance: bool,
445
+ ) -> None:
446
+ """Build and append one BelAtroHistoryEntry to `self.run.history`.
447
+
448
+ Pulled out of `_play_blind` so the long round body stays readable.
449
+ Kept private — callers should never construct entries directly.
450
+ """
451
+ if self.run is None:
452
+ return
453
+ from .ui.history import BelAtroHistoryEntry
454
+
455
+ blind_label = ("Small", "Big", "Boss")[blind_index] if 0 <= blind_index <= 2 else "?"
456
+ boss_name = getattr(boss, "name", None) if boss is not None else None
457
+
458
+ taker = getattr(final_state, "taker", None)
459
+ if taker is None:
460
+ taker_label = "—"
461
+ else:
462
+ team = "NS" if taker.value % 2 == 0 else "EW"
463
+ taker_label = f"{taker.name[0]} ({team})"
464
+
465
+ contract_field = getattr(final_state, "contract", None)
466
+ trump = getattr(final_state, "trump", None)
467
+ if contract_field == "sans_atout":
468
+ contract_str = "SA"
469
+ elif contract_field == "tout_atout":
470
+ contract_str = "TA"
471
+ elif trump is not None and hasattr(trump, "symbol"):
472
+ contract_str = trump.symbol
473
+ else:
474
+ contract_str = "—"
475
+
476
+ is_capot = bool(getattr(bd, "is_capot", False))
477
+ taker_team = getattr(bd, "taker_team", None)
478
+ if total >= target and is_capot and taker_team == 0:
479
+ status = "CAPOT"
480
+ elif total >= target:
481
+ status = "WON"
482
+ elif survived_via_insurance:
483
+ status = "SURVIVED"
484
+ else:
485
+ status = "FAILED"
486
+
487
+ tricks_ns = int(getattr(bd, "tricks_ns", 0))
488
+ tricks_ew = int(getattr(bd, "tricks_ew", 0))
489
+
490
+ # Pull declaration summaries off the breakdown when present. score_round
491
+ # doesn't currently expose them, so this is best-effort and falls back
492
+ # to empty tuples — the renderer treats those as "─".
493
+ decl_ns: tuple[str, ...] = tuple(getattr(bd, "decl_summary_ns", ()) or ())
494
+ decl_ew: tuple[str, ...] = tuple(getattr(bd, "decl_summary_ew", ()) or ())
495
+
496
+ self.run.history.append(
497
+ BelAtroHistoryEntry(
498
+ ante=ante,
499
+ blind_label=blind_label,
500
+ target=target,
501
+ boss_name=boss_name,
502
+ taker_label=taker_label,
503
+ contract=contract_str,
504
+ tricks_ns=tricks_ns,
505
+ tricks_ew=tricks_ew,
506
+ score=total,
507
+ status=status,
508
+ money_delta=money_delta,
509
+ decl_summary_ns=decl_ns,
510
+ decl_summary_ew=decl_ew,
511
+ )
512
+ )
513
+
370
514
 
371
515
  def main() -> None:
372
516
  import argparse