belote-cli 3.3.2__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.2 → belote_cli-3.3.3}/CHANGELOG.md +51 -0
  2. {belote_cli-3.3.2 → belote_cli-3.3.3}/DEVELOPMENT.md +5 -4
  3. {belote_cli-3.3.2 → belote_cli-3.3.3}/PKG-INFO +10 -1
  4. {belote_cli-3.3.2 → belote_cli-3.3.3}/README.md +9 -0
  5. {belote_cli-3.3.2 → belote_cli-3.3.3}/pyproject.toml +1 -1
  6. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/__init__.py +1 -1
  7. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/tarots.py +9 -1
  8. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/main.py +5 -3
  9. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/game.py +10 -4
  10. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_belatro.py +98 -0
  11. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_hud_synergy.py +20 -0
  12. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_game_logic.py +51 -1
  13. belote_cli-3.3.3/tests/test_properties.py +166 -0
  14. belote_cli-3.3.3/tests/test_replay.py +183 -0
  15. belote_cli-3.3.2/tests/test_properties.py +0 -63
  16. belote_cli-3.3.2/tests/test_replay.py +0 -88
  17. {belote_cli-3.3.2 → belote_cli-3.3.3}/.claude/settings.local.json +0 -0
  18. {belote_cli-3.3.2 → belote_cli-3.3.3}/.gitignore +0 -0
  19. {belote_cli-3.3.2 → belote_cli-3.3.3}/.python-version +0 -0
  20. {belote_cli-3.3.2 → belote_cli-3.3.3}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
  21. {belote_cli-3.3.2 → belote_cli-3.3.3}/LICENSE +0 -0
  22. {belote_cli-3.3.2 → belote_cli-3.3.3}/scripts/benchmark.py +0 -0
  23. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/__init__.py +0 -0
  24. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/a11y.py +0 -0
  25. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/achievements.py +0 -0
  26. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/ai.py +0 -0
  27. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/ansi.py +0 -0
  28. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/__init__.py +0 -0
  29. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/core/__init__.py +0 -0
  30. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/core/economy.py +0 -0
  31. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/core/run_state.py +0 -0
  32. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/core/scoring.py +0 -0
  33. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/engine/__init__.py +0 -0
  34. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/engine/event_bus.py +0 -0
  35. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/engine/modifier_patch.py +0 -0
  36. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/engine/round_driver.py +0 -0
  37. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ghost_run.py +0 -0
  38. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/__init__.py +0 -0
  39. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/base.py +0 -0
  40. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/__init__.py +0 -0
  41. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/annonces.py +0 -0
  42. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/coinche.py +0 -0
  43. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/contract.py +0 -0
  44. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  45. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/economy.py +0 -0
  46. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  47. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
  48. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  49. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  50. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  51. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  52. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/planets.py +0 -0
  53. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/registry.py +0 -0
  54. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/vouchers.py +0 -0
  55. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/partner/__init__.py +0 -0
  56. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/partner/partner_state.py +0 -0
  57. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/partner/personality.py +0 -0
  58. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/partner/trust.py +0 -0
  59. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/progression/__init__.py +0 -0
  60. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/progression/save.py +0 -0
  61. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/progression/unlocks.py +0 -0
  62. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/run/__init__.py +0 -0
  63. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/run/ante.py +0 -0
  64. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/run/ante_themes.py +0 -0
  65. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/run/boss.py +0 -0
  66. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/run/decks.py +0 -0
  67. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/run/shop.py +0 -0
  68. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/run_summary.py +0 -0
  69. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/__init__.py +0 -0
  70. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/announce.py +0 -0
  71. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/collection.py +0 -0
  72. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/history.py +0 -0
  73. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/hud.py +0 -0
  74. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/menu.py +0 -0
  75. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/rules.py +0 -0
  76. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/shop.py +0 -0
  77. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/trust_bar.py +0 -0
  78. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/config.py +0 -0
  79. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/context.py +0 -0
  80. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/deck.py +0 -0
  81. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/gameflow.py +0 -0
  82. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/input.py +0 -0
  83. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/main.py +0 -0
  84. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/replay.py +0 -0
  85. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/rules.py +0 -0
  86. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/scoring.py +0 -0
  87. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/stats.py +0 -0
  88. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/themes.py +0 -0
  89. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/ui/__init__.py +0 -0
  90. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/ui/announce.py +0 -0
  91. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/ui/layout.py +0 -0
  92. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/ui/menu.py +0 -0
  93. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/ui/prompts.py +0 -0
  94. {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/ui/render.py +0 -0
  95. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/__init__.py +0 -0
  96. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/__init__.py +0 -0
  97. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_boss_modifiers_integration.py +0 -0
  98. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_collection_logic.py +0 -0
  99. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_contract_unlocks.py +0 -0
  100. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_dead_flag_fixes.py +0 -0
  101. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_deck_variants.py +0 -0
  102. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_ghost_run.py +0 -0
  103. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_history_overlay.py +0 -0
  104. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_partner_trust.py +0 -0
  105. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_phase0_coverage.py +0 -0
  106. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_phase1_plumbing.py +0 -0
  107. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_phase2_content.py +0 -0
  108. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_phase3_meta.py +0 -0
  109. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_progression.py +0 -0
  110. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_round_driver.py +0 -0
  111. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_a11y.py +0 -0
  112. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_achievements.py +0 -0
  113. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_ai.py +0 -0
  114. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_ansi_helpers.py +0 -0
  115. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_belote.py +0 -0
  116. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_extended.py +0 -0
  117. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_gameflow.py +0 -0
  118. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_layout.py +0 -0
  119. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_new_coverage.py +0 -0
  120. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_official_rules.py +0 -0
  121. {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_undo.py +0 -0
@@ -5,6 +5,57 @@ 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
+
8
59
  ## [3.3.2] - 2026-05-10
9
60
 
10
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.
@@ -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.2
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.2"
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__"]
@@ -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
 
@@ -1059,21 +1059,27 @@ def _build_suit_idx(trump: Suit | None) -> dict[Suit, int]:
1059
1059
 
1060
1060
 
1061
1061
  # Pre-compute suit→position maps for every possible trump value (None + the
1062
- # four card suits). sort_hand is called frequently during rendering and this
1063
- # 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.
1064
1064
  _SUIT_IDX_CACHE: Final[dict[Suit | None, dict[Suit, int]]] = {
1065
- 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)
1066
1066
  }
1067
1067
 
1068
1068
 
1069
1069
  def sort_hand(hand: tuple[Card, ...], trump: Suit | None) -> tuple[Card, ...]:
1070
1070
  """Sort hand by suit and rank (trump first, then others, honors together)."""
1071
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
1072
1077
 
1073
1078
  def sort_key(c: Card) -> tuple[int, int]:
1079
+ is_trump = all_trump or c.suit == trump
1074
1080
  return (
1075
1081
  suit_idx[c.suit],
1076
- _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],
1077
1083
  )
