belote-cli 3.3.1__tar.gz → 3.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. {belote_cli-3.3.1 → belote_cli-3.3.3}/CHANGELOG.md +75 -0
  2. {belote_cli-3.3.1 → belote_cli-3.3.3}/DEVELOPMENT.md +5 -4
  3. {belote_cli-3.3.1 → belote_cli-3.3.3}/PKG-INFO +10 -1
  4. {belote_cli-3.3.1 → belote_cli-3.3.3}/README.md +9 -0
  5. {belote_cli-3.3.1 → belote_cli-3.3.3}/pyproject.toml +1 -1
  6. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/__init__.py +1 -1
  7. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/core/scoring.py +5 -1
  8. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/tarots.py +9 -1
  9. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/main.py +5 -3
  10. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/game.py +18 -5
  11. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/gameflow.py +1 -1
  12. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/replay.py +11 -2
  13. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/scoring.py +7 -6
  14. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/belatro/test_belatro.py +98 -0
  15. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/belatro/test_boss_modifiers_integration.py +51 -1
  16. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/belatro/test_hud_synergy.py +20 -0
  17. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/test_game_logic.py +51 -1
  18. belote_cli-3.3.3/tests/test_properties.py +166 -0
  19. belote_cli-3.3.3/tests/test_replay.py +183 -0
  20. belote_cli-3.3.1/tests/test_properties.py +0 -63
  21. belote_cli-3.3.1/tests/test_replay.py +0 -48
  22. {belote_cli-3.3.1 → belote_cli-3.3.3}/.claude/settings.local.json +0 -0
  23. {belote_cli-3.3.1 → belote_cli-3.3.3}/.gitignore +0 -0
  24. {belote_cli-3.3.1 → belote_cli-3.3.3}/.python-version +0 -0
  25. {belote_cli-3.3.1 → belote_cli-3.3.3}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
  26. {belote_cli-3.3.1 → belote_cli-3.3.3}/LICENSE +0 -0
  27. {belote_cli-3.3.1 → belote_cli-3.3.3}/scripts/benchmark.py +0 -0
  28. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/__init__.py +0 -0
  29. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/a11y.py +0 -0
  30. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/achievements.py +0 -0
  31. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/ai.py +0 -0
  32. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/ansi.py +0 -0
  33. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/__init__.py +0 -0
  34. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/core/__init__.py +0 -0
  35. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/core/economy.py +0 -0
  36. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/core/run_state.py +0 -0
  37. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/engine/__init__.py +0 -0
  38. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/engine/event_bus.py +0 -0
  39. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/engine/modifier_patch.py +0 -0
  40. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/engine/round_driver.py +0 -0
  41. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/ghost_run.py +0 -0
  42. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/__init__.py +0 -0
  43. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/base.py +0 -0
  44. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/__init__.py +0 -0
  45. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/annonces.py +0 -0
  46. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/coinche.py +0 -0
  47. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/contract.py +0 -0
  48. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  49. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/economy.py +0 -0
  50. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  51. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
  52. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  53. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  54. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  55. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  56. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/planets.py +0 -0
  57. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/registry.py +0 -0
  58. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/items/vouchers.py +0 -0
  59. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/partner/__init__.py +0 -0
  60. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/partner/partner_state.py +0 -0
  61. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/partner/personality.py +0 -0
  62. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/partner/trust.py +0 -0
  63. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/progression/__init__.py +0 -0
  64. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/progression/save.py +0 -0
  65. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/progression/unlocks.py +0 -0
  66. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/run/__init__.py +0 -0
  67. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/run/ante.py +0 -0
  68. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/run/ante_themes.py +0 -0
  69. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/run/boss.py +0 -0
  70. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/run/decks.py +0 -0
  71. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/run/shop.py +0 -0
  72. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/run_summary.py +0 -0
  73. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/ui/__init__.py +0 -0
  74. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/ui/announce.py +0 -0
  75. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/ui/collection.py +0 -0
  76. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/ui/history.py +0 -0
  77. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/ui/hud.py +0 -0
  78. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/ui/menu.py +0 -0
  79. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/ui/rules.py +0 -0
  80. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/ui/shop.py +0 -0
  81. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/belatro/ui/trust_bar.py +0 -0
  82. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/config.py +0 -0
  83. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/context.py +0 -0
  84. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/deck.py +0 -0
  85. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/input.py +0 -0
  86. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/main.py +0 -0
  87. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/rules.py +0 -0
  88. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/stats.py +0 -0
  89. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/themes.py +0 -0
  90. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/ui/__init__.py +0 -0
  91. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/ui/announce.py +0 -0
  92. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/ui/layout.py +0 -0
  93. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/ui/menu.py +0 -0
  94. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/ui/prompts.py +0 -0
  95. {belote_cli-3.3.1 → belote_cli-3.3.3}/src/belote/ui/render.py +0 -0
  96. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/__init__.py +0 -0
  97. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/belatro/__init__.py +0 -0
  98. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/belatro/test_collection_logic.py +0 -0
  99. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/belatro/test_contract_unlocks.py +0 -0
  100. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/belatro/test_dead_flag_fixes.py +0 -0
  101. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/belatro/test_deck_variants.py +0 -0
  102. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/belatro/test_ghost_run.py +0 -0
  103. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/belatro/test_history_overlay.py +0 -0
  104. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/belatro/test_partner_trust.py +0 -0
  105. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/belatro/test_phase0_coverage.py +0 -0
  106. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/belatro/test_phase1_plumbing.py +0 -0
  107. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/belatro/test_phase2_content.py +0 -0
  108. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/belatro/test_phase3_meta.py +0 -0
  109. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/belatro/test_progression.py +0 -0
  110. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/belatro/test_round_driver.py +0 -0
  111. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/test_a11y.py +0 -0
  112. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/test_achievements.py +0 -0
  113. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/test_ai.py +0 -0
  114. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/test_ansi_helpers.py +0 -0
  115. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/test_belote.py +0 -0
  116. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/test_extended.py +0 -0
  117. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/test_gameflow.py +0 -0
  118. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/test_layout.py +0 -0
  119. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/test_new_coverage.py +0 -0
  120. {belote_cli-3.3.1 → belote_cli-3.3.3}/tests/test_official_rules.py +0 -0
  121. {belote_cli-3.3.1 → belote_cli-3.3.3}/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.3] - 2026-05-10
