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