1078
1084
 
1079
1085
  return tuple(sorted(hand, key=sort_key))
@@ -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
@@ -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)
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+
5
+ from belote.deck import Suit, card_points, deal, make_deck, shuffle
6
+ from belote.game import (
7
+ Phase,
8
+ legal_cards,
9
+ new_game,
10
+ place_bid,
11
+ play_card,
12
+ start_round,
13
+ )
14
+ from belote.scoring import score_round
15
+
16
+
17
+ def test_point_conservation_property() -> None:
18
+ """Total card points must always be 152 for any deal."""
19
+ deck = make_deck()
20
+ for _ in range(20): # 20 random deals
21
+ rng = random.Random()
22
+ shuffled = shuffle(deck, rng)
23
+ hands, up_card, remaining = deal(shuffled)
24
+
25
+ # Iterate over standard card-suit trumps only; TOUT_ATOUT scores every
26
+ # card as trump and intentionally breaks the 152 invariant.
27
+ for trump in up_card.suit.__class__:
28
+ if not trump.is_card_suit:
29
+ continue
30
+ total = card_points(up_card, trump)
31
+ for hand in hands:
32
+ total += sum(card_points(c, trump) for c in hand)
33
+ total += sum(card_points(c, trump) for c in remaining)
34
+ assert total == 152
35
+
36
+
37
+ def test_legal_moves_never_empty() -> None:
38
+ """In PLAYING phase, legal_cards() should never return an empty tuple if hand is not empty."""
39
+ for _ in range(5): # 5 full game simulations
40
+ rng = random.Random()
41
+ state = start_round(new_game(), rng)
42
+
43
+ # Mock taking the first suit to enter PLAYING phase
44
+ from belote.game import place_bid
45
+
46
+ state = place_bid(state, state.up_card.suit)
47
+
48
+ while state.phase == Phase.PLAYING:
49
+ seat = state.turn
50
+ hand = state.hand_of(seat)
51
+ # An empty hand mid-PLAYING is a deal/play bug; surface it loudly
52
+ # rather than silently bailing.
53
+ assert hand, (
54
+ f"Empty hand for {seat} mid-PLAYING at trick "
55
+ f"{len(state.completed_tricks)} — invariant violation"
56
+ )
57
+
58
+ legal = legal_cards(state, seat)
59
+ assert len(legal) > 0, (
60
+ f"No legal moves for {seat} with hand {hand} at trick {len(state.completed_tricks)}"
61
+ )
62
+
63
+ # Play a random legal card (use the seeded rng for determinism).
64
+ card = rng.choice(legal)
65
+ state = play_card(state, card)
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # 3.3.3 T1 — Post-round scoring invariants
70
+ #
71
+ # Drive seeded rounds to completion via the same legal_cards + play_card loop
72
+ # as test_legal_moves_never_empty, then assert post-round invariants. These
73
+ # are the kind of property tests that would have caught the L'Anarchie belote
74
+ # zero (3.3.1) and the La Rupture HUD divergence (3.3.1/3.3.2) years earlier
75
+ # had they existed at the time.
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def _drive_full_round(rng: random.Random, contract_bid: object) -> object:
80
+ """Play a complete round to terminal phase under the given first-bid.
81
+
82
+ `contract_bid` is whatever `place_bid` accepts: a card Suit (normal),
83
+ `Suit.TOUT_ATOUT`, or `"sans_atout"`. TA/SA are only legal in round 2,
84
+ so for those we pass 4× to step into round 2 first.
85
+ """
86
+ state = start_round(new_game(), rng)
87
+ is_round_2_only = contract_bid == Suit.TOUT_ATOUT or contract_bid == "sans_atout"
88
+ if is_round_2_only:
89
+ for _ in range(4):
90
+ state = place_bid(state, None)
91
+ state = place_bid(state, contract_bid)
92
+ while state.phase == Phase.PLAYING:
93
+ seat = state.turn
94
+ legal = legal_cards(state, seat)
95
+ assert legal, f"No legal moves for {seat} — deal/play bug"
96
+ card = rng.choice(legal)
97
+ state = play_card(state, card)
98
+ return state
99
+
100
+
101
+ def test_full_round_consumes_every_card_normal_contract() -> None:
102
+ """Invariant: after a full round under a normal contract, every hand is
103
+ empty and exactly 8 completed tricks are recorded. Trip-wire for any
104
+ bug that leaks a card or short-circuits a trick.
105
+ """
106
+ for seed in range(15):
107
+ rng = random.Random(seed)
108
+ state = _drive_full_round(rng, Suit.SPADES)
109
+ # Every hand should be drained.
110
+ for seat_idx, hand in enumerate(state.hands):
111
+ assert hand == (), f"seed={seed}: seat {seat_idx} still holds {hand}"
112
+ # 8 tricks complete.
113
+ assert len(state.completed_tricks) == 8, (
114
+ f"seed={seed}: expected 8 tricks, got {len(state.completed_tricks)}"
115
+ )
116
+
117
+
118
+ def test_score_round_sums_to_card_total_normal_contract() -> None:
119
+ """Invariant: for a successful (non-litige, non-zero-table) normal-suit
120
+ contract, table_taker_pts + table_defender_pts = 162 (152 card pts +
121
+ 10 dix de der). Boss zero-rank flags would lower this; with no boss
122
+ active and a normal contract, the sum is exact.
123
+ """
124
+ for seed in range(15):
125
+ rng = random.Random(seed)
126
+ state = _drive_full_round(rng, Suit.SPADES)
127
+ bd = score_round(state)
128
+ table_total = bd.table_taker_pts + bd.table_defender_pts
129
+ # 152 card pts + 10 dix de der = 162. The card-point conservation
130
+ # property (152) is already pinned by test_point_conservation_property;
131
+ # this extends it through scoring to include the last-trick bonus.
132
+ assert table_total == 162, (
133
+ f"seed={seed}: table_taker+defender = {table_total}, expected 162"
134
+ )
135
+
136
+
137
+ def test_score_round_sums_to_card_total_tout_atout() -> None:
138
+ """Invariant: Tout Atout deck = 248 card pts (every card on trump scale)
139
+ + 10 dix de der = 258. The 3.3.3 sort_hand TA fix (F1) is upstream of
140
+ this — it doesn't change scoring, but if the TA branch ever bled into
141
+ card_points the invariant would break and surface here.
142
+ """
143
+ for seed in range(15):
144
+ rng = random.Random(seed)
145
+ state = _drive_full_round(rng, Suit.TOUT_ATOUT)
146
+ bd = score_round(state)
147
+ table_total = bd.table_taker_pts + bd.table_defender_pts
148
+ assert table_total == 258, (
149
+ f"seed={seed}: TA table_taker+defender = {table_total}, expected 258"
150
+ )
151
+
152
+
153
+ def test_score_round_sums_to_card_total_sans_atout() -> None:
154
+ """Invariant: Sans Atout deck = 120 card pts (every card on non-trump
155
+ scale, 30 per suit × 4) + 10 dix de der = 130. Pin against the
156
+ L'Anarchie-style "scoring keys on rotated trump" class of bug.
157
+ (`config.TOTAL_POINTS_SANS_ATOUT` is the authoritative constant.)
158
+ """
159
+ for seed in range(15):
160
+ rng = random.Random(seed)
161
+ state = _drive_full_round(rng, "sans_atout")
162
+ bd = score_round(state)
163
+ table_total = bd.table_taker_pts + bd.table_defender_pts
164
+ assert table_total == 130, (
165
+ f"seed={seed}: SA table_taker+defender = {table_total}, expected 130"
166
+ )