belote-cli 3.0.2__tar.gz → 3.3.1__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 (119) hide show
  1. {belote_cli-3.0.2 → belote_cli-3.3.1}/.claude/settings.local.json +2 -1
  2. {belote_cli-3.0.2 → belote_cli-3.3.1}/CHANGELOG.md +171 -0
  3. {belote_cli-3.0.2 → belote_cli-3.3.1}/DEVELOPMENT.md +3 -3
  4. {belote_cli-3.0.2 → belote_cli-3.3.1}/PKG-INFO +46 -6
  5. {belote_cli-3.0.2 → belote_cli-3.3.1}/README.md +45 -5
  6. {belote_cli-3.0.2 → belote_cli-3.3.1}/pyproject.toml +1 -1
  7. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/__init__.py +1 -1
  8. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/ai.py +66 -14
  9. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/core/run_state.py +36 -2
  10. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/core/scoring.py +12 -12
  11. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/engine/modifier_patch.py +21 -22
  12. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/engine/round_driver.py +33 -4
  13. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/base.py +23 -0
  14. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/jokers/hand_comp.py +7 -9
  15. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/jokers/trick_timing.py +7 -2
  16. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/registry.py +20 -0
  17. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/tarots.py +21 -9
  18. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/main.py +158 -3
  19. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/partner/personality.py +9 -5
  20. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/progression/unlocks.py +26 -5
  21. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/run/boss.py +23 -23
  22. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/run/shop.py +55 -20
  23. belote_cli-3.3.1/src/belote/belatro/ui/history.py +192 -0
  24. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ui/rules.py +1 -1
  25. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ui/shop.py +90 -3
  26. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ui/trust_bar.py +9 -1
  27. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/game.py +80 -29
  28. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/input.py +2 -2
  29. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/replay.py +3 -0
  30. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/scoring.py +83 -59
  31. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/stats.py +10 -5
  32. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/ui/layout.py +3 -1
  33. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/ui/prompts.py +21 -0
  34. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/ui/render.py +14 -1
  35. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_belatro.py +100 -4
  36. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_boss_modifiers_integration.py +35 -0
  37. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_dead_flag_fixes.py +41 -5
  38. belote_cli-3.3.1/tests/belatro/test_history_overlay.py +222 -0
  39. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_phase1_plumbing.py +106 -0
  40. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_phase2_content.py +104 -0
  41. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_phase3_meta.py +28 -0
  42. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_round_driver.py +6 -4
  43. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_belote.py +24 -0
  44. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_official_rules.py +34 -0
  45. {belote_cli-3.0.2 → belote_cli-3.3.1}/.gitignore +0 -0
  46. {belote_cli-3.0.2 → belote_cli-3.3.1}/.python-version +0 -0
  47. {belote_cli-3.0.2 → belote_cli-3.3.1}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
  48. {belote_cli-3.0.2 → belote_cli-3.3.1}/LICENSE +0 -0
  49. {belote_cli-3.0.2 → belote_cli-3.3.1}/scripts/benchmark.py +0 -0
  50. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/__init__.py +0 -0
  51. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/a11y.py +0 -0
  52. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/achievements.py +0 -0
  53. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/ansi.py +0 -0
  54. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/__init__.py +0 -0
  55. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/core/__init__.py +0 -0
  56. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/core/economy.py +0 -0
  57. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/engine/__init__.py +0 -0
  58. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/engine/event_bus.py +0 -0
  59. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ghost_run.py +0 -0
  60. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/__init__.py +0 -0
  61. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/jokers/__init__.py +0 -0
  62. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/jokers/annonces.py +0 -0
  63. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/jokers/coinche.py +0 -0
  64. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/jokers/contract.py +0 -0
  65. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  66. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/jokers/economy.py +0 -0
  67. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  68. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  69. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  70. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  71. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/planets.py +0 -0
  72. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/vouchers.py +0 -0
  73. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/partner/__init__.py +0 -0
  74. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/partner/partner_state.py +0 -0
  75. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/partner/trust.py +0 -0
  76. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/progression/__init__.py +0 -0
  77. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/progression/save.py +0 -0
  78. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/run/__init__.py +0 -0
  79. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/run/ante.py +0 -0
  80. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/run/ante_themes.py +0 -0
  81. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/run/decks.py +0 -0
  82. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/run_summary.py +0 -0
  83. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ui/__init__.py +0 -0
  84. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ui/announce.py +0 -0
  85. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ui/collection.py +0 -0
  86. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ui/hud.py +0 -0
  87. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ui/menu.py +0 -0
  88. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/config.py +0 -0
  89. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/context.py +0 -0
  90. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/deck.py +0 -0
  91. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/gameflow.py +0 -0
  92. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/main.py +0 -0
  93. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/rules.py +0 -0
  94. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/themes.py +0 -0
  95. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/ui/__init__.py +0 -0
  96. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/ui/announce.py +0 -0
  97. {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/ui/menu.py +0 -0
  98. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/__init__.py +0 -0
  99. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/__init__.py +0 -0
  100. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_collection_logic.py +0 -0
  101. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_contract_unlocks.py +0 -0
  102. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_deck_variants.py +0 -0
  103. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_ghost_run.py +0 -0
  104. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_hud_synergy.py +0 -0
  105. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_partner_trust.py +0 -0
  106. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_phase0_coverage.py +0 -0
  107. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_progression.py +0 -0
  108. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_a11y.py +0 -0
  109. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_achievements.py +0 -0
  110. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_ai.py +0 -0
  111. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_ansi_helpers.py +0 -0
  112. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_extended.py +0 -0
  113. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_game_logic.py +0 -0
  114. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_gameflow.py +0 -0
  115. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_layout.py +0 -0
  116. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_new_coverage.py +0 -0
  117. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_properties.py +0 -0
  118. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_replay.py +0 -0
  119. {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_undo.py +0 -0
@@ -11,7 +11,8 @@
11
11
  "Bash(python3 -m pytest tests/ -x -q)",
12
12
  "Bash(PYTHONPATH=src python3 *)",
13
13
  "Bash(.venv/bin/python -m mypy src/)",
14
- "Bash(PYTHONPATH=src python -m pytest --tb=short -q)"
14
+ "Bash(PYTHONPATH=src python -m pytest --tb=short -q)",
15
+ "Bash(python3 *)"
15
16
  ]
16
17
  }
