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.
- {belote_cli-3.3.2 → belote_cli-3.3.3}/CHANGELOG.md +51 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/DEVELOPMENT.md +5 -4
- {belote_cli-3.3.2 → belote_cli-3.3.3}/PKG-INFO +10 -1
- {belote_cli-3.3.2 → belote_cli-3.3.3}/README.md +9 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/pyproject.toml +1 -1
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/__init__.py +1 -1
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/tarots.py +9 -1
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/main.py +5 -3
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/game.py +10 -4
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_belatro.py +98 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_hud_synergy.py +20 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_game_logic.py +51 -1
- belote_cli-3.3.3/tests/test_properties.py +166 -0
- belote_cli-3.3.3/tests/test_replay.py +183 -0
- belote_cli-3.3.2/tests/test_properties.py +0 -63
- belote_cli-3.3.2/tests/test_replay.py +0 -88
- {belote_cli-3.3.2 → belote_cli-3.3.3}/.claude/settings.local.json +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/.gitignore +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/.python-version +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/LICENSE +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/scripts/benchmark.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/__init__.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/a11y.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/achievements.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/ai.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/ansi.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/__init__.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/core/__init__.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/core/economy.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/core/run_state.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/core/scoring.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/engine/__init__.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/engine/event_bus.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/engine/modifier_patch.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/engine/round_driver.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ghost_run.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/__init__.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/base.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/__init__.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/annonces.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/coinche.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/contract.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/corrupted.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/economy.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/planets.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/registry.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/items/vouchers.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/partner/__init__.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/partner/partner_state.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/partner/personality.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/partner/trust.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/progression/__init__.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/progression/save.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/progression/unlocks.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/run/__init__.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/run/ante.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/run/ante_themes.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/run/boss.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/run/decks.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/run/shop.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/run_summary.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/__init__.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/announce.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/collection.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/history.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/hud.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/menu.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/rules.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/shop.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/belatro/ui/trust_bar.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/config.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/context.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/deck.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/gameflow.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/input.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/main.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/replay.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/rules.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/scoring.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/stats.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/themes.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/ui/__init__.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/ui/announce.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/ui/layout.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/ui/menu.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/ui/prompts.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/src/belote/ui/render.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/__init__.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/__init__.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_boss_modifiers_integration.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_collection_logic.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_contract_unlocks.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_dead_flag_fixes.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_deck_variants.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_ghost_run.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_history_overlay.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_partner_trust.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_phase0_coverage.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_phase1_plumbing.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_phase2_content.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_phase3_meta.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_progression.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/belatro/test_round_driver.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_a11y.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_achievements.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_ai.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_ansi_helpers.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_belote.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_extended.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_gameflow.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_layout.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_new_coverage.py +0 -0
- {belote_cli-3.3.2 → belote_cli-3.3.3}/tests/test_official_rules.py +0 -0
- {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 (
|
|
87
|
+
# Full test suite (549 tests expected)
|
|
88
88
|
PYTHONPATH=src pytest
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
Current baseline (3.3.
|
|
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**:
|
|
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.
|
|
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.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
)
|