9
+
10
+ Audit-of-audit release — a fresh three-agent codebase pass (classic engine / BelAtro mode / tests + UI) produced ~50 candidate findings. Verification cut that to **3 real fixes** plus **3 net-new invariant test suites** for properties the prior 3.3.x cycles silently relied on. ~14 rejected claims are catalogued at the bottom of this entry so they aren't re-investigated next cycle. 549 tests passing (up from 537), ruff and mypy strict still clean. Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-tingly-barto.md`.
11
+
12
+ ### Fixed
13
+
14
+ - **`src/belote/game.py::sort_hand` (F1)** — Under the Tout Atout contract every card should sort by the trump rank ladder (`J > 9 > A > 10 > K > Q > 8 > 7`). Pre-3.3.3 the sort key gated `_TRUMP_RANK_IDX` on `c.suit == trump`, which is *always* false when `trump is Suit.TOUT_ATOUT` because `Card.suit` is one of `SPADES/HEARTS/DIAMONDS/CLUBS` (TA is a contract-level marker, not a card suit). Result: the South hand displayed in the non-trump order whenever the player bid or held TA. Fix: explicit `all_trump = trump is Suit.TOUT_ATOUT` branch in `sort_key`. Also extends `_SUIT_IDX_CACHE` to pre-build the TA entry so the hot path stays cache-resident. UI-only — no scoring impact. Regression test in `tests/test_game_logic.py::test_sort_hand_uses_trump_ladder_under_tout_atout`.
15
+ - **`src/belote/belatro/main.py::_play_blind` (F2)** — Boss assignment on the boss blind now draws from `self.run._get_rng().choice(ALL_BOSS_MODIFIERS)`. Pre-3.3.3 the function imported `random` inline and called `random.choice()` on the module-level RNG — the same class of bug the 3.2.0 release fixed for shop generation and the three RNG-using tarots (`LeJugement` / `LaPretresse` / `LeFou`). Boss assignment was the last unseeded RNG site in the BelAtro round flow; ghost-run reproducibility now observes the same boss for the same seed regardless of prior process-wide RNG state. Regression tests in `tests/belatro/test_belatro.py::TestBossSelectionDeterminism` (behaviour + source-grep against the anti-pattern).
16
+ - **`src/belote/belatro/items/tarots.py::LeJugement` (F3)** — The tarot's description promises *"a random Common Joker"* but the implementation drew from `registry.get_available_jokers(run.profile)` — the full unlocked pool across all rarities. Late-run players with Rare/Legendary jokers unlocked could roll Legendary off this tarot, which is strictly stronger than advertised and mis-prices the consumable. Fix: filter the pool to `getattr(v, "rarity", Rarity.COMMON) == Rarity.COMMON` before the choice; existing empty-pool guard handles the (rare) case where no Commons are available. Regression tests in `tests/belatro/test_belatro.py::TestLeJugementRarity`.
17
+
18
+ ### Added — invariant tests
19
+
20
+ These are the three test suites the 3.3.x bug cycle has been silently asking for. Each one would have caught at least one prior bug from below.
21
+
22
+ - **`tests/test_properties.py` — scoring conservation per contract (T1)** — Three new tests drive seeded full rounds and assert `table_taker_pts + table_defender_pts == 162` (normal) / `258` (Tout Atout) / `130` (Sans Atout). Plus a card-consumption invariant: after a full round every hand is empty and exactly 8 tricks were recorded. Would have caught the L'Anarchie belote-zero (3.3.1) and the La Rupture HUD divergence (3.3.1/3.3.2) years earlier had it existed at the time. Also includes a small `_drive_full_round` helper for future scoring-pin tests (handles the round-2-only TA/SA bidding flow).
23
+ - **`tests/test_replay.py` — replay round-trip + seeded determinism (T2)** — Two new tests: (a) record each played card from a seeded run, replay them into a fresh `GameState` built from the same seed, assert identical final state across `team_scores` / `completed_tricks` / `belote_tracker` / `belote_announcer` / `last_trick_winner`; (b) drive the same seed twice and assert identical 32-card sequences. Pins the determinism promise the 3.3.1 AI-RNG fix and the 3.3.2 replay-RNG fix established.
24
+ - **`tests/belatro/test_hud_synergy.py` — solo-half pair test (T3)** — The existing file already exercises the "both halves present → badge fires" direction. The new test adds the negative direction: for each pair in `_SYNERGY_PAIRS`, feed a single half into `detect_synergies` and assert no badge fires. Trip-wire for any change to the synergy matcher that accidentally promotes lone jokers to a pair badge.
25
+
26
+ ### Internal
27
+
28
+ - **Tests**: 537 → 549 (+12 — 3 F-regressions + 4 T1 + 2 T2 + 1 T3 + extra cross-suit / TA sanity assertions).
29
+ - **Strict gates**: pytest 549/549, mypy 0 errors (76 files), ruff 0 violations.
30
+ - **`_SUIT_IDX_CACHE` widened**: now pre-builds for `(None, Suit.TOUT_ATOUT, *_SUITS_ORDER)` instead of just `(None, *_SUITS_ORDER)`. Removes a per-render cache miss under TA but is otherwise a no-op.
31
+
32
+ ### Rejected — claims catalogued (so they aren't re-investigated)
33
+
34
+ The three Explore agents that drove this audit surfaced many plausible-sounding findings; the ones below fell apart on direct read of the current code and are documented here to save the next cycle from re-investigating them.
35
+
36
+ **Already fixed in 3.3.1/3.3.2 (agents read against stale priors):**
37
+ - "`_hard_play` returns `legal[0]` under Sans Atout" — fixed in 3.3.1.
38
+ - "`AIPlayer.__init__` constructs unseeded `Random()`" — fixed in 3.3.1; `analyze_round` followed in 3.3.2.
39
+ - "`AIMemory.last_voids_key` not reset on mid-round undo" — fixed in 3.3.1.
40
+ - "Live HUD diverges from final score under La Rupture" — fixed in 3.3.1 (`compute_trick_winners`) + 3.3.2 (`is_capot(tricks=…)`).
41
+ - "Belote/Rebelote silently zeroed under L'Anarchie when trump rotates" — fixed in 3.3.1 via `GameState.belote_announcer`.
42
+
43
+ **Interpretive, not bugs:**
44
+ - "`ScoreAccumulator` applies edition before partner-tier scaling, so Holo isn't tier-scaled" — by design. Editions ride along once per joker trigger; tier extras re-apply the *base* joker result. Otherwise an elite-tier Polychrome partner joker would compound geometrically.
45
+ - "Libra's `coinche_multiplier=1.0 × event.coinche_level` makes coinche pay ×5, not ×4" — description is ambiguous; the math matches the Phase 3 design doc (`+1 Mult per coinche level on success`).
46
+ - "Pluto `capot_bonus = 48` is additive to 252 = 300" — that *is* the advertised behaviour.
47
+ - "`_TIERCE_LIKE` has title-case dead entries (`Tierce`/`Quarte`/`Quinte`)" — `decl.kind` is always lowercase (`sequence`/`carre`/`belote`/`rebelote`), so only `"sequence"` ever matches. The joker fires correctly on every Tierce/Quarte/Quinte; the title-case entries are dead but harmless.
48
+ - "QuinteRoyale arms on `event.points >= 100` instead of declaration length" — Quinte = 100 pts in classic Belote and `event.points` is the unmodified `get_declaration_points([...])` computed inline at emit time; the proxy is sound.
49
+ - "`EventBus.emit` has no try/except around handlers" — broad-except would mask real bugs in joker/accumulator code. Current handlers are internal; an exception should surface in dev/test rather than be swallowed.
50
+ - "Negative-edition jokers still pay the 1.5× shop markup" — design: Negative is the rarest edition and grants a permanent +1 joker slot.
51
+ - "Boss `random.choice` doesn't respect profile unlocks" — there is no boss-unlock system in the data model; all bosses are always available by design.
52
+ - "ToutStreak / LeSergent reset semantics don't match flavour text" — joker authoring judgment call. Behaviour matches the registry definition.
53
+
54
+ **Already addressed by existing code:**
55
+ - "`ScoreAccumulator.update_state` clones `_joker_state` per event" — intentional shallow copy; `test_joker_state_only_contains_scalar_values` pins the scalar invariant.
56
+ - "Registry duplicate-ID overwrites silently" — fixed in 3.2.0; all four `register_*` methods assert same-class re-registration.
57
+ - "`_SUIT_IDX_CACHE` missing TOUT_ATOUT" — addressed as part of F1 (now in the cache).
58
+
59
+ ## [3.3.2] - 2026-05-10
60
+
61
+ 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.
62
+
63
+ ### Fixed
64
+
65
+ - **`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`.
66
+ - **`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`.
67
+ - **`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.
68
+
69
+ ### Internal
70
+
71
+ - **Tests**: 535 → 537 (+2 regressions for F1 and F2).
72
+ - **Strict gates**: pytest 537/537, mypy 0 errors (76 files), ruff 0 violations.
73
+ - **`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.
74
+
75
+ ### Rejected — performance "wins" catalogued (so they aren't re-investigated)
76
+
77
+ - **"`_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.
78
+ - **"`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.
79
+ - **"`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.
80
+ - **"`stats.py` per-round full-rewrite is a regression"** — falsified. It's the B2 (3.3.1) fix for crash-safety, intentional.
81
+ - **"Event-bus `list(self._handlers)` per emit is wasteful"** — defensive copy enabling sub/unsub during emit. Handler counts are static and tiny.
82
+
8
83
  ## [3.3.1] - 2026-05-10