17
18
  }
@@ -5,6 +5,177 @@ 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.1] - 2026-05-10
9
+
10
+ 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.
11
+
12
+ ### Fixed — audit findings
13
+
14
+ - **`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.
15
+ - **`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.
16
+ - **`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.
17
+ - **`src/belote/belatro/ui/rules.py` (B8)** — Reroll cost doc text now reads `$5` to match `Shop.reroll_cost = 5` in code.
18
+ - **`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`.
19
+ - **`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`.
20
+ - **`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.
21
+ - **`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.
22
+ - **`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.
23
+
24
+ ### Fixed — independent bug-hunt pass (not in original audit)
25
+
26
+ - **`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.
27
+ - **`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.
28
+ - **`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.
29
+ - **`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.
30
+ - **`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.
31
+ - **`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.
32
+ - **`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.
33
+
34
+ ### Internal
35
+
36
+ - **Tests**: 535 / 535 still passing.
37
+ - **Strict gates**: mypy 0 errors (75 files), ruff 0 violations.
38
+ - **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.
39
+ - **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.
40
+
41
+ ## [3.3.0] - 2026-05-10
42
+
43
+ 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.
44
+
45
+ ### Fixed
46
+
47
+ - **`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).
48
+
49
+ ### Added
50
+
51
+ - **`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.
52
+ - **`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.
53
+
54
+ ### Internal
55
+
56
+ - **Tests**: 528 → 535 (+7).
57
+ - **Strict gates**: pytest 535/535, mypy 0 errors, ruff 0 violations across `src/` and `tests/`.
58
+
59
+ ## [3.2.0] - 2026-05-10
60
+
61
+ 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.
62
+
63
+ ### Fixed
64
+
65
+ - **`src/belote/belatro/items/jokers/hand_comp.py::LaSentinelle`** — Detection of the trump Jack now keys on the NS *team* via `team_of(seat) == 0` instead of `seat == Seat.SOUTH`. Pre-3.2 the joker was silently no-op when North (the partner) was dealt the trump Jack, even though Belote's "you" is team-level. Trick-win detection follows the same team rule. Regressions: `tests/belatro/test_dead_flag_fixes.py::test_la_sentinelle_arms_when_partner_plays_trump_jack`, `test_la_sentinelle_does_not_arm_for_opponent_jack`.
66
+ - **`src/belote/belatro/items/jokers/trick_timing.py::LeDernierMot`** — Dix de Der replacement now fires whenever the NS team wins the last trick (`team_of(event.winner) == 0`), not only when South personally takes it. Pre-3.2 the joker silently did nothing when partner won the closing trick. Regressions: `tests/belatro/test_belatro.py::TestLeDernierMot::test_north_last_trick_returns_result`, `test_east_last_trick_returns_none`.
67
+ - **`src/belote/belatro/items/jokers/corrupted.py::LEgoiste` → `src/belote/belatro/core/scoring.py::ScoreAccumulator.get_total`** — Final chip total is now `max(0, state._chips)`. L'Égoïste subtracts `event.card_points` for every partner-won trick; with enough partner wins the running total could cross zero, producing a negative final round score. Clamping at the scoring boundary preserves the intermediate accounting log while guaranteeing the visible score is never negative.
68
+ - **`src/belote/belatro/engine/round_driver.py:236-249`** — NS-taker `auto_coinche` path now re-emits `BidMadeEvent` with the new `coinche_level` so jokers/HUD subscribed to `on_bid` see the bump. The EW-taker branch above always emitted; this NS-side branch silently set `coinche_level = 1` without notifying subscribers.
69
+ - **`src/belote/belatro/core/run_state.py::BelAtroRun.advance_blind`** — Victory now sets both `run_won = True` and `run_over = True`, so downstream callers can rely on `run_over` alone as the terminal-state signal. `enter_endless()` resets both, re-opening the run for endless mode. Pre-3.2 the main loop only terminated via a `break` after a `run_won` check — semantically correct but fragile under refactors.
70
+ - **`src/belote/belatro/items/registry.py::ItemRegistry.register_*`** — All four register methods (`joker` / `planet` / `tarot` / `voucher`) now assert that an existing entry under the same `id` is the *same class*. Pre-3.2 a typo'd duplicate ID would silently overwrite the prior class, and the override would never surface until the original behaviour visibly broke. Idempotent re-registration of the same class still works for the test-suite swap pattern.
71
+ - **`src/belote/belatro/engine/modifier_patch.py`** — `boss_fields` is now derived from `BossModifiers`' dataclass fields via `dataclasses.fields(BossModifiers)` instead of a hardcoded set. Pre-3.2 a new boss flag added to `BossModifiers` would be silently no-op'd until someone remembered to add it to the hardcoded allowlist in lock-step.
72
+
73
+ ### Determinism
74
+
75
+ - **`src/belote/belatro/run/shop.py::Shop.generate_inventory`** — All RNG calls (`random.random` / `random.choice` / `random.sample` across edition rolls, joker pick, tarot/planet pick, voucher pick) now use `self.run._get_rng()` instead of the module-level `random`. Pre-3.2 shop contents were non-deterministic even with a seeded run, which broke ghost-run reproducibility. `Shop._roll_edition` signature changed to accept an explicit `rng` argument; the `test_shop_edition_weights_match_distribution` test was updated to pass the seeded RNG directly instead of monkey-patching `shop_mod.random.random`.
76
+ - **`src/belote/belatro/items/tarots.py`** — `LeJugement`, `LaPretresse`, and `LeFou` all now draw from `run._get_rng()` instead of the module-level `random`. Module-level `import random` removed.
77
+
78
+ ### Improved
79
+
80
+ - **`LaPretresse` planet picks now deduplicate** — switched from two independent `random.choice(planets)` calls to `rng.sample(planets, k=2)`, so the tarot can no longer pick the same planet twice. Falls back to a single pick when the planet pool has fewer than 2 entries.
81
+ - **`LeJugement` slot-full notification** — new `BelAtroRun.last_tarot_message: str | None` field carries a non-fatal failure reason ("joker slots are full — no joker granted") when the tarot can't complete. Pre-3.2 the joker was silently dropped with no UI signal. Cleared whenever a tarot is used.
82
+ - **`src/belote/ui/render.py::patch_trick_card`** — Now reads `_last_rendered_unpadded_h` (set by `render()`) and threads it into `_calculate_base_row`, so single-card patches re-apply the same vertical-centering offset `render()` used. Pre-3.2 it passed the "I don't know" sentinel (0) and skipped the offset entirely, drawing cards too high on tall terminals (>40 rows).
83
+ - **`src/belote/ui/layout.py`** — `hud_style` docstring corrected. Pre-3.2 it claimed `"verbose" / "standard" / "compact"`, but no preset used `"standard"` and no consumer recognized it — only `"verbose"` and `"compact"` are real.
84
+
85
+ ### Rejected (catalogued so they aren't re-investigated)
86
+
87
+ Eleven claims from the input audits were rejected after verification against the actual code:
88
+
89
+ - LaBalance voucher (`tie_breaks_for_taker`) and LaCompetition (`separate_scoring`) flags — **both consumed** in `src/belote/scoring.py` and `src/belote/belatro/main.py`. Qwen flagged both as P0 dead-flag bugs; verification falsified both.
90
+ - LeFou tarot "chain broken" — `run_state.py::consume` sets `last_consumable_id` *before* `item.use()` runs, so chaining works as intended.
91
+ - `no_belote_rebelote` deck-mod flag — consumed at `src/belote/scoring.py:630`.
92
+ - `_pending_tierce_charge` cross-round leak — each blind constructs a fresh `ScoreAccumulator` (main.py:126) and `drive_round` builds a fresh `GameState` via `new_game()` (round_driver.py:84), so `_joker_state` is empty at every round start. No cross-round persistence path exists.
93
+ - `fuse_jokers` "loses `on_purchase` effects" — `on_purchase` mutates `run` state (which survives fusion); re-applying on the fused instance would *double-apply* cumulative effects (LeDemon's trust drop). Pre-3.2 behaviour is correct.
94
+ - IllegalMoveError in `round_driver.py:291` — reachable only via test MockCallbacks; production `prompt_card` has a guard.
95
+ - `_card_beats` defensive `assert trump is not None` — unreachable under current contract invariants.
96
+ - `display_hud` no clear-to-EOL — HUD is rebuilt fresh per call; the claim was wrong.
97
+ - Libra planet description — "×4 instead of ×3" matches the payout; mechanism is additive per coinche level but the description references the result.
98
+ - `get_total()` float precision — explicit `int()` guard at scoring.py:248-249.
99
+ - KeyboardInterrupt save — profile is saved *before* the loop starts; only intra-run delta is lost.
100
+
101
+ ### Internal
102
+
103
+ - **Tests**: 525 → 528 (+3 net: −1 test renamed/repurposed for LeDernierMot team check, +2 new for La Sentinelle partner-detection and EW opponent rejection).
104
+ - **Strict gates**: pytest 528/528, mypy 0 errors, ruff 0 violations across `src/` and `tests/`.
105
+ - **Audit plan**: `~/.claude/plans/between-these-two-plans-graceful-puppy.md` — captures the two source audits, the verification pass that filtered them, the implementation order, and the catalogue of rejected claims.
106
+
107
+ ## [3.1.0] - 2026-05-08
108
+
109
+ Audit-action release — implements the prioritized fix list from 3.0.3. One real correctness bug fixed, one unreachable feature wired up, one money-leak path closed, three measurable perf wins, and the long-standing `modifier_patch` underscore shim retired. 525 tests passing (up from 510), ruff and mypy strict still clean across 75 source files.
110
+
111
+ ### Fixed
112
+
113
+ - **`src/belote/game.py:843-855` (HUD multi-boss running total)** — Under `Les Clubs Bannis + Le Roi Mort` (or any combo of `ban_clubs` with a rank-zero boss), the live HUD running total in `play_card` over-credited a clubs-led trick: the `ban_clubs → trick_pts = 0` branch was immediately overwritten by the rank-zero recompute. The eventual round score was already correct (different code path through `scoring.py`). Now `play_card` delegates to `scoring.trick_card_points`, the canonical helper that composes every boss zero-rank flag, `ban_clubs`, and the SE-trump scale in a single pass — the HUD cannot drift from the round score under any boss combo. Regression: `tests/test_official_rules.py::test_hud_running_total_under_multi_boss_ban_clubs_plus_kings_zero`.
114
+ - **`src/belote/belatro/run/shop.py::buy_item` (consumable money-leak)** — Slot-capacity check is now hoisted *above* `Economy.spend_money`. Pre-3.1.0 the player's money was charged for a Tarot/Planet purchase even when consumable slots were full, and the item was silently dropped. New `Shop.last_buy_failure: str | None` carries the reason ("slots_full" / "no_money") so the shop UI surfaces a `BelAtroAnnounce.banner("Slots full — sell first")` banner. Regressions: `tests/belatro/test_belatro.py::TestShop::test_buy_consumable_with_full_slots_does_not_charge_money`, `test_buy_joker_with_full_slots_does_not_charge_money`, `test_buy_item_no_money_records_no_money_failure`.
115
+
116
+ ### Added
117
+
118
+ - **TierceForge UI integration** (`src/belote/belatro/ui/shop.py`) — The `TierceForge` voucher shipped in 3.0.0 with a working `forge_tierce(run, planet_id)` backend (`src/belote/belatro/items/vouchers.py:129`) but no UI caller; the feature was unreachable. The shop now shows a "Forge ×N/3" tile when the voucher is owned, opens a numbered planet picker on Enter, and surfaces a confirmation banner on success. Regressions: `tests/belatro/test_phase2_content.py::test_forge_tierce_voucher_spends_charges_and_levels_planet`, `test_forge_tierce_blocked_when_charges_below_three`.
119
+ - **Block-policy regressions for Tarot overflow** — `LeJugement` and `LaPretresse` are now pinned to no-op when joker/consumable slots are at capacity (rather than partial-grant). Tests: `test_le_jugement_no_op_when_joker_slots_full`, `test_la_pretresse_no_op_when_consumable_slots_full`.
120
+ - **`tests/belatro/test_phase1_plumbing.py::test_joker_state_only_contains_scalar_values`** — Walks every registered joker through `on_round_start` + four event hooks and asserts no mutable container leaks into `_joker_state`. Locks the contract that lets the per-event copy stay shallow (3.1.0 dropped the deepcopy).
121
+ - **`tests/belatro/test_phase1_plumbing.py::test_shop_edition_weights_match_distribution`** — 10 000-roll empirical check on `Shop._roll_edition()`, ±1% per bucket. Catches accidental edits to the `_EDITION_WEIGHTS` table.
122
+ - **`tests/belatro/test_phase3_meta.py::test_endless_ante_target_scaling`** + `test_endless_ante_offset_zero_matches_base_table` — pin the `100 × 1.5^(ante-1) × blind × 2.2^offset` formula and the static-table parity invariant.
123
+ - **`tests/belatro/test_phase2_content.py::test_le_fou_no_prior_consumable_falls_back_to_random_tarot`** — covers the `last_id == self.id` defensive branch in `tarots.py::LeFou.use`.
124
+ - **`tests/belatro/test_boss_modifiers_integration.py::test_invariant_no_underscore_boss_attrs`** — anti-pattern lock for the architecture-pinned rule that boss flags must be reached via `state.boss_modifiers.X`, never `getattr(state, "_X", False)`.
125
+
126
+ ### Improved
127
+
128
+ - **`src/belote/scoring.py` (winners-threading)** — `score_round` already pre-computed the per-trick winner list (3.0.2); the residual `trick_winner_seat` recomputations in the Malédiction branch (lines 776-793) and `apply_round_score` (lines 843-855) are now eliminated. Per-team trick counts ride on the new `ScoringBreakdown.tricks_ns` / `tricks_ew` fields (default 0; `apply_round_score` falls back to walking when a hand-constructed breakdown leaves them at default). Net: ~16 fewer `trick_winner_seat` calls per round.
129
+ - **`src/belote/belatro/core/scoring.py::ScoreAccumulator.update_state` (deepcopy → shallow)** — Replaced the per-event `copy.deepcopy(state._joker_state)` with `dict(state._joker_state)`. All current `_joker_state` writers store scalars (bool/int/str), so the deep-copy was over-defensive — and ran ~20×/round. Module-level `import copy` and `from dataclasses import replace` removed (they were also reimported inside two methods). Contract is locked by the new scalar-invariant test.
130
+ - **`src/belote/ai.py` (Hard AI hot-loop allocations)** — `_hard_play` precomputes `hand_suit_counts: Counter[Suit]`, `my_trumps`, `opp_trumps` once per turn and threads them into `_score_card_play` / `_score_leading_strategy` / `_score_discarding_strategy`. Pre-3.1.0 these counters were rebuilt for every candidate card — a four-card legal set walked the hand and `memory.played` four times each.
131
+ - **`@dataclass(slots=True)` on `Statistics`, `SessionStats`, `ScoreAccumulator`** (`src/belote/stats.py`, `src/belote/belatro/core/scoring.py`). Frequently-instantiated containers; ~40 bytes saved per instance. `BelAtroRun` deliberately stays non-slotted (its `__post_init__` lazy-init pattern fights `slots=True`).
132
+ - **`src/belote/stats.py:97-98`** — `print(..., file=sys.stderr)` on save failure swapped for `logging.getLogger(__name__).warning`. Removed unused `import sys`.
133
+ - **`src/belote/input.py:138, 160`** — bare `except Exception:` in key-press parsing narrowed to `(UnicodeDecodeError,)` and `(ValueError, UnicodeDecodeError)`. Genuine bugs surface; key-press robustness preserved.
134
+ - **`src/belote/replay.py:46`** — explanatory comment added above the `# noqa: BLE001` so the broad-except rationale is visible at the call site.
135
+ - **`src/belote/game.py:213-217, 220-224`** — docstring on `belote_holders` and `_joker_state` documenting the "always replace, never mutate-in-place" contract for mutable dicts inside the frozen `GameState`.
136
+
137
+ ### Removed
138
+
139
+ - **`modifier_patch.py` underscore shim** — The `state.patch("_X", True)` → `state.patch("X", True)` migration is complete. All 23 boss `apply()` methods in `src/belote/belatro/run/boss.py` were rewritten in lock-step. The leading-underscore strip in `PatchedGameState.patch()` and the `__getattr__` fallback to `boss_modifiers.X` are gone; `patch()` now asserts loud on a leading-underscore key. The `getattr(state, "_X", False)` reading anti-pattern is locked against in `test_invariant_no_underscore_boss_attrs`.
140
+
141
+ ### Internal
142
+
143
+ - **Tests**: 510 → 525 (+15).
144
+ - **Strict gates**: pytest 525/525, mypy 0 errors, ruff 0 violations across `src/` and `tests/`.
145
+ - **Audit plan**: `~/.claude/plans/bug-hunt-code-performance-sleepy-ritchie.md`.
146
+
147
+ ## [3.0.3] - 2026-05-08
148
+
149
+ Full-codebase audit pass + documentation accuracy. No behaviour changes; the audit produced a prioritized findings list and corrected three stale README counts. Planned fixes (one P0 functional, two P0 perf/quality, five P1, seven P2) are tracked for follow-up cuts and not yet implemented.
150
+
151
+ ### Fixed (documentation)
152
+
153
+ - **`README.md`** — "Full Boss Blind Suite: All 18 unique bosses" → "All 21 unique bosses". 3.0.0 added Le Sauvage / L'Iconoclaste / Le Mime to bring `ALL_BOSS_MODIFIERS` (in `src/belote/belatro/run/boss.py`) to 21; the showcase line was never bumped.
154
+ - **`README.md`** — two stale "(435 tests)" / "pytest: 435/435 passed" references corrected to 510, matching `pytest --collect-only` and the figure already present at `README.md:250` ("Currently 510 tests passing").
155
+
156
+ ### Audit findings (planning only — implementation deferred)
157
+
158
+ A three-agent audit covered the classic engine vs. canonical Belote rules, BelAtro content wiring (jokers / bosses / planets / vouchers / tarots / editions / unlocks), and performance / code-quality hotspots across ~7,100 LOC. Headline: engine is rule-correct; BelAtro content matrix is 93/93 wired (21 bosses, 8 planets, 36 jokers, 4 editions, 12 vouchers, 12 tarots).
159
+
160
+ Findings tracked at `~/.claude/plans/bug-hunt-code-performance-atomic-sutton.md`:
161
+ - **P0-1** — `EventBus.emit` still never called (carried over from 3.0.2). `L'Exécuteur` / `L'Idéologue` / `Le Fanatique` unlocks silently never fire.
162
+ - **P0-2** — `legal_cards()` LRU wrapper rebuilds `Card` objects on every cache hit (`src/belote/game.py:475-653`); est. 5–8% AI-turn regression vs. caching the resolved tuple.
163
+ - **P0-3** — `play_card()` is 174 LOC / cyclomatic ~20 (`src/belote/game.py:777-950`); split into `_update_belote_tracker` / `_apply_play_modifiers` / `_resolve_trick_complete`.
164
+ - **P0-4** — `_calculate_base_points()` accepts an optional pre-computed `winners` arg; cache-miss callers walk all 8 tricks twice (`src/belote/scoring.py:580-588`). Make required.
165
+ - **P1-1** — `card_points(trump: Suit)` lies about None; 8 `# type: ignore` markers across `game.py` / `scoring.py` should drop once signature becomes `Suit | None`.
166
+ - **P1-2** — Boss zero-rank logic duplicated across three sites (`game.py:856-872`, `scoring.py:390-400`, `scoring.py:429-440`); extract a single `apply_zero_rank_bosses(card, trump, bm)` helper. Highest-leverage maintenance fix.
167
+ - **P1-3..P1-5** — `_hard_bid` recomputes void counts inside the suit loop; `trick_rank()` called twice per overtrump check; missing docstrings on hot APIs.
168
+ - **P2** — carré KeyError harden, `REBELOTE_POINTS = 40` variant doc, AI memory reset hardening, `render()` 129-LOC split, `register_all_items` `__all__`, voucher / tarot integration test (24 effects to cover).
169
+
170
+ ### Internal
171
+
172
+ - **Tests**: 510 (unchanged).
173
+ - **Strict gates**: pytest 510/510, mypy 0 errors, ruff 0 violations (all unchanged from 3.0.2).
174
+
175
+ ### Carried forward
176
+
177
+ - `EventBus.emit` wiring fix (P0-1 above) remains deferred. Now planned for 3.0.4 alongside the perf wins.
178
+
8
179
  ## [3.0.2] - 2026-05-08
9
180
 
10
181
  Audit pass — wired two previously-dead 3.0.0 modules behind opt-in env vars, removed redundant work from `score_round()`, and pinned every boss modifier's patch keys against typo regressions.
@@ -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 (510 tests expected)
87
+ # Full test suite (535 tests expected)
88
88
  PYTHONPATH=src pytest
89
89
  ```
90
90
 
91
- Current baseline (3.0.2):
91
+ Current baseline (3.3.1):
92
92
  - **mypy**: 0 errors (strict mode)
93
93
  - **ruff**: 0 violations
94
- - **pytest**: 510 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.0.2
3
+ Version: 3.3.1
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,12 +45,52 @@ 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
+
64
+ ## What's new in 3.2.0
65
+
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.
67
+ - **Score floor** — L'Égoïste's `add_chips = -event.card_points` per partner-won trick could drive the running total negative and produce a negative final round score; `ScoreAccumulator.get_total` now clamps at 0 so the intermediate log can still show the deduction without the visible score going below zero.
68
+ - **Auto-coinche event parity** — The NS-taker `auto_coinche` boss path now re-emits `BidMadeEvent` with the new `coinche_level`, matching the EW-taker branch. Pre-3.2 jokers and HUD subscribed to `on_bid` silently missed this code path.
69
+ - **Determinism** — The shop and the three RNG-using tarots (`LeJugement`, `LaPretresse`, `LeFou`) now all draw from the run's seeded `_get_rng()` instead of the module-level `random`. Ghost-run replays are now reproducible across shop generations. `LaPretresse` additionally `sample`s instead of `choice`-ing twice, so the two planets it grants are always distinct.
70
+ - **Registry / boss-field hygiene** — `register_joker/planet/tarot/voucher` now assert against duplicate IDs (typo'd registrations used to silently overwrite the original). `boss_fields` in `modifier_patch.py` is now derived from `BossModifiers`' dataclass fields, so adding a new flag no longer requires updating an out-of-band allowlist.
71
+ - **UI fix** — `patch_trick_card` now re-applies `render`'s vertical-centering offset; on tall terminals (>40 rows) it used to draw single-card patches too high.
72
+ - **Audit reconciliation** — This release consolidates the verified findings from two independent LLM code audits (Qwen 3.6 27B + Ring 1T). Both audits had load-bearing false positives — Qwen's two P0 "dead voucher / dead boss flag" claims were both wrong (the flags are consumed); Ring's "critical IllegalMoveError" only fires under test mocks. Eleven rejected claims are catalogued in the changelog so they aren't re-investigated.
73
+ - **Test coverage** — 528 tests (up from 525). Strict gates still clean: pytest 528/528, mypy 0 errors, ruff 0 violations.
74
+
75
+ ## What's new in 3.1.0
76
+
77
+ - **Bug fixes** — HUD running-total no longer drifts under multi-boss combos (`Les Clubs Bannis + Le Roi Mort` style: pre-3.1.0 the rank-zero recompute silently overwrote the `ban_clubs` zeroing). `Shop.buy_item` no longer charges money when consumable slots are full — the "Slots full — sell first" banner now fires before any spend.
78
+ - **TierceForge wired up** — the voucher shipped in 3.0.0 with a working backend but no UI caller; the feature was unreachable. The shop now shows a "Forge ×N/3" tile when the voucher is owned, opens a numbered planet picker on Enter, and confirms the level-up via a banner.
79
+ - **Performance** — `score_round` and `apply_round_score` no longer re-walk the trick list (~16 fewer `trick_winner_seat` calls per round). The per-event `copy.deepcopy` in `ScoreAccumulator.update_state` is gone (~20 deepcopies/round saved); replaced with a shallow `dict(...)` plus a scalar-only invariant test that locks the contract. Hard-AI's `_score_card_play` precomputes hand suit counts and trump tallies once per turn instead of per candidate.
80
+ - **Cleanup** — the `modifier_patch` underscore-prefix shim is gone (23 boss `apply()` methods rewritten to use unprefixed field names; the `getattr(state, "_X", False)` anti-pattern is now locked against by a regression test). `slots=True` added to `Statistics`, `SessionStats`, `ScoreAccumulator`. Bare `except Exception:` in key-press parsing narrowed; `print → logging` in stats.
81
+ - **Test coverage** — 525 tests (up from 510). Strict gates still clean: pytest 525/525, mypy 0 errors, ruff 0 violations.
82
+
83
+ ## What's new in 3.0.3
84
+
85
+ - **Full-codebase audit** — three-agent pass over the classic engine, BelAtro content wiring, and perf / code-quality hotspots (~7,100 LOC). Headline: engine is rule-correct against canonical French Belote; BelAtro content matrix is **93/93 wired** (21 bosses, 8 planets, 36 jokers, 4 editions, 12 vouchers, 12 tarots). Prioritized findings list (1 P0 functional, 2 P0 perf, 5 P1, 7 P2) tracked for follow-up cuts; implementation landed in 3.1.0.
86
+ - **Doc accuracy** — README boss-count corrected (18 → 21; 3.0.0 added Le Sauvage / L'Iconoclaste / Le Mime), and two stale `(435 tests)` references bumped to 510 to match the figure already present elsewhere in the file.
87
+
48
88
  ## What's new in 3.0.2
49
89
 
50
90
  - **Replay analyzer + Ghost run wired up** — both shipped in 3.0.0 as code modules but were never called from the running game. Now opt-in behind `BELOTE_REPLAY=1` (post-round Hard-AI comparison) and `BELOTE_GHOST=1` (per-run JSON dump to `~/.local/share/belote/ghosts/`). See DEVELOPMENT.md › Optional Runtime Flags.
51
91
  - **Performance** — `score_round()` now caches per-trick winners once instead of recomputing them in each boss-modifier helper (2-3× walks → 1× walk per round). `register_all_items()` is now idempotent so test setup no longer re-walks every items module per `BelAtroRun`. Bidding's special-bid path (TA / SA) hoists `_suit_lengths` out of the per-difficulty branches.
52
92
  - **Defensive pin** — every entry in `ALL_BOSS_MODIFIERS` is now asserted to actually toggle a `BossModifiers` field via `.flags()`. Catches typo'd `state.patch("_misspelled", True)` keys at test time rather than letting the boss silently no-op.
53
- - **Test coverage** — 510 tests (up from 509).
93
+ - **Test coverage** — 525 tests (up from 509).
54
94
 
55
95
  ## What's new in 3.0.1
56
96
 
@@ -222,7 +262,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
222
262
 
223
263
  - **BelAtro Roguelite Mode:** A massive expansion featuring 36 Jokers, 12 Tarot cards, 8 Planets, 12 Vouchers, and permanent upgrades.
224
264
  - **Collection (Almanac):** Persistent tracker to browse every Joker, Planet, and Voucher you've discovered across your runs.
225
- - **Full Boss Blind Suite:** All 18 unique bosses implemented, including complex mechanics like *L'Anarchie* (dynamic trump) and *La Rupture* (no consecutive wins).
265
+ - **Full Boss Blind Suite:** All 21 unique bosses implemented, including complex mechanics like *L'Anarchie* (dynamic trump) and *La Rupture* (no consecutive wins).
226
266
  - **Multiplier Scoring:** Use items to stack Multipliers and reach scores in the millions.
227
267
  - **Partner Trust:** Build a relationship with your AI partner to unlock synergies.
228
268
  - **Rich Terminal UI:** Full-screen green felt table with detailed card graphics and "You" vs "Partner" terminology.
@@ -274,7 +314,7 @@ belote/
274
314
  │ ├── input.py # Platform-dispatched key reader and interruptible sleep
275
315
  │ ├── stats.py # Global and session statistics tracking
276
316
  │ └── rules.py # Game rules content
277
- ├── tests/ # Comprehensive test suite (435 tests)
317
+ ├── tests/ # Comprehensive test suite (528 tests)
278
318
  ├── scripts/ # Performance benchmarks
279
319
  ├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
280
320
  ├── LICENSE # MIT License
@@ -290,14 +330,14 @@ belote/
290
330
  PYTHONPATH=src pytest
291
331
  ```
292
332
 
293
- Currently **510 tests** passing with 100% coverage on game-logic modules.
333
+ Currently **528 tests** passing with 100% coverage on game-logic modules.
294
334
 
295
335
  ## Technical Integrity
296
336
 
297
337
  The codebase is strictly validated with the following tools:
298
338
  - **mypy**: 0 errors (strict type safety)
299
339
  - **ruff**: 0 violations (linting & formatting)
300
- - **pytest**: 435/435 passed
340
+ - **pytest**: 528/528 passed
301
341
  - **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
302
342
  - **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
303
343
 
@@ -2,12 +2,52 @@
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
+
21
+ ## What's new in 3.2.0
22
+
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.
24
+ - **Score floor** — L'Égoïste's `add_chips = -event.card_points` per partner-won trick could drive the running total negative and produce a negative final round score; `ScoreAccumulator.get_total` now clamps at 0 so the intermediate log can still show the deduction without the visible score going below zero.
25
+ - **Auto-coinche event parity** — The NS-taker `auto_coinche` boss path now re-emits `BidMadeEvent` with the new `coinche_level`, matching the EW-taker branch. Pre-3.2 jokers and HUD subscribed to `on_bid` silently missed this code path.
26
+ - **Determinism** — The shop and the three RNG-using tarots (`LeJugement`, `LaPretresse`, `LeFou`) now all draw from the run's seeded `_get_rng()` instead of the module-level `random`. Ghost-run replays are now reproducible across shop generations. `LaPretresse` additionally `sample`s instead of `choice`-ing twice, so the two planets it grants are always distinct.
27
+ - **Registry / boss-field hygiene** — `register_joker/planet/tarot/voucher` now assert against duplicate IDs (typo'd registrations used to silently overwrite the original). `boss_fields` in `modifier_patch.py` is now derived from `BossModifiers`' dataclass fields, so adding a new flag no longer requires updating an out-of-band allowlist.
28
+ - **UI fix** — `patch_trick_card` now re-applies `render`'s vertical-centering offset; on tall terminals (>40 rows) it used to draw single-card patches too high.
29
+ - **Audit reconciliation** — This release consolidates the verified findings from two independent LLM code audits (Qwen 3.6 27B + Ring 1T). Both audits had load-bearing false positives — Qwen's two P0 "dead voucher / dead boss flag" claims were both wrong (the flags are consumed); Ring's "critical IllegalMoveError" only fires under test mocks. Eleven rejected claims are catalogued in the changelog so they aren't re-investigated.
30
+ - **Test coverage** — 528 tests (up from 525). Strict gates still clean: pytest 528/528, mypy 0 errors, ruff 0 violations.
31
+
32
+ ## What's new in 3.1.0
33
+
34
+ - **Bug fixes** — HUD running-total no longer drifts under multi-boss combos (`Les Clubs Bannis + Le Roi Mort` style: pre-3.1.0 the rank-zero recompute silently overwrote the `ban_clubs` zeroing). `Shop.buy_item` no longer charges money when consumable slots are full — the "Slots full — sell first" banner now fires before any spend.
35
+ - **TierceForge wired up** — the voucher shipped in 3.0.0 with a working backend but no UI caller; the feature was unreachable. The shop now shows a "Forge ×N/3" tile when the voucher is owned, opens a numbered planet picker on Enter, and confirms the level-up via a banner.
36
+ - **Performance** — `score_round` and `apply_round_score` no longer re-walk the trick list (~16 fewer `trick_winner_seat` calls per round). The per-event `copy.deepcopy` in `ScoreAccumulator.update_state` is gone (~20 deepcopies/round saved); replaced with a shallow `dict(...)` plus a scalar-only invariant test that locks the contract. Hard-AI's `_score_card_play` precomputes hand suit counts and trump tallies once per turn instead of per candidate.
37
+ - **Cleanup** — the `modifier_patch` underscore-prefix shim is gone (23 boss `apply()` methods rewritten to use unprefixed field names; the `getattr(state, "_X", False)` anti-pattern is now locked against by a regression test). `slots=True` added to `Statistics`, `SessionStats`, `ScoreAccumulator`. Bare `except Exception:` in key-press parsing narrowed; `print → logging` in stats.
38
+ - **Test coverage** — 525 tests (up from 510). Strict gates still clean: pytest 525/525, mypy 0 errors, ruff 0 violations.
39
+
40
+ ## What's new in 3.0.3
41
+
42
+ - **Full-codebase audit** — three-agent pass over the classic engine, BelAtro content wiring, and perf / code-quality hotspots (~7,100 LOC). Headline: engine is rule-correct against canonical French Belote; BelAtro content matrix is **93/93 wired** (21 bosses, 8 planets, 36 jokers, 4 editions, 12 vouchers, 12 tarots). Prioritized findings list (1 P0 functional, 2 P0 perf, 5 P1, 7 P2) tracked for follow-up cuts; implementation landed in 3.1.0.
43
+ - **Doc accuracy** — README boss-count corrected (18 → 21; 3.0.0 added Le Sauvage / L'Iconoclaste / Le Mime), and two stale `(435 tests)` references bumped to 510 to match the figure already present elsewhere in the file.
44
+
5
45
  ## What's new in 3.0.2
6
46
 
7
47
  - **Replay analyzer + Ghost run wired up** — both shipped in 3.0.0 as code modules but were never called from the running game. Now opt-in behind `BELOTE_REPLAY=1` (post-round Hard-AI comparison) and `BELOTE_GHOST=1` (per-run JSON dump to `~/.local/share/belote/ghosts/`). See DEVELOPMENT.md › Optional Runtime Flags.
8
48
  - **Performance** — `score_round()` now caches per-trick winners once instead of recomputing them in each boss-modifier helper (2-3× walks → 1× walk per round). `register_all_items()` is now idempotent so test setup no longer re-walks every items module per `BelAtroRun`. Bidding's special-bid path (TA / SA) hoists `_suit_lengths` out of the per-difficulty branches.
9
49
  - **Defensive pin** — every entry in `ALL_BOSS_MODIFIERS` is now asserted to actually toggle a `BossModifiers` field via `.flags()`. Catches typo'd `state.patch("_misspelled", True)` keys at test time rather than letting the boss silently no-op.
10
- - **Test coverage** — 510 tests (up from 509).
50
+ - **Test coverage** — 525 tests (up from 509).
11
51
 
12
52
  ## What's new in 3.0.1
13
53
 
@@ -179,7 +219,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
179
219
 
180
220
  - **BelAtro Roguelite Mode:** A massive expansion featuring 36 Jokers, 12 Tarot cards, 8 Planets, 12 Vouchers, and permanent upgrades.
181
221
  - **Collection (Almanac):** Persistent tracker to browse every Joker, Planet, and Voucher you've discovered across your runs.
182
- - **Full Boss Blind Suite:** All 18 unique bosses implemented, including complex mechanics like *L'Anarchie* (dynamic trump) and *La Rupture* (no consecutive wins).
222
+ - **Full Boss Blind Suite:** All 21 unique bosses implemented, including complex mechanics like *L'Anarchie* (dynamic trump) and *La Rupture* (no consecutive wins).
183
223
  - **Multiplier Scoring:** Use items to stack Multipliers and reach scores in the millions.
184
224
  - **Partner Trust:** Build a relationship with your AI partner to unlock synergies.
185
225
  - **Rich Terminal UI:** Full-screen green felt table with detailed card graphics and "You" vs "Partner" terminology.
@@ -231,7 +271,7 @@ belote/
231
271
  │ ├── input.py # Platform-dispatched key reader and interruptible sleep
232
272
  │ ├── stats.py # Global and session statistics tracking
233
273
  │ └── rules.py # Game rules content
234
- ├── tests/ # Comprehensive test suite (435 tests)
274
+ ├── tests/ # Comprehensive test suite (528 tests)
235
275
  ├── scripts/ # Performance benchmarks
236
276
  ├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
237
277
  ├── LICENSE # MIT License
@@ -247,14 +287,14 @@ belote/
247
287
  PYTHONPATH=src pytest
248
288
  ```
249
289
 
250
- Currently **510 tests** passing with 100% coverage on game-logic modules.
290
+ Currently **528 tests** passing with 100% coverage on game-logic modules.
251
291
 
252
292
  ## Technical Integrity
253
293
 
254
294
  The codebase is strictly validated with the following tools:
255
295
  - **mypy**: 0 errors (strict type safety)
256
296
  - **ruff**: 0 violations (linting & formatting)
257
- - **pytest**: 435/435 passed
297
+ - **pytest**: 528/528 passed
258
298
  - **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
259
299
  - **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
260
300
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "belote-cli"
7
- version = "3.0.2"
7
+ version = "3.3.1"
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.0.2"
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)
@@ -490,12 +522,31 @@ class AIPlayer:
490
522
  )
491
523
  partner_winning = current_winner is not None and current_winner == p
492
524
 
525
+ # Precompute per-call counters used by every scoring branch — pre-3.1.0
526
+ # these were recomputed per candidate card (n×4 walks of the hand and
527
+ # memory.played for each legal card).
528
+ from collections import Counter
529
+
530
+ my_hand = state.hand_of(self.seat)
531
+ hand_suit_counts: dict[Suit, int] = Counter(c.suit for c in my_hand)
532
+ my_trumps = hand_suit_counts.get(trump, 0)
533
+ opp_trumps = 8 - sum(1 for c in self.memory.played if c.suit == trump)
534
+
493
535
  # Score each legal card by expected outcome
494
536
  best_card = legal[0]
495
537
  best_score: float = -999.0
496
538
 
497
539
  for card in legal:
498
- score = self._score_card_play(card, state, trump, trick, partner_winning)
540
+ score = self._score_card_play(
541
+ card,
542
+ state,
543
+ trump,
544
+ trick,
545
+ partner_winning,
546
+ hand_suit_counts,
547
+ my_trumps,
548
+ opp_trumps,
549
+ )
499
550
  if score > best_score:
500
551
  best_score = score
501
552
  best_card = card
@@ -509,6 +560,9 @@ class AIPlayer:
509
560
  trump: Suit,
510
561
  trick: tuple[TrickCard, ...],
511
562
  partner_winning: bool,
563
+ hand_suit_counts: dict[Suit, int],
564
+ my_trumps: int,
565
+ opp_trumps: int,
512
566
  ) -> float:
513
567
  """Score a card play decision with advanced heuristics."""
514
568
  score = 0.0
@@ -517,20 +571,20 @@ class AIPlayer:
517
571
  score += points * 0.1
518
572
 
519
573
  if not trick:
520
- return self._score_leading_strategy(card, state, trump)
574
+ return self._score_leading_strategy(card, trump, my_trumps, opp_trumps)
521
575
 
522
576
  if partner_winning and trick[0].card.suit != trump:
523
- return self._score_discarding_strategy(card, state, trump, points)
577
+ return self._score_discarding_strategy(card, trump, points, hand_suit_counts)
524
578
 
525
579
  return self._score_winning_strategy(card, state, trump, trick, partner_winning, points)
526
580
 
527
- def _score_leading_strategy(self, card: Card, state: GameState, trump: Suit) -> float:
581
+ def _score_leading_strategy(
582
+ self, card: Card, trump: Suit, my_trumps: int, opp_trumps: int
583
+ ) -> float:
528
584
  """Heuristics for when we are leading the trick."""
529
585
  score = 0.0
530
586
  if card.suit == trump:
531
587
  # Leading trump is good for pulling if opponents still have them
532
- opp_trumps = 8 - sum(1 for c in self.memory.played if c.suit == trump)
533
- my_trumps = sum(1 for c in state.hand_of(self.seat) if c.suit == trump)
534
588
  if opp_trumps > my_trumps:
535
589
  score += 4
536
590
  else:
@@ -543,7 +597,7 @@ class AIPlayer:
543
597
  return score
544
598
 
545
599
  def _score_discarding_strategy(
546
- self, card: Card, state: GameState, trump: Suit, points: int
600
+ self, card: Card, trump: Suit, points: int, hand_suit_counts: dict[Suit, int]
547
601
  ) -> float:
548
602
  """Heuristics for when partner is winning and we can discard."""
549
603
  score = 0.0
@@ -551,9 +605,7 @@ class AIPlayer:
551
605
  score -= points * 0.7 # Penalize throwing away points
552
606
 
553
607
  # Prefer discarding from short suits (to establish voids)
554
- my_hand = state.hand_of(self.seat)
555
- suit_count = sum(1 for c in my_hand if c.suit == card.suit)
556
- if suit_count == 1:
608
+ if hand_suit_counts.get(card.suit, 0) == 1:
557
609
  score += 3
558
610
 
559
611
  # Prefer keeping cards that partner is void in (to trump later)