belote-cli 3.3.1__tar.gz → 3.3.2__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.2}/CHANGELOG.md +24 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/PKG-INFO +1 -1
- {belote_cli-3.3.1 → belote_cli-3.3.2}/pyproject.toml +1 -1
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/core/scoring.py +5 -1
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/game.py +8 -1
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/gameflow.py +1 -1
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/replay.py +11 -2
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/scoring.py +7 -6
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/belatro/test_boss_modifiers_integration.py +51 -1
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/test_replay.py +40 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/.claude/settings.local.json +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/.gitignore +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/.python-version +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/DEVELOPMENT.md +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/LICENSE +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/README.md +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/scripts/benchmark.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/__init__.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/__init__.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/a11y.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/achievements.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/ai.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/ansi.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/__init__.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/core/__init__.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/core/economy.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/core/run_state.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/engine/__init__.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/engine/event_bus.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/engine/modifier_patch.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/engine/round_driver.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/ghost_run.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/__init__.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/base.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/__init__.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/annonces.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/coinche.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/contract.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/corrupted.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/economy.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/planets.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/registry.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/tarots.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/items/vouchers.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/main.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/partner/__init__.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/partner/partner_state.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/partner/personality.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/partner/trust.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/progression/__init__.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/progression/save.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/progression/unlocks.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/run/__init__.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/run/ante.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/run/ante_themes.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/run/boss.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/run/decks.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/run/shop.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/run_summary.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/ui/__init__.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/ui/announce.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/ui/collection.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/ui/history.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/ui/hud.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/ui/menu.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/ui/rules.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/ui/shop.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/belatro/ui/trust_bar.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/config.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/context.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/deck.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/input.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/main.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/rules.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/stats.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/themes.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/ui/__init__.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/ui/announce.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/ui/layout.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/ui/menu.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/ui/prompts.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/src/belote/ui/render.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/__init__.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/belatro/__init__.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/belatro/test_belatro.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/belatro/test_collection_logic.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/belatro/test_contract_unlocks.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/belatro/test_dead_flag_fixes.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/belatro/test_deck_variants.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/belatro/test_ghost_run.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/belatro/test_history_overlay.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/belatro/test_hud_synergy.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/belatro/test_partner_trust.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/belatro/test_phase0_coverage.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/belatro/test_phase1_plumbing.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/belatro/test_phase2_content.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/belatro/test_phase3_meta.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/belatro/test_progression.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/belatro/test_round_driver.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/test_a11y.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/test_achievements.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/test_ai.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/test_ansi_helpers.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/test_belote.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/test_extended.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/test_game_logic.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/test_gameflow.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/test_layout.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/test_new_coverage.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/test_official_rules.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/test_properties.py +0 -0
- {belote_cli-3.3.1 → belote_cli-3.3.2}/tests/test_undo.py +0 -0
|
@@ -5,6 +5,30 @@ 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.2] - 2026-05-10
|
|
9
|
+
|
|
10
|
+
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.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`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`.
|
|
15
|
+
- **`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`.
|
|
16
|
+
- **`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.
|
|
17
|
+
|
|
18
|
+
### Internal
|
|
19
|
+
|
|
20
|
+
- **Tests**: 535 → 537 (+2 regressions for F1 and F2).
|
|
21
|
+
- **Strict gates**: pytest 537/537, mypy 0 errors (76 files), ruff 0 violations.
|
|
22
|
+
- **`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.
|
|
23
|
+
|
|
24
|
+
### Rejected — performance "wins" catalogued (so they aren't re-investigated)
|
|
25
|
+
|
|
26
|
+
- **"`_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.
|
|
27
|
+
- **"`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.
|
|
28
|
+
- **"`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.
|
|
29
|
+
- **"`stats.py` per-round full-rewrite is a regression"** — falsified. It's the B2 (3.3.1) fix for crash-safety, intentional.
|
|
30
|
+
- **"Event-bus `list(self._handlers)` per emit is wasteful"** — defensive copy enabling sub/unsub during emit. Handler counts are static and tiny.
|
|
31
|
+
|
|
8
32
|
## [3.3.1] - 2026-05-10
|
|
9
33
|
|
|
10
34
|
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.
|
|
@@ -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
|
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import random
|
|
6
|
+
|
|
5
7
|
from belote.deck import Card, Rank, Suit
|
|
6
8
|
from belote.game import GameState, Phase, Seat, TrickCard
|
|
7
9
|
from belote.replay import DecisionReport, analyze_round, summarize
|
|
@@ -46,3 +48,41 @@ def test_analyze_round_returns_one_report_per_south_decision() -> None:
|
|
|
46
48
|
assert len(out) == 1
|
|
47
49
|
assert out[0].chosen == only
|
|
48
50
|
assert out[0].matched is True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_analyze_round_deterministic_under_seeded_rng() -> None:
|
|
54
|
+
"""3.3.2: `analyze_round` must thread the caller's seeded RNG into the
|
|
55
|
+
Hard AI so the same decisions reproduce. Pre-3.3.2 the constructor
|
|
56
|
+
fell back to an unseeded `random.Random()`, so the SA fallback path
|
|
57
|
+
(`_hard_play` → `_easy_play` → `rng.choice(legal)`) returned a
|
|
58
|
+
different suggested card between runs on the same data.
|
|
59
|
+
"""
|
|
60
|
+
# Sans Atout state with multiple legal cards. `_hard_play` falls through
|
|
61
|
+
# to `_easy_play` under SA (trump is None) → `self._rng.choice(legal)`
|
|
62
|
+
# is the only thing picking the suggested card; an unseeded RNG makes
|
|
63
|
+
# the report non-reproducible.
|
|
64
|
+
hand = (
|
|
65
|
+
Card(Suit.HEARTS, Rank.SEVEN),
|
|
66
|
+
Card(Suit.SPADES, Rank.ACE),
|
|
67
|
+
Card(Suit.DIAMONDS, Rank.KING),
|
|
68
|
+
Card(Suit.CLUBS, Rank.JACK),
|
|
69
|
+
)
|
|
70
|
+
state = GameState(
|
|
71
|
+
hands=(hand, (), (), ()),
|
|
72
|
+
trump=None,
|
|
73
|
+
contract="sans_atout",
|
|
74
|
+
turn=Seat.SOUTH,
|
|
75
|
+
phase=Phase.PLAYING,
|
|
76
|
+
)
|
|
77
|
+
chosen = hand[0]
|
|
78
|
+
|
|
79
|
+
reports_a = analyze_round([(state, chosen)], rng=random.Random(42))
|
|
80
|
+
reports_b = analyze_round([(state, chosen)], rng=random.Random(42))
|
|
81
|
+
reports_c = analyze_round([(state, chosen)], rng=random.Random(42))
|
|
82
|
+
assert reports_a == reports_b == reports_c
|
|
83
|
+
|
|
84
|
+
# Different seed may or may not pick the same card — but each seed
|
|
85
|
+
# must be self-consistent.
|
|
86
|
+
reports_d = analyze_round([(state, chosen)], rng=random.Random(7))
|
|
87
|
+
reports_e = analyze_round([(state, chosen)], rng=random.Random(7))
|
|
88
|
+
assert reports_d == reports_e
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|