9
84
 
10
85
  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.
@@ -84,14 +84,15 @@ PYTHONPATH=src mypy --strict src/
84
84
  # Linting (0 violations expected)
85
85
  ruff check src/ tests/
86
86
 
87
- # Full test suite (535 tests expected)
87
+ # Full test suite (549 tests expected)
88
88
  PYTHONPATH=src pytest
89
89
  ```
90
90
 
91
- Current baseline (3.3.1):
92
- - **mypy**: 0 errors (strict mode)
91
+ Current baseline (3.3.3):
92
+ - **mypy**: 0 errors (strict mode, 76 files)
93
93
  - **ruff**: 0 violations
94
- - **pytest**: 535 tests, 0 failures
94
+ - **pytest**: 549 tests, 0 failures
95
+ - 3.3.3 covered: boss-RNG seeding, Tout Atout hand sort, Le Jugement Common-only filter, plus three new invariant suites (scoring conservation, replay round-trip, HUD synergy negative test).
95
96
 
96
97
  Run all gates before committing:
97
98
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: belote-cli
3
- Version: 3.3.1
3
+ Version: 3.3.3
4
4
  Summary: A 4-player terminal card game
5
5
  Project-URL: Homepage, https://github.com/ElysiumDisc/belote
6
6
  Project-URL: Repository, https://github.com/ElysiumDisc/belote
@@ -45,6 +45,15 @@ 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.3
49
+
50
+ - **Determinism** — Boss assignment in BelAtro mode now draws from the run's seeded RNG instead of the module-level `random`. This was the last unseeded RNG site in the round flow (shop and tarots were converted in 3.2.0, AI in 3.3.1, replay analysis in 3.3.2). Same seed now reproduces the same boss on the boss blind.
51
+ - **UI** — Hands sort by the trump-rank ladder under Tout Atout (Jack first, then 9 / A / 10 / K / Q / 8 / 7). Pre-3.3.3 the predicate `c.suit == trump` was always false under TA because Card.suit is never `Suit.TOUT_ATOUT`, so the South hand displayed in the non-trump order whenever the player bid TA.
52
+ - **Tarot** — Le Jugement now correctly grants only Common jokers as advertised. Pre-3.3.3 the code drew from the full unlocked pool, so late-run players with Rare/Legendary unlocks could roll out-of-rarity jokers off this tarot.
53
+ - **Test moat** — Three new invariant test suites added: scoring-conservation property (`table_taker + table_defender == 162 / 258 / 130` for normal / Tout Atout / Sans Atout), seeded-round replay determinism (same seed → same card sequence → same final state), and HUD synergy-badge negative test (solo half of a pair must not fire the badge). These would have caught most of the 3.3.x bug cycle from below.
54
+ - **Audit reconciliation** — A fresh three-agent codebase pass surfaced ~50 candidate findings; the three above held up under verification and the rest are catalogued in `CHANGELOG.md` so they aren't re-investigated.
55
+ - **Test coverage** — 549 tests (up from 537). Strict gates still clean: pytest 549/549, mypy 0 errors (76 files), ruff 0 violations.
56
+
48
57
  ## What's new in 3.3.1
49
58
 
50
59
  - **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.
@@ -2,6 +2,15 @@
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.3
6
+
7
+ - **Determinism** — Boss assignment in BelAtro mode now draws from the run's seeded RNG instead of the module-level `random`. This was the last unseeded RNG site in the round flow (shop and tarots were converted in 3.2.0, AI in 3.3.1, replay analysis in 3.3.2). Same seed now reproduces the same boss on the boss blind.
8
+ - **UI** — Hands sort by the trump-rank ladder under Tout Atout (Jack first, then 9 / A / 10 / K / Q / 8 / 7). Pre-3.3.3 the predicate `c.suit == trump` was always false under TA because Card.suit is never `Suit.TOUT_ATOUT`, so the South hand displayed in the non-trump order whenever the player bid TA.
9
+ - **Tarot** — Le Jugement now correctly grants only Common jokers as advertised. Pre-3.3.3 the code drew from the full unlocked pool, so late-run players with Rare/Legendary unlocks could roll out-of-rarity jokers off this tarot.
10
+ - **Test moat** — Three new invariant test suites added: scoring-conservation property (`table_taker + table_defender == 162 / 258 / 130` for normal / Tout Atout / Sans Atout), seeded-round replay determinism (same seed → same card sequence → same final state), and HUD synergy-badge negative test (solo half of a pair must not fire the badge). These would have caught most of the 3.3.x bug cycle from below.
11
+ - **Audit reconciliation** — A fresh three-agent codebase pass surfaced ~50 candidate findings; the three above held up under verification and the rest are catalogued in `CHANGELOG.md` so they aren't re-investigated.
12
+ - **Test coverage** — 549 tests (up from 537). Strict gates still clean: pytest 549/549, mypy 0 errors (76 files), ruff 0 violations.
13
+
5
14
  ## What's new in 3.3.1
6
15
 
7
16
  - **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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "belote-cli"
7
- version = "3.3.1"
7
+ version = "3.3.3"
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.3.1"
1
+ __version__ = "3.3.3"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -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
 
@@ -32,9 +32,17 @@ class LeJugement(Tarot):
32
32
  description = "Instantly gain a random Common Joker."
33
33
 
34
34
  def use(self, run: BelAtroRun, context: object) -> None:
35
+ from .base import Rarity
35
36
  from .registry import registry
36
37
  run.last_tarot_message = None
37
- avail = registry.get_available_jokers(run.profile)
38
+ # Description promises a Common joker — pre-3.3.3 the pool was the
39
+ # full unlocked set, so late-run players could roll Rare/Legendary
40
+ # off this tarot and mis-price it. Filter to Rarity.COMMON only.
41
+ avail = {
42
+ k: v
43
+ for k, v in registry.get_available_jokers(run.profile).items()
44
+ if getattr(v, "rarity", Rarity.COMMON) == Rarity.COMMON
45
+ }
38
46
  if not avail:
39
47
  run.last_tarot_message = "Le Jugement: no jokers available to grant."
40
48
  return
@@ -270,11 +270,13 @@ class BelAtroGame:
270
270
  if self.run.card_enhancements.pop("disable_next_boss", False):
271
271
  pass # boss stays None; deliberately skip the reveal animation
272
272
  else:
273
- import random
274
-
275
273
  from .run.boss import ALL_BOSS_MODIFIERS
276
274
 
277
- boss_cls = random.choice(ALL_BOSS_MODIFIERS)
275
+ # Use the run's seeded RNG, not the module-level random — same
276
+ # determinism fix the 3.2.0 release applied to shop generation
277
+ # and the three RNG-using tarots. Boss assignment was the last
278
+ # unseeded RNG site in the BelAtro round flow.
279
+ boss_cls = self.run._get_rng().choice(ALL_BOSS_MODIFIERS)
278
280
  boss = boss_cls()
279
281
  BelAtroAnnounce.boss_reveal(boss, self.reader)
280
282
 
@@ -757,6 +757,7 @@ def compute_trick_winners(
757
757
  state: GameState,
758
758
  trump: Suit | None,
759
759
  is_sans_atout: bool,
760
+ tricks: tuple[tuple[TrickCard, ...], ...] | list[tuple[TrickCard, ...]] | None = None,
760
761
  ) -> list[Seat | None]:
761
762
  """Resolve the winner of each completed trick, honoring La Rupture.
762
763
 
@@ -766,12 +767,18 @@ def compute_trick_winners(
766
767
  to live HUD but the final scoring path re-derived winners from raw
767
768
  `trick_winner_seat` — silently restoring the original winner and double-
768
769
  crediting the round.
770
+
771
+ When `tricks` is None (the default), resolves `state.completed_tricks`.
772
+ Callers building an in-flight trick list (live HUD CAPOT detection on the
773
+ 8th trick) may pass an explicit sequence so the same Rupture rule applies
774
+ to the projected final state.
769
775
  """
770
776
  se_trump = state.boss_modifiers.seven_eight_trump
771
777
  rupture = state.boss_modifiers.no_consecutive_team_wins
778
+ source = state.completed_tricks if tricks is None else tricks
772
779
  winners: list[Seat | None] = []
773
780
  prev_winner: Seat | None = None
774
- for trick in state.completed_tricks:
781
+ for trick in source:
775
782
  w = trick_winner_seat(trick, trump, se_trump, is_sans_atout)
776
783
  if (
777
784
  rupture
@@ -1052,21 +1059,27 @@ def _build_suit_idx(trump: Suit | None) -> dict[Suit, int]:
1052
1059
 
1053
1060
 
1054
1061
  # Pre-compute suit→position maps for every possible trump value (None + the
1055
- # four card suits). sort_hand is called frequently during rendering and this
1056
- # keeps the hot path branch-free.
1062
+ # four card suits + TOUT_ATOUT). sort_hand is called frequently during
1063
+ # rendering and this keeps the hot path branch-free.
1057
1064
  _SUIT_IDX_CACHE: Final[dict[Suit | None, dict[Suit, int]]] = {
1058
- trump: _build_suit_idx(trump) for trump in (None, *_SUITS_ORDER)
1065
+ trump: _build_suit_idx(trump) for trump in (None, Suit.TOUT_ATOUT, *_SUITS_ORDER)
1059
1066
  }
1060
1067
 
1061
1068
 
1062
1069
  def sort_hand(hand: tuple[Card, ...], trump: Suit | None) -> tuple[Card, ...]:
1063
1070
  """Sort hand by suit and rank (trump first, then others, honors together)."""
1064
1071
  suit_idx = _SUIT_IDX_CACHE.get(trump) or _build_suit_idx(trump)
1072
+ # Under Tout Atout every card is trump, so the trump-rank ladder applies
1073
+ # to *every* suit. `c.suit == trump` would always be False here because
1074
+ # Card.suit is one of SPADES/HEARTS/DIAMONDS/CLUBS — TOUT_ATOUT is a
1075
+ # contract-level marker, never a card suit.
1076
+ all_trump = trump is Suit.TOUT_ATOUT
1065
1077
 
1066
1078
  def sort_key(c: Card) -> tuple[int, int]:
1079
+ is_trump = all_trump or c.suit == trump
1067
1080
  return (
1068
1081
  suit_idx[c.suit],
1069
- _TRUMP_RANK_IDX[c.rank] if c.suit == trump else _NORMAL_RANK_IDX[c.rank],
1082
+ _TRUMP_RANK_IDX[c.rank] if is_trump else _NORMAL_RANK_IDX[c.rank],
1070
1083
  )
1071
1084
 
1072
1085
  return tuple(sorted(hand, key=sort_key))
@@ -397,7 +397,7 @@ def run_round(
397
397
  sys.stdout.write(f" Team EW: {ew_pts} points\r\n")
398
398
  if replay_decisions:
399
399
  from .replay import analyze_round, summarize
400
- reports = analyze_round(replay_decisions)
400
+ reports = analyze_round(replay_decisions, rng=current._rng)
401
401
  sys.stdout.write(f" Replay: {summarize(reports)}\r\n")
402
402
  sys.stdout.write(f"{'=' * 50}\r\n\r\n")
403
403
  sys.stdout.flush()
@@ -10,6 +10,7 @@ on the round summary screen.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import random
13
14
  from dataclasses import dataclass
14
15
 
15
16
  from .ai import AIPlayer, Difficulty
@@ -26,15 +27,23 @@ class DecisionReport:
26
27
 
27
28
 
28
29
  def analyze_round(
29
- decisions: list[tuple[GameState, Card]], seat: Seat = Seat.SOUTH
30
+ decisions: list[tuple[GameState, Card]],
31
+ seat: Seat = Seat.SOUTH,
32
+ rng: random.Random | None = None,
30
33
  ) -> list[DecisionReport]:
31
34
  """Replay the given decisions through the Hard AI for `seat` and return
32
35
  a per-decision report.
33
36
 
34
37
  Each tuple is the (state-just-before-the-decision, card-actually-played).
35
38
  The function is pure; it doesn't mutate any inputs.
39
+
40
+ Pass `rng` to make the report deterministic — the 3.3.1 fix threaded
41
+ seeded RNG into `AIPlayer.__init__` so personality jitter and easy-AI
42
+ fallbacks reproduce under a fixed run seed; without an explicit `rng`
43
+ here, "Optimal: 6/8" can flip to "5/8" between calls on the same data.
44
+ Default falls through to the legacy unseeded path.
36
45
  """
37
- ai = AIPlayer(seat, Difficulty.HARD)
46
+ ai = AIPlayer(seat, Difficulty.HARD, rng=rng)
38
47
  reports: list[DecisionReport] = []
39
48
  for state, chosen in decisions:
40
49
  # Decide_card requires the state's turn to be the seat. Skip otherwise.
@@ -18,7 +18,6 @@ from .game import (
18
18
  compute_trick_winners,
19
19
  reset_round_fields,
20
20
  team_of,
21
- trick_winner_seat,
22
21
  )
23
22
 
24
23
  # Rank numeric values for sequence detection (ascending order)
@@ -318,9 +317,12 @@ def resolve_declarations(
318
317
  def is_capot(state: GameState, tricks: list[tuple[TrickCard, ...]] | None = None) -> int | None:
319
318
  """Check if either team won all 8 tricks. Returns team index (0=NS, 1=EW) or None.
320
319
 
321
- Honors La Rupture (`no_consecutive_team_wins`) when reading the state's
322
- own tricks capot under La Rupture is effectively impossible, but the
323
- resolved winners are what the round was actually scored against.
320
+ Honors La Rupture (`no_consecutive_team_wins`) for both the default
321
+ (`state.completed_tricks`) and explicit-tricks branches. Capot under La
322
+ Rupture is effectively impossible; the live HUD CAPOT announcement on the
323
+ 8th trick (`gameflow.py` passes `tricks=completed + [current]`) must use
324
+ the same Rupture-aware resolution as the final scoring path or it will
325
+ falsely announce CAPOT mid-round.
324
326
  """
325
327
  is_sa = state.contract == "sans_atout"
326
328
  if tricks is None:
@@ -328,8 +330,7 @@ def is_capot(state: GameState, tricks: list[tuple[TrickCard, ...]] | None = None
328
330
  else:
329
331
  if not tricks or len(tricks) < 8:
330
332
  return None
331
- se_trump = state.boss_modifiers.seven_eight_trump
332
- winners = [trick_winner_seat(t, state.trump, se_trump, is_sa) for t in tricks]
333
+ winners = compute_trick_winners(state, state.trump, is_sa, tuple(tricks))
333
334
 
334
335
  if not winners or len(winners) < 8:
335
336
  return None
@@ -1796,3 +1796,101 @@ class TestShop:
1796
1796
  result = self.shop.buy_item(0)
1797
1797
  assert result is True
1798
1798
  assert self.shop.last_buy_failure is None
1799
+
1800
+
1801
+ # ===========================================================================
1802
+ # 3.3.3 F2 — Boss selection determinism
1803
+ # ===========================================================================
1804
+
1805
+
1806
+ class TestBossSelectionDeterminism:
1807
+ """F2: BelAtroGame._play_blind now picks the boss via run._get_rng()
1808
+ instead of the module-level random.choice. Same fix class as 3.2.0 for
1809
+ shop and tarots; boss was the last unseeded RNG site."""
1810
+
1811
+ def test_seeded_runs_pick_same_boss_class(self) -> None:
1812
+ from belote.belatro.core.run_state import BelAtroRun
1813
+ from belote.belatro.run.boss import ALL_BOSS_MODIFIERS
1814
+
1815
+ run1 = BelAtroRun(seed=42)
1816
+ run2 = BelAtroRun(seed=42)
1817
+ # The exact call _play_blind now performs.
1818
+ b1 = run1._get_rng().choice(ALL_BOSS_MODIFIERS)
1819
+ b2 = run2._get_rng().choice(ALL_BOSS_MODIFIERS)
1820
+ assert b1 is b2
1821
+
1822
+ def test_play_blind_does_not_use_module_random_for_boss(self) -> None:
1823
+ """Source-level guard: the pre-3.3.3 anti-pattern
1824
+ `random.choice(ALL_BOSS_MODIFIERS)` must not return. If someone
1825
+ reverts to module-level random, this fails before the determinism
1826
+ regression can ship.
1827
+ """
1828
+ import inspect
1829
+
1830
+ from belote.belatro.main import BelAtroGame
1831
+
1832
+ source = inspect.getsource(BelAtroGame)
1833
+ assert "random.choice(ALL_BOSS_MODIFIERS)" not in source, (
1834
+ "Boss selection must go through self.run._get_rng().choice() — "
1835
+ "see 3.3.3 F2."
1836
+ )
1837
+
1838
+
1839
+ # ===========================================================================
1840
+ # 3.3.3 F3 — LeJugement Common-only rarity
1841
+ # ===========================================================================
1842
+
1843
+
1844
+ class TestLeJugementRarity:
1845
+ """F3: LeJugement description promises a Common joker; pre-3.3.3 the
1846
+ pool was the full unlocked set so Rare/Legendary unlocks could roll
1847
+ off this tarot. Now filtered to Rarity.COMMON.
1848
+ """
1849
+
1850
+ def test_le_jugement_only_grants_common_jokers(self) -> None:
1851
+ from belote.belatro.core.run_state import BelAtroRun
1852
+ from belote.belatro.items.base import Rarity
1853
+ from belote.belatro.items.tarots import LeJugement
1854
+
1855
+ # Drive enough trials to cover the available pool many times over.
1856
+ # Each pass adds a joker → the slot guard fires after joker_slots
1857
+ # grants and short-circuits. Reset jokers between trials so each
1858
+ # call actually grants.
1859
+ for seed in range(20):
1860
+ run = BelAtroRun(seed=seed)
1861
+ run.jokers = [] # ensure slots aren't full
1862
+ LeJugement().use(run, None)
1863
+ if not run.jokers:
1864
+ # Pool was empty for this seed/profile — the tarot left
1865
+ # a message and returned. Acceptable.
1866
+ continue
1867
+ granted = run.jokers[-1]
1868
+ assert getattr(granted, "rarity", Rarity.COMMON) == Rarity.COMMON, (
1869
+ f"Le Jugement granted {granted.__class__.__name__} of "
1870
+ f"rarity {getattr(granted, 'rarity', None)} — expected Common"
1871
+ )
1872
+
1873
+ def test_le_jugement_empty_pool_sets_message(self) -> None:
1874
+ """If the Common pool is empty (e.g., all jokers are higher-rarity
1875
+ unlockables), LeJugement should fall through to the empty-pool path
1876
+ and set last_tarot_message rather than crash.
1877
+ """
1878
+ from belote.belatro.core.run_state import BelAtroRun
1879
+ from belote.belatro.items.tarots import LeJugement
1880
+
1881
+ run = BelAtroRun(seed=1)
1882
+ run.jokers = []
1883
+ # Monkey-patch the registry lookup to return an empty pool.
1884
+ import belote.belatro.items.tarots as tarot_mod
1885
+ from belote.belatro.items import registry as reg_mod
1886
+
1887
+ orig = reg_mod.registry.get_available_jokers
1888
+ reg_mod.registry.get_available_jokers = lambda profile: {} # type: ignore[assignment]
1889
+ try:
1890
+ LeJugement().use(run, None)
1891
+ assert run.last_tarot_message is not None
1892
+ assert "no jokers" in run.last_tarot_message.lower()
1893
+ assert run.jokers == []
1894
+ finally:
1895
+ reg_mod.registry.get_available_jokers = orig # type: ignore[assignment]
1896
+ _ = tarot_mod # silence unused-import lint
@@ -2,9 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from dataclasses import replace
6
+
5
7
  from belote.deck import Card, Rank, Suit
6
8
  from belote.game import BossModifiers, GameState, Phase, Seat, TrickCard
7
- from belote.scoring import score_round
9
+ from belote.scoring import is_capot, score_round
8
10
 
9
11
 
10
12
  def test_boss_no_belote():
@@ -135,6 +137,54 @@ def test_boss_invert_scoring():
135
137
  assert any("Malédiction" in m for m in breakdown.messages)
136
138
 
137
139
 
140
+ # ── La Rupture: is_capot must honor Rupture in explicit-tricks branch ─────
141
+
142
+
143
+ def test_is_capot_honors_rupture_in_explicit_tricks_branch() -> None:
144
+ """Live HUD CAPOT announcement (`gameflow.py` 8th-trick path) calls
145
+ `is_capot(state, tricks=completed + [current])`. Pre-3.3.2 that branch
146
+ re-derived winners with raw `trick_winner_seat`, ignoring La Rupture —
147
+ so a raw NS sweep falsely shouted CAPOT mid-round while the final score
148
+ correctly resolved as non-capot via `compute_trick_winners`. Lock the
149
+ fix: both branches of `is_capot` must agree under La Rupture.
150
+ """
151
+ # Eight tricks where the raw winner is SOUTH every time. South leads
152
+ # Spades (non-trump under trump=HEARTS); others follow with lower
153
+ # Spades. Cross-trick rank uniqueness doesn't matter for winner
154
+ # detection.
155
+ def south_wins(lead_rank: Rank) -> tuple[TrickCard, ...]:
156
+ return (
157
+ TrickCard(Seat.SOUTH, Card(Suit.SPADES, lead_rank)),
158
+ TrickCard(Seat.WEST, Card(Suit.SPADES, Rank.SEVEN)),
159
+ TrickCard(Seat.NORTH, Card(Suit.SPADES, Rank.EIGHT)),
160
+ TrickCard(Seat.EAST, Card(Suit.SPADES, Rank.NINE)),
161
+ )
162
+
163
+ high = [Rank.ACE, Rank.TEN, Rank.KING, Rank.QUEEN,
164
+ Rank.JACK, Rank.ACE, Rank.TEN, Rank.KING]
165
+ tricks = tuple(south_wins(r) for r in high)
166
+
167
+ rupture_state = GameState(
168
+ hands=((), (), (), ()),
169
+ trump=Suit.HEARTS,
170
+ taker=Seat.SOUTH,
171
+ phase=Phase.SCORING,
172
+ boss_modifiers=BossModifiers(no_consecutive_team_wins=True),
173
+ completed_tricks=tricks,
174
+ )
175
+
176
+ # Default branch (tricks=None): already honored Rupture pre-3.3.2.
177
+ assert is_capot(rupture_state) is None
178
+
179
+ # Explicit-tricks branch: must also honor Rupture (the 3.3.2 fix).
180
+ assert is_capot(rupture_state, tricks=list(tricks)) is None
181
+
182
+ # Sanity: without Rupture, both branches see the raw NS sweep.
183
+ no_rupture = replace(rupture_state, boss_modifiers=BossModifiers())
184
+ assert is_capot(no_rupture) == 0
185
+ assert is_capot(no_rupture, tricks=list(tricks)) == 0
186
+
187
+
138
188
  # ── Anti-pattern lock (3.1.0 modifier_patch shim removal) ──────────────────
139
189
 
140
190
 
@@ -59,3 +59,23 @@ def test_detect_synergies_empty_for_unrelated_pair() -> None:
59
59
  return
60
60
  out = detect_synergies([cls() for cls in unrelated])
61
61
  assert out == []
62
+
63
+
64
+ def test_detect_synergies_does_not_fire_for_solo_half() -> None:
65
+ """3.3.3 T3: a pair badge must NOT fire when only one half of the pair
66
+ is owned. Trip-wire for any future change to detect_synergies that
67
+ accidentally matches single jokers against pair entries.
68
+ """
69
+ for left_id, right_id in _SYNERGY_PAIRS:
70
+ # Confirm the right half is registered (the validate test above
71
+ # already pins this, but be defensive).
72
+ if right_id not in registry.jokers or left_id not in registry.jokers:
73
+ continue
74
+ left_cls = registry.jokers[left_id]
75
+ out = detect_synergies([left_cls()])
76
+ assert (left_id, right_id) not in out, (
77
+ f"Pair ({left_id}, {right_id}) fired for solo {left_id}"
78
+ )
79
+ assert (right_id, left_id) not in out, (
80
+ f"Pair ({right_id}, {left_id}) fired for solo {left_id}"
81
+ )
@@ -2,7 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import random
4
4
 
5
- from belote.game import GameState, Phase, Seat, new_game, start_round
5
+ from belote.deck import Card, Rank, Suit
6
+ from belote.game import GameState, Phase, Seat, new_game, sort_hand, start_round
6
7
  from belote.scoring import ScoringBreakdown, apply_round_score
7
8
 
8
9
 
@@ -79,3 +80,52 @@ def test_start_round_integrity() -> None:
79
80
  assert len(h) == 5
80
81
 
81
82
  assert state.phase == Phase.BIDDING
83
+
84
+
85
+ def test_sort_hand_uses_trump_ladder_under_tout_atout() -> None:
86
+ """3.3.3 F1: under Tout Atout every card should sort by the trump rank
87
+ ladder (J > 9 > A > 10 > K > Q > 8 > 7), not the non-trump ladder.
88
+
89
+ Pre-3.3.3 the predicate `c.suit == trump` was always false because
90
+ Card.suit is never Suit.TOUT_ATOUT (TA is a contract-level marker,
91
+ not a card suit), so all cards fell through to the non-trump ladder
92
+ and the South hand displayed in the wrong order under TA.
93
+ """
94
+ hand = (
95
+ Card(Suit.SPADES, Rank.SEVEN),
96
+ Card(Suit.SPADES, Rank.JACK),
97
+ Card(Suit.SPADES, Rank.NINE),
98
+ Card(Suit.SPADES, Rank.ACE),
99
+ )
100
+ sorted_ta = sort_hand(hand, Suit.TOUT_ATOUT)
101
+ ranks = [c.rank for c in sorted_ta]
102
+ assert ranks == [Rank.JACK, Rank.NINE, Rank.ACE, Rank.SEVEN], (
103
+ f"Tout Atout hand must use trump ladder; got {ranks}"
104
+ )
105
+
106
+ # Cross-suit sanity: with two suits, suits are still grouped (by the
107
+ # natural _SUITS_ORDER) and within each suit the trump ladder applies.
108
+ mixed = (
109
+ Card(Suit.HEARTS, Rank.SEVEN),
110
+ Card(Suit.SPADES, Rank.JACK),
111
+ Card(Suit.HEARTS, Rank.JACK),
112
+ Card(Suit.SPADES, Rank.SEVEN),
113
+ )
114
+ sorted_mixed = sort_hand(mixed, Suit.TOUT_ATOUT)
115
+ # Spades first (suit_idx[SPADES]=0), then hearts; within each: J before 7.
116
+ assert sorted_mixed == (
117
+ Card(Suit.SPADES, Rank.JACK),
118
+ Card(Suit.SPADES, Rank.SEVEN),
119
+ Card(Suit.HEARTS, Rank.JACK),
120
+ Card(Suit.HEARTS, Rank.SEVEN),
121
+ )
122
+
123
+ # Regression guard: normal-trump path unchanged. Hearts trump → hearts
124
+ # use trump ladder, spades use non-trump ladder.
125
+ sorted_hearts = sort_hand(mixed, Suit.HEARTS)
126
+ # Hearts comes first (now suit_idx[HEARTS]=0 because trump-shift), and
127
+ # within hearts: J before 7 (trump ladder). Spades after, non-trump
128
+ # ladder so JACK is *lower* than 7 in terms of rank-pts but `sort_hand`
129
+ # uses the rank-index list — JACK_idx=4, SEVEN_idx=7 → J before 7.
130
+ assert sorted_hearts[0] == Card(Suit.HEARTS, Rank.JACK)
131
+ assert sorted_hearts[1] == Card(Suit.HEARTS, Rank.SEVEN)