belote-cli 3.0.2__tar.gz → 3.3.1__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.0.2 → belote_cli-3.3.1}/.claude/settings.local.json +2 -1
- {belote_cli-3.0.2 → belote_cli-3.3.1}/CHANGELOG.md +171 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/DEVELOPMENT.md +3 -3
- {belote_cli-3.0.2 → belote_cli-3.3.1}/PKG-INFO +46 -6
- {belote_cli-3.0.2 → belote_cli-3.3.1}/README.md +45 -5
- {belote_cli-3.0.2 → belote_cli-3.3.1}/pyproject.toml +1 -1
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/__init__.py +1 -1
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/ai.py +66 -14
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/core/run_state.py +36 -2
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/core/scoring.py +12 -12
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/engine/modifier_patch.py +21 -22
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/engine/round_driver.py +33 -4
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/base.py +23 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/jokers/hand_comp.py +7 -9
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/jokers/trick_timing.py +7 -2
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/registry.py +20 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/tarots.py +21 -9
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/main.py +158 -3
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/partner/personality.py +9 -5
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/progression/unlocks.py +26 -5
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/run/boss.py +23 -23
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/run/shop.py +55 -20
- belote_cli-3.3.1/src/belote/belatro/ui/history.py +192 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ui/rules.py +1 -1
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ui/shop.py +90 -3
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ui/trust_bar.py +9 -1
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/game.py +80 -29
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/input.py +2 -2
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/replay.py +3 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/scoring.py +83 -59
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/stats.py +10 -5
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/ui/layout.py +3 -1
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/ui/prompts.py +21 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/ui/render.py +14 -1
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_belatro.py +100 -4
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_boss_modifiers_integration.py +35 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_dead_flag_fixes.py +41 -5
- belote_cli-3.3.1/tests/belatro/test_history_overlay.py +222 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_phase1_plumbing.py +106 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_phase2_content.py +104 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_phase3_meta.py +28 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_round_driver.py +6 -4
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_belote.py +24 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_official_rules.py +34 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/.gitignore +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/.python-version +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/LICENSE +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/scripts/benchmark.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/a11y.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/achievements.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/ansi.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/core/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/core/economy.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/engine/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/engine/event_bus.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ghost_run.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/jokers/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/jokers/annonces.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/jokers/coinche.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/jokers/contract.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/jokers/corrupted.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/jokers/economy.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/planets.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/items/vouchers.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/partner/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/partner/partner_state.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/partner/trust.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/progression/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/progression/save.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/run/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/run/ante.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/run/ante_themes.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/run/decks.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/run_summary.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ui/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ui/announce.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ui/collection.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ui/hud.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/belatro/ui/menu.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/config.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/context.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/deck.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/gameflow.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/main.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/rules.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/themes.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/ui/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/ui/announce.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/src/belote/ui/menu.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_collection_logic.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_contract_unlocks.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_deck_variants.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_ghost_run.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_hud_synergy.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_partner_trust.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_phase0_coverage.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/belatro/test_progression.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_a11y.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_achievements.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_ai.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_ansi_helpers.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_extended.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_game_logic.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_gameflow.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_layout.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_new_coverage.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_properties.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_replay.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.3.1}/tests/test_undo.py +0 -0
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"Bash(python3 -m pytest tests/ -x -q)",
|
|
12
12
|
"Bash(PYTHONPATH=src python3 *)",
|
|
13
13
|
"Bash(.venv/bin/python -m mypy src/)",
|
|
14
|
-
"Bash(PYTHONPATH=src python -m pytest --tb=short -q)"
|
|
14
|
+
"Bash(PYTHONPATH=src python -m pytest --tb=short -q)",
|
|
15
|
+
"Bash(python3 *)"
|
|
15
16
|
]
|
|
16
17
|
}
|
|
17
18
|
}
|
|
@@ -5,6 +5,177 @@ 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.1] - 2026-05-10
|
|
9
|
+
|
|
10
|
+
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.
|
|
11
|
+
|
|
12
|
+
### Fixed — audit findings
|
|
13
|
+
|
|
14
|
+
- **`src/belote/scoring.py::trick_card_points` (B1)** — `ban_clubs` zero rule now matches `_calculate_base_points`: the whole trick zeros when *any* card is a club, not just when the lead is. Pre-3.3.1 the live HUD running total diverged from the final round score whenever a non-lead card was a club under the `LesClubsBannis` boss.
|
|
15
|
+
- **`src/belote/stats.py::StatisticsManager.update_stats_round` (B2)** — Now calls `flush_stats()` after every round, not just at end-of-game. A crash between rounds no longer silently loses round-level stats and achievement unlocks.
|
|
16
|
+
- **`src/belote/belatro/items/base.py::fuse_jokers` (B7)** — Fused joker now carries over the better edition of the two inputs (POLY > HOLO > FOIL > NONE; NEGATIVE collapses to NONE since its slot bonus was already granted at purchase) and inherits `is_corrupted` if either input was corrupted. Pre-3.3.1 `type(a)()` returned a class-default instance, silently erasing any Foil/Holo/Polychrome the player had paid for.
|
|
17
|
+
- **`src/belote/belatro/ui/rules.py` (B8)** — Reroll cost doc text now reads `$5` to match `Shop.reroll_cost = 5` in code.
|
|
18
|
+
- **`src/belote/belatro/engine/modifier_patch.py::patch` (B9)** — Replaced `assert not attr.startswith("_")` with an explicit `if … raise ValueError(...)`. The `assert` was strippable with `python -O`.
|
|
19
|
+
- **`src/belote/belatro/progression/unlocks.py` + `src/belote/belatro/main.py::BelAtroGame._drain_unlock_announcements` (B10)** — Unlock notifications no longer `print()` raw to stdout (which scrolled and corrupted the alt-screen). Notices are queued on `UnlockTracker.pending_announcements` and drained by the host loop through `BelAtroAnnounce.banner`.
|
|
20
|
+
- **`src/belote/belatro/run/ante_themes.py` + `src/belote/belatro/main.py::_play_blind` + `src/belote/belatro/core/run_state.py::target_score` (B14)** — The Phase 3.1 ante-themes module is now wired into the live game loop: `roll_theme(rng_value)` fires at the start of each ante (`blind_index == 0`) using the run's seeded RNG, `target_score` applies the theme's `target_multiplier(blind_index)`, and `on_blind_won` runs after each successful blind. Tests already covered the module in isolation; production code never invoked it.
|
|
21
|
+
- **`src/belote/belatro/ui/trust_bar.py` (B16)** — Three-tier color: ≤3 red, 4–6 gold (neutral), ≥7 green. Default trust value 5 used to render red under the old `> 5` threshold, falsely signalling distrust at the start of every run.
|
|
22
|
+
- **`src/belote/belatro/partner/personality.py::should_coinche` (B17 + wiring gap)** — Signature now takes a `Random` parameter; `LeFlambeur` consumes the round-driver's seeded RNG instead of the bare module-level `random.random()`. The round driver (`engine/round_driver.py:215-235`) also now calls `partner.personality.should_coinche(state, rng)` when the human player declines a coinche on an EW taker, giving the AI partner a chance to act on its own initiative (gated by `partner.trust.ai_degraded`). Pre-3.3.1 `should_coinche` had no production caller at all.
|
|
23
|
+
|
|
24
|
+
### Fixed — independent bug-hunt pass (not in original audit)
|
|
25
|
+
|
|
26
|
+
- **`src/belote/scoring.py::compute_trick_winners` (new helper in `game.py`) — La Rupture scoring divergence** — `play_card` reassigned the trick winner for the live HUD whenever `no_consecutive_team_wins` (La Rupture boss) would flip the win, but `score_round`, `_calculate_base_points`, `_apply_scoring_modifiers`, and `is_capot` all re-derived winners via raw `trick_winner_seat` calls — silently restoring the un-flipped winner and producing impossible capots / double-credited rounds. A new `compute_trick_winners(state, trump, is_sa)` helper in `game.py` carries the Rupture rule once and is used by every scoring path. Live HUD and final score now agree under La Rupture.
|
|
27
|
+
- **`src/belote/game.py::GameState.belote_announcer` + `src/belote/scoring.py::score_round` — L'Anarchie + Belote/Rebelote** — Under L'Anarchie (dynamic trump), `state.trump` rotates mid-round. Scoring's `belote_holders.get(state.trump)` lookup then keyed on the *post-rotation* trump and missed any Belote announced on the original trump, silently zeroing the 20/40 bonus. New `belote_announcer: Seat | None` field on `GameState` captures the announcing seat at the moment `belote_tracker[0]` flips True; scoring reads it directly instead of going through the rotated-trump lookup.
|
|
28
|
+
- **`src/belote/scoring.py::score_round` chute branch — `no_dix_de_der` ignored on chute** — The chute formula at line ~774 unconditionally added `LAST_TRICK_BONUS` (+10) to the defender total, even when the `Le Zéro Final` boss was active. The in-round path at line ~606 already gated the bonus on `no_dix_de_der`; the chute branch is now gated symmetrically.
|
|
29
|
+
- **`src/belote/scoring.py::_apply_scoring_modifiers` — La Compétition (`separate_scoring`) parity** — Two parallel bugs to B1 / the chute fix above: (a) the separate-scoring branch zeroed only the *lead-clubs* trick under `ban_clubs` (same divergence we fixed in `trick_card_points`); (b) it unconditionally added +10 de der to the individual last-trick winner, ignoring `no_dix_de_der`. Both now mirror the main scoring path.
|
|
30
|
+
- **`src/belote/ai.py::AIPlayer.__init__` + `src/belote/belatro/engine/round_driver.py` — AI RNG was unseeded** — `AIPlayer.__init__` constructed `random.Random()` (no seed) regardless of the round's seed. Easy-AI random plays, personality jitter, and any other stochastic AI decision randomised per process even at a fixed run seed — silently breaking ghost-run reproducibility, replay determinism, and seeded benchmarks. The round driver now passes its seeded `rng` into every AIPlayer it constructs; the constructor accepts an optional `rng` arg with the old unseeded `Random()` as fallback for legacy callers.
|
|
31
|
+
- **`src/belote/ai.py::AIPlayer.update_memory` — stale void inference across undo** — `known_voids` and `processed_tricks_count` were monotonic; a mid-round undo (which reverts `state` from `gameflow.history`) left voids inferred from now-rolled-back tricks in place, causing the AI to misplay based on cards that no longer existed in the game. `update_memory` now detects regression (current `(completed_count, current_trick_len)` strictly less than `last_voids_key`) and rebuilds the inference set from the live state.
|
|
32
|
+
- **`src/belote/ai.py::_hard_play` — first-legal-card under Sans Atout** — `_hard_play` bailed to `return legal[0]` when `state.trump is None` (the legitimate SA contract), making hard AI strictly worse than medium under SA — `_medium_play` falls through to `_easy_play` (uniform random) in the same case. Hard now does the same; the deterministic-worst-case path is gone.
|
|
33
|
+
|
|
34
|
+
### Internal
|
|
35
|
+
|
|
36
|
+
- **Tests**: 535 / 535 still passing.
|
|
37
|
+
- **Strict gates**: mypy 0 errors (75 files), ruff 0 violations.
|
|
38
|
+
- **One new GameState field**: `belote_announcer: Seat | None = None`, threaded through `play_card` and `reset_round_fields`. Default-None matches pre-3.3.1 serialisation for the legacy non-Anarchie path.
|
|
39
|
+
- **One new public helper**: `belote.game.compute_trick_winners(state, trump, is_sans_atout) -> list[Seat | None]` — the single source of truth for La Rupture-aware winner resolution. `play_card`'s own Rupture branch is retained for the live HUD; the helper is what scoring now uses.
|
|
40
|
+
|
|
41
|
+
## [3.3.0] - 2026-05-10
|
|
42
|
+
|
|
43
|
+
BelAtro history overlay release — the [H] key in BelAtro mode now opens a populated, run-aware overlay instead of always showing "No rounds completed yet." Classic Belote's H-key path is unchanged. 535 tests passing (up from 528), ruff and mypy strict still clean.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- **`src/belote/belatro/ui/history.py` (new) + `src/belote/belatro/core/run_state.py::BelAtroRun.history` + `src/belote/belatro/main.py::BelAtroGame._record_history_entry`** — Pressing **H** in BelAtro now shows a per-blind ledger (ante, blind label, target, boss, taker, contract, score, status, money Δ, declarations) instead of an empty "No rounds completed yet." screen. Root cause: the classic [H] overlay (`belote.ui.prompts.show_history`) reads `state.score_history`, but the BelAtro round driver (`belatro.engine.round_driver.drive_round`) never invokes `apply_round_score` — the sole writer of `score_history` — and the existing BelAtro `on_round_end` callback was just `pass`. Fix: `BelAtroRun` now carries a parallel `history: list[BelAtroHistoryEntry]`, populated after each round in `_play_blind` from the score breakdown + run snapshot, and rendered by the new `show_belatro_history` overlay (wide table on ≥90-col terminals, three-line-per-row compact layout below).
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- **`src/belote/ui/prompts.py::set_history_override`** — Module-level hook the BelAtro launcher installs in `BelAtroGame.start` (closure over `self.run.history`) and clears in its `finally` block. `show_history` short-circuits to the override when set, otherwise falls through to the classic `state.score_history` renderer. This is the seam that lets BelAtro own its overlay without forking `prompt_card` or threading a renderer through every UI call site.
|
|
52
|
+
- **`tests/belatro/test_history_overlay.py`** — 7 new tests covering `BelAtroRun.history` default-empty, the four status branches (WON / FAILED / CAPOT / SURVIVED), and the override hook's routing + cleanup contract. An autouse fixture clears `_history_override` between tests so leaks across the test session are impossible.
|
|
53
|
+
|
|
54
|
+
### Internal
|
|
55
|
+
|
|
56
|
+
- **Tests**: 528 → 535 (+7).
|
|
57
|
+
- **Strict gates**: pytest 535/535, mypy 0 errors, ruff 0 violations across `src/` and `tests/`.
|
|
58
|
+
|
|
59
|
+
## [3.2.0] - 2026-05-10
|
|
60
|
+
|
|
61
|
+
Two-audit reconciliation release — the prioritized fix list distilled from Qwen 3.6 27B + Ring 1T audits (~30 raw claims, ~half held up under verification). Twelve real bugs fixed across joker logic, registry hygiene, RNG determinism, and UI offsets; one new finding (Tarot RNG was also unseeded) caught by the fresh-hunt pass. Eleven audit claims rejected as false positives are catalogued in the plan file so they aren't re-investigated. 528 tests passing (up from 525), ruff and mypy strict still clean.
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- **`src/belote/belatro/items/jokers/hand_comp.py::LaSentinelle`** — Detection of the trump Jack now keys on the NS *team* via `team_of(seat) == 0` instead of `seat == Seat.SOUTH`. Pre-3.2 the joker was silently no-op when North (the partner) was dealt the trump Jack, even though Belote's "you" is team-level. Trick-win detection follows the same team rule. Regressions: `tests/belatro/test_dead_flag_fixes.py::test_la_sentinelle_arms_when_partner_plays_trump_jack`, `test_la_sentinelle_does_not_arm_for_opponent_jack`.
|
|
66
|
+
- **`src/belote/belatro/items/jokers/trick_timing.py::LeDernierMot`** — Dix de Der replacement now fires whenever the NS team wins the last trick (`team_of(event.winner) == 0`), not only when South personally takes it. Pre-3.2 the joker silently did nothing when partner won the closing trick. Regressions: `tests/belatro/test_belatro.py::TestLeDernierMot::test_north_last_trick_returns_result`, `test_east_last_trick_returns_none`.
|
|
67
|
+
- **`src/belote/belatro/items/jokers/corrupted.py::LEgoiste` → `src/belote/belatro/core/scoring.py::ScoreAccumulator.get_total`** — Final chip total is now `max(0, state._chips)`. L'Égoïste subtracts `event.card_points` for every partner-won trick; with enough partner wins the running total could cross zero, producing a negative final round score. Clamping at the scoring boundary preserves the intermediate accounting log while guaranteeing the visible score is never negative.
|
|
68
|
+
- **`src/belote/belatro/engine/round_driver.py:236-249`** — NS-taker `auto_coinche` path now re-emits `BidMadeEvent` with the new `coinche_level` so jokers/HUD subscribed to `on_bid` see the bump. The EW-taker branch above always emitted; this NS-side branch silently set `coinche_level = 1` without notifying subscribers.
|
|
69
|
+
- **`src/belote/belatro/core/run_state.py::BelAtroRun.advance_blind`** — Victory now sets both `run_won = True` and `run_over = True`, so downstream callers can rely on `run_over` alone as the terminal-state signal. `enter_endless()` resets both, re-opening the run for endless mode. Pre-3.2 the main loop only terminated via a `break` after a `run_won` check — semantically correct but fragile under refactors.
|
|
70
|
+
- **`src/belote/belatro/items/registry.py::ItemRegistry.register_*`** — All four register methods (`joker` / `planet` / `tarot` / `voucher`) now assert that an existing entry under the same `id` is the *same class*. Pre-3.2 a typo'd duplicate ID would silently overwrite the prior class, and the override would never surface until the original behaviour visibly broke. Idempotent re-registration of the same class still works for the test-suite swap pattern.
|
|
71
|
+
- **`src/belote/belatro/engine/modifier_patch.py`** — `boss_fields` is now derived from `BossModifiers`' dataclass fields via `dataclasses.fields(BossModifiers)` instead of a hardcoded set. Pre-3.2 a new boss flag added to `BossModifiers` would be silently no-op'd until someone remembered to add it to the hardcoded allowlist in lock-step.
|
|
72
|
+
|
|
73
|
+
### Determinism
|
|
74
|
+
|
|
75
|
+
- **`src/belote/belatro/run/shop.py::Shop.generate_inventory`** — All RNG calls (`random.random` / `random.choice` / `random.sample` across edition rolls, joker pick, tarot/planet pick, voucher pick) now use `self.run._get_rng()` instead of the module-level `random`. Pre-3.2 shop contents were non-deterministic even with a seeded run, which broke ghost-run reproducibility. `Shop._roll_edition` signature changed to accept an explicit `rng` argument; the `test_shop_edition_weights_match_distribution` test was updated to pass the seeded RNG directly instead of monkey-patching `shop_mod.random.random`.
|
|
76
|
+
- **`src/belote/belatro/items/tarots.py`** — `LeJugement`, `LaPretresse`, and `LeFou` all now draw from `run._get_rng()` instead of the module-level `random`. Module-level `import random` removed.
|
|
77
|
+
|
|
78
|
+
### Improved
|
|
79
|
+
|
|
80
|
+
- **`LaPretresse` planet picks now deduplicate** — switched from two independent `random.choice(planets)` calls to `rng.sample(planets, k=2)`, so the tarot can no longer pick the same planet twice. Falls back to a single pick when the planet pool has fewer than 2 entries.
|
|
81
|
+
- **`LeJugement` slot-full notification** — new `BelAtroRun.last_tarot_message: str | None` field carries a non-fatal failure reason ("joker slots are full — no joker granted") when the tarot can't complete. Pre-3.2 the joker was silently dropped with no UI signal. Cleared whenever a tarot is used.
|
|
82
|
+
- **`src/belote/ui/render.py::patch_trick_card`** — Now reads `_last_rendered_unpadded_h` (set by `render()`) and threads it into `_calculate_base_row`, so single-card patches re-apply the same vertical-centering offset `render()` used. Pre-3.2 it passed the "I don't know" sentinel (0) and skipped the offset entirely, drawing cards too high on tall terminals (>40 rows).
|
|
83
|
+
- **`src/belote/ui/layout.py`** — `hud_style` docstring corrected. Pre-3.2 it claimed `"verbose" / "standard" / "compact"`, but no preset used `"standard"` and no consumer recognized it — only `"verbose"` and `"compact"` are real.
|
|
84
|
+
|
|
85
|
+
### Rejected (catalogued so they aren't re-investigated)
|
|
86
|
+
|
|
87
|
+
Eleven claims from the input audits were rejected after verification against the actual code:
|
|
88
|
+
|
|
89
|
+
- LaBalance voucher (`tie_breaks_for_taker`) and LaCompetition (`separate_scoring`) flags — **both consumed** in `src/belote/scoring.py` and `src/belote/belatro/main.py`. Qwen flagged both as P0 dead-flag bugs; verification falsified both.
|
|
90
|
+
- LeFou tarot "chain broken" — `run_state.py::consume` sets `last_consumable_id` *before* `item.use()` runs, so chaining works as intended.
|
|
91
|
+
- `no_belote_rebelote` deck-mod flag — consumed at `src/belote/scoring.py:630`.
|
|
92
|
+
- `_pending_tierce_charge` cross-round leak — each blind constructs a fresh `ScoreAccumulator` (main.py:126) and `drive_round` builds a fresh `GameState` via `new_game()` (round_driver.py:84), so `_joker_state` is empty at every round start. No cross-round persistence path exists.
|
|
93
|
+
- `fuse_jokers` "loses `on_purchase` effects" — `on_purchase` mutates `run` state (which survives fusion); re-applying on the fused instance would *double-apply* cumulative effects (LeDemon's trust drop). Pre-3.2 behaviour is correct.
|
|
94
|
+
- IllegalMoveError in `round_driver.py:291` — reachable only via test MockCallbacks; production `prompt_card` has a guard.
|
|
95
|
+
- `_card_beats` defensive `assert trump is not None` — unreachable under current contract invariants.
|
|
96
|
+
- `display_hud` no clear-to-EOL — HUD is rebuilt fresh per call; the claim was wrong.
|
|
97
|
+
- Libra planet description — "×4 instead of ×3" matches the payout; mechanism is additive per coinche level but the description references the result.
|
|
98
|
+
- `get_total()` float precision — explicit `int()` guard at scoring.py:248-249.
|
|
99
|
+
- KeyboardInterrupt save — profile is saved *before* the loop starts; only intra-run delta is lost.
|
|
100
|
+
|
|
101
|
+
### Internal
|
|
102
|
+
|
|
103
|
+
- **Tests**: 525 → 528 (+3 net: −1 test renamed/repurposed for LeDernierMot team check, +2 new for La Sentinelle partner-detection and EW opponent rejection).
|
|
104
|
+
- **Strict gates**: pytest 528/528, mypy 0 errors, ruff 0 violations across `src/` and `tests/`.
|
|
105
|
+
- **Audit plan**: `~/.claude/plans/between-these-two-plans-graceful-puppy.md` — captures the two source audits, the verification pass that filtered them, the implementation order, and the catalogue of rejected claims.
|
|
106
|
+
|
|
107
|
+
## [3.1.0] - 2026-05-08
|
|
108
|
+
|
|
109
|
+
Audit-action release — implements the prioritized fix list from 3.0.3. One real correctness bug fixed, one unreachable feature wired up, one money-leak path closed, three measurable perf wins, and the long-standing `modifier_patch` underscore shim retired. 525 tests passing (up from 510), ruff and mypy strict still clean across 75 source files.
|
|
110
|
+
|
|
111
|
+
### Fixed
|
|
112
|
+
|
|
113
|
+
- **`src/belote/game.py:843-855` (HUD multi-boss running total)** — Under `Les Clubs Bannis + Le Roi Mort` (or any combo of `ban_clubs` with a rank-zero boss), the live HUD running total in `play_card` over-credited a clubs-led trick: the `ban_clubs → trick_pts = 0` branch was immediately overwritten by the rank-zero recompute. The eventual round score was already correct (different code path through `scoring.py`). Now `play_card` delegates to `scoring.trick_card_points`, the canonical helper that composes every boss zero-rank flag, `ban_clubs`, and the SE-trump scale in a single pass — the HUD cannot drift from the round score under any boss combo. Regression: `tests/test_official_rules.py::test_hud_running_total_under_multi_boss_ban_clubs_plus_kings_zero`.
|
|
114
|
+
- **`src/belote/belatro/run/shop.py::buy_item` (consumable money-leak)** — Slot-capacity check is now hoisted *above* `Economy.spend_money`. Pre-3.1.0 the player's money was charged for a Tarot/Planet purchase even when consumable slots were full, and the item was silently dropped. New `Shop.last_buy_failure: str | None` carries the reason ("slots_full" / "no_money") so the shop UI surfaces a `BelAtroAnnounce.banner("Slots full — sell first")` banner. Regressions: `tests/belatro/test_belatro.py::TestShop::test_buy_consumable_with_full_slots_does_not_charge_money`, `test_buy_joker_with_full_slots_does_not_charge_money`, `test_buy_item_no_money_records_no_money_failure`.
|
|
115
|
+
|
|
116
|
+
### Added
|
|
117
|
+
|
|
118
|
+
- **TierceForge UI integration** (`src/belote/belatro/ui/shop.py`) — The `TierceForge` voucher shipped in 3.0.0 with a working `forge_tierce(run, planet_id)` backend (`src/belote/belatro/items/vouchers.py:129`) but no UI caller; the feature was unreachable. The shop now shows a "Forge ×N/3" tile when the voucher is owned, opens a numbered planet picker on Enter, and surfaces a confirmation banner on success. Regressions: `tests/belatro/test_phase2_content.py::test_forge_tierce_voucher_spends_charges_and_levels_planet`, `test_forge_tierce_blocked_when_charges_below_three`.
|
|
119
|
+
- **Block-policy regressions for Tarot overflow** — `LeJugement` and `LaPretresse` are now pinned to no-op when joker/consumable slots are at capacity (rather than partial-grant). Tests: `test_le_jugement_no_op_when_joker_slots_full`, `test_la_pretresse_no_op_when_consumable_slots_full`.
|
|
120
|
+
- **`tests/belatro/test_phase1_plumbing.py::test_joker_state_only_contains_scalar_values`** — Walks every registered joker through `on_round_start` + four event hooks and asserts no mutable container leaks into `_joker_state`. Locks the contract that lets the per-event copy stay shallow (3.1.0 dropped the deepcopy).
|
|
121
|
+
- **`tests/belatro/test_phase1_plumbing.py::test_shop_edition_weights_match_distribution`** — 10 000-roll empirical check on `Shop._roll_edition()`, ±1% per bucket. Catches accidental edits to the `_EDITION_WEIGHTS` table.
|
|
122
|
+
- **`tests/belatro/test_phase3_meta.py::test_endless_ante_target_scaling`** + `test_endless_ante_offset_zero_matches_base_table` — pin the `100 × 1.5^(ante-1) × blind × 2.2^offset` formula and the static-table parity invariant.
|
|
123
|
+
- **`tests/belatro/test_phase2_content.py::test_le_fou_no_prior_consumable_falls_back_to_random_tarot`** — covers the `last_id == self.id` defensive branch in `tarots.py::LeFou.use`.
|
|
124
|
+
- **`tests/belatro/test_boss_modifiers_integration.py::test_invariant_no_underscore_boss_attrs`** — anti-pattern lock for the architecture-pinned rule that boss flags must be reached via `state.boss_modifiers.X`, never `getattr(state, "_X", False)`.
|
|
125
|
+
|
|
126
|
+
### Improved
|
|
127
|
+
|
|
128
|
+
- **`src/belote/scoring.py` (winners-threading)** — `score_round` already pre-computed the per-trick winner list (3.0.2); the residual `trick_winner_seat` recomputations in the Malédiction branch (lines 776-793) and `apply_round_score` (lines 843-855) are now eliminated. Per-team trick counts ride on the new `ScoringBreakdown.tricks_ns` / `tricks_ew` fields (default 0; `apply_round_score` falls back to walking when a hand-constructed breakdown leaves them at default). Net: ~16 fewer `trick_winner_seat` calls per round.
|
|
129
|
+
- **`src/belote/belatro/core/scoring.py::ScoreAccumulator.update_state` (deepcopy → shallow)** — Replaced the per-event `copy.deepcopy(state._joker_state)` with `dict(state._joker_state)`. All current `_joker_state` writers store scalars (bool/int/str), so the deep-copy was over-defensive — and ran ~20×/round. Module-level `import copy` and `from dataclasses import replace` removed (they were also reimported inside two methods). Contract is locked by the new scalar-invariant test.
|
|
130
|
+
- **`src/belote/ai.py` (Hard AI hot-loop allocations)** — `_hard_play` precomputes `hand_suit_counts: Counter[Suit]`, `my_trumps`, `opp_trumps` once per turn and threads them into `_score_card_play` / `_score_leading_strategy` / `_score_discarding_strategy`. Pre-3.1.0 these counters were rebuilt for every candidate card — a four-card legal set walked the hand and `memory.played` four times each.
|
|
131
|
+
- **`@dataclass(slots=True)` on `Statistics`, `SessionStats`, `ScoreAccumulator`** (`src/belote/stats.py`, `src/belote/belatro/core/scoring.py`). Frequently-instantiated containers; ~40 bytes saved per instance. `BelAtroRun` deliberately stays non-slotted (its `__post_init__` lazy-init pattern fights `slots=True`).
|
|
132
|
+
- **`src/belote/stats.py:97-98`** — `print(..., file=sys.stderr)` on save failure swapped for `logging.getLogger(__name__).warning`. Removed unused `import sys`.
|
|
133
|
+
- **`src/belote/input.py:138, 160`** — bare `except Exception:` in key-press parsing narrowed to `(UnicodeDecodeError,)` and `(ValueError, UnicodeDecodeError)`. Genuine bugs surface; key-press robustness preserved.
|
|
134
|
+
- **`src/belote/replay.py:46`** — explanatory comment added above the `# noqa: BLE001` so the broad-except rationale is visible at the call site.
|
|
135
|
+
- **`src/belote/game.py:213-217, 220-224`** — docstring on `belote_holders` and `_joker_state` documenting the "always replace, never mutate-in-place" contract for mutable dicts inside the frozen `GameState`.
|
|
136
|
+
|
|
137
|
+
### Removed
|
|
138
|
+
|
|
139
|
+
- **`modifier_patch.py` underscore shim** — The `state.patch("_X", True)` → `state.patch("X", True)` migration is complete. All 23 boss `apply()` methods in `src/belote/belatro/run/boss.py` were rewritten in lock-step. The leading-underscore strip in `PatchedGameState.patch()` and the `__getattr__` fallback to `boss_modifiers.X` are gone; `patch()` now asserts loud on a leading-underscore key. The `getattr(state, "_X", False)` reading anti-pattern is locked against in `test_invariant_no_underscore_boss_attrs`.
|
|
140
|
+
|
|
141
|
+
### Internal
|
|
142
|
+
|
|
143
|
+
- **Tests**: 510 → 525 (+15).
|
|
144
|
+
- **Strict gates**: pytest 525/525, mypy 0 errors, ruff 0 violations across `src/` and `tests/`.
|
|
145
|
+
- **Audit plan**: `~/.claude/plans/bug-hunt-code-performance-sleepy-ritchie.md`.
|
|
146
|
+
|
|
147
|
+
## [3.0.3] - 2026-05-08
|
|
148
|
+
|
|
149
|
+
Full-codebase audit pass + documentation accuracy. No behaviour changes; the audit produced a prioritized findings list and corrected three stale README counts. Planned fixes (one P0 functional, two P0 perf/quality, five P1, seven P2) are tracked for follow-up cuts and not yet implemented.
|
|
150
|
+
|
|
151
|
+
### Fixed (documentation)
|
|
152
|
+
|
|
153
|
+
- **`README.md`** — "Full Boss Blind Suite: All 18 unique bosses" → "All 21 unique bosses". 3.0.0 added Le Sauvage / L'Iconoclaste / Le Mime to bring `ALL_BOSS_MODIFIERS` (in `src/belote/belatro/run/boss.py`) to 21; the showcase line was never bumped.
|
|
154
|
+
- **`README.md`** — two stale "(435 tests)" / "pytest: 435/435 passed" references corrected to 510, matching `pytest --collect-only` and the figure already present at `README.md:250` ("Currently 510 tests passing").
|
|
155
|
+
|
|
156
|
+
### Audit findings (planning only — implementation deferred)
|
|
157
|
+
|
|
158
|
+
A three-agent audit covered the classic engine vs. canonical Belote rules, BelAtro content wiring (jokers / bosses / planets / vouchers / tarots / editions / unlocks), and performance / code-quality hotspots across ~7,100 LOC. Headline: engine is rule-correct; BelAtro content matrix is 93/93 wired (21 bosses, 8 planets, 36 jokers, 4 editions, 12 vouchers, 12 tarots).
|
|
159
|
+
|
|
160
|
+
Findings tracked at `~/.claude/plans/bug-hunt-code-performance-atomic-sutton.md`:
|
|
161
|
+
- **P0-1** — `EventBus.emit` still never called (carried over from 3.0.2). `L'Exécuteur` / `L'Idéologue` / `Le Fanatique` unlocks silently never fire.
|
|
162
|
+
- **P0-2** — `legal_cards()` LRU wrapper rebuilds `Card` objects on every cache hit (`src/belote/game.py:475-653`); est. 5–8% AI-turn regression vs. caching the resolved tuple.
|
|
163
|
+
- **P0-3** — `play_card()` is 174 LOC / cyclomatic ~20 (`src/belote/game.py:777-950`); split into `_update_belote_tracker` / `_apply_play_modifiers` / `_resolve_trick_complete`.
|
|
164
|
+
- **P0-4** — `_calculate_base_points()` accepts an optional pre-computed `winners` arg; cache-miss callers walk all 8 tricks twice (`src/belote/scoring.py:580-588`). Make required.
|
|
165
|
+
- **P1-1** — `card_points(trump: Suit)` lies about None; 8 `# type: ignore` markers across `game.py` / `scoring.py` should drop once signature becomes `Suit | None`.
|
|
166
|
+
- **P1-2** — Boss zero-rank logic duplicated across three sites (`game.py:856-872`, `scoring.py:390-400`, `scoring.py:429-440`); extract a single `apply_zero_rank_bosses(card, trump, bm)` helper. Highest-leverage maintenance fix.
|
|
167
|
+
- **P1-3..P1-5** — `_hard_bid` recomputes void counts inside the suit loop; `trick_rank()` called twice per overtrump check; missing docstrings on hot APIs.
|
|
168
|
+
- **P2** — carré KeyError harden, `REBELOTE_POINTS = 40` variant doc, AI memory reset hardening, `render()` 129-LOC split, `register_all_items` `__all__`, voucher / tarot integration test (24 effects to cover).
|
|
169
|
+
|
|
170
|
+
### Internal
|
|
171
|
+
|
|
172
|
+
- **Tests**: 510 (unchanged).
|
|
173
|
+
- **Strict gates**: pytest 510/510, mypy 0 errors, ruff 0 violations (all unchanged from 3.0.2).
|
|
174
|
+
|
|
175
|
+
### Carried forward
|
|
176
|
+
|
|
177
|
+
- `EventBus.emit` wiring fix (P0-1 above) remains deferred. Now planned for 3.0.4 alongside the perf wins.
|
|
178
|
+
|
|
8
179
|
## [3.0.2] - 2026-05-08
|
|
9
180
|
|
|
10
181
|
Audit pass — wired two previously-dead 3.0.0 modules behind opt-in env vars, removed redundant work from `score_round()`, and pinned every boss modifier's patch keys against typo regressions.
|
|
@@ -84,14 +84,14 @@ 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 (535 tests expected)
|
|
88
88
|
PYTHONPATH=src pytest
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
Current baseline (3.
|
|
91
|
+
Current baseline (3.3.1):
|
|
92
92
|
- **mypy**: 0 errors (strict mode)
|
|
93
93
|
- **ruff**: 0 violations
|
|
94
|
-
- **pytest**:
|
|
94
|
+
- **pytest**: 535 tests, 0 failures
|
|
95
95
|
|
|
96
96
|
Run all gates before committing:
|
|
97
97
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: belote-cli
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.3.1
|
|
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,12 +45,52 @@ 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.1
|
|
49
|
+
|
|
50
|
+
- **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.
|
|
51
|
+
- **Boss interactions** — Le Zéro Final (`no_dix_de_der`) is now honored by the chute branch and by La Compétition's per-player tally (both used to unconditionally add +10 de der). La Compétition's `ban_clubs` check now matches the main scoring path (`any` rather than `lead-only`).
|
|
52
|
+
- **Determinism** — `AIPlayer` now accepts and threads a seeded `Random` from the round driver. Pre-3.3.1 it constructed an unseeded `Random()` regardless of the run seed, breaking ghost-run reproducibility and replay determinism. Partner's `should_coinche` (Le Flambeur) was also using the global `random` module; now uses the driver's seeded RNG and is actually wired into the coinche flow (it had no production caller before).
|
|
53
|
+
- **AI memory across undo** — Hard AI's void inference is no longer stuck with stale voids after a mid-round undo: `update_memory` detects state regression and rebuilds inferences from the live tricks.
|
|
54
|
+
- **Hard AI under Sans Atout** — Used to deterministically return `legal[0]`; now falls back to easy (random) like medium, removing the worst-case under SA.
|
|
55
|
+
- **Audit fixes** — `ban_clubs` HUD/final divergence (was real); per-round stats now flush to disk (achievement unlocks no longer lost on crash); `fuse_jokers` now carries over edition (FOIL/HOLO/POLY) and corruption; `assert` in `modifier_patch` replaced with `raise ValueError` (was strippable by `python -O`); unlock notifications routed through the TUI banner instead of raw `print()` (no more alt-screen scroll); ante themes (Café/Tournoi) wired into the live game loop (the Phase 3.1 module was previously test-only).
|
|
56
|
+
- **UI** — Trust bar is three-tier (red ≤3, gold 4–6, green ≥7). Pre-3.3.1 the default trust of 5 rendered red, falsely signalling distrust at run start.
|
|
57
|
+
- **Test coverage** — 535 tests still passing. Strict gates clean: pytest 535/535, mypy 0 errors, ruff 0 violations.
|
|
58
|
+
|
|
59
|
+
## What's new in 3.3.0
|
|
60
|
+
|
|
61
|
+
- **BelAtro [H] history overlay** — Pressing **H** during a BelAtro run now opens a populated, run-aware ledger: one row per blind with ante, blind label (Small/Big/Boss), target, boss name (when active), taker, contract, NS/EW trick split, BelAtro score, status (`WON` / `FAILED` / `CAPOT` / `SURVIVED`), and money delta. Before 3.3, [H] always showed "No rounds completed yet." in BelAtro because the round driver never invoked `apply_round_score` — the classic-mode writer of `state.score_history`. The fix keeps a parallel `BelAtroRun.history` ledger and routes [H] to a new BelAtro renderer via a small override hook in `belote.ui.prompts`. Classic Belote's [H] path is unchanged.
|
|
62
|
+
- **Test coverage** — 535 tests (up from 528). Strict gates still clean: pytest 535/535, mypy 0 errors, ruff 0 violations.
|
|
63
|
+
|
|
64
|
+
## What's new in 3.2.0
|
|
65
|
+
|
|
66
|
+
- **Joker correctness** — La Sentinelle and Le Dernier Mot both used to key on `Seat.SOUTH` instead of the NS team, so the joker silently no-op'd when North (the AI partner) held the trump Jack or won the last trick. Both now correctly fire on team membership.
|
|
67
|
+
- **Score floor** — L'Égoïste's `add_chips = -event.card_points` per partner-won trick could drive the running total negative and produce a negative final round score; `ScoreAccumulator.get_total` now clamps at 0 so the intermediate log can still show the deduction without the visible score going below zero.
|
|
68
|
+
- **Auto-coinche event parity** — The NS-taker `auto_coinche` boss path now re-emits `BidMadeEvent` with the new `coinche_level`, matching the EW-taker branch. Pre-3.2 jokers and HUD subscribed to `on_bid` silently missed this code path.
|
|
69
|
+
- **Determinism** — The shop and the three RNG-using tarots (`LeJugement`, `LaPretresse`, `LeFou`) now all draw from the run's seeded `_get_rng()` instead of the module-level `random`. Ghost-run replays are now reproducible across shop generations. `LaPretresse` additionally `sample`s instead of `choice`-ing twice, so the two planets it grants are always distinct.
|
|
70
|
+
- **Registry / boss-field hygiene** — `register_joker/planet/tarot/voucher` now assert against duplicate IDs (typo'd registrations used to silently overwrite the original). `boss_fields` in `modifier_patch.py` is now derived from `BossModifiers`' dataclass fields, so adding a new flag no longer requires updating an out-of-band allowlist.
|
|
71
|
+
- **UI fix** — `patch_trick_card` now re-applies `render`'s vertical-centering offset; on tall terminals (>40 rows) it used to draw single-card patches too high.
|
|
72
|
+
- **Audit reconciliation** — This release consolidates the verified findings from two independent LLM code audits (Qwen 3.6 27B + Ring 1T). Both audits had load-bearing false positives — Qwen's two P0 "dead voucher / dead boss flag" claims were both wrong (the flags are consumed); Ring's "critical IllegalMoveError" only fires under test mocks. Eleven rejected claims are catalogued in the changelog so they aren't re-investigated.
|
|
73
|
+
- **Test coverage** — 528 tests (up from 525). Strict gates still clean: pytest 528/528, mypy 0 errors, ruff 0 violations.
|
|
74
|
+
|
|
75
|
+
## What's new in 3.1.0
|
|
76
|
+
|
|
77
|
+
- **Bug fixes** — HUD running-total no longer drifts under multi-boss combos (`Les Clubs Bannis + Le Roi Mort` style: pre-3.1.0 the rank-zero recompute silently overwrote the `ban_clubs` zeroing). `Shop.buy_item` no longer charges money when consumable slots are full — the "Slots full — sell first" banner now fires before any spend.
|
|
78
|
+
- **TierceForge wired up** — the voucher shipped in 3.0.0 with a working backend but no UI caller; the feature was unreachable. The shop now shows a "Forge ×N/3" tile when the voucher is owned, opens a numbered planet picker on Enter, and confirms the level-up via a banner.
|
|
79
|
+
- **Performance** — `score_round` and `apply_round_score` no longer re-walk the trick list (~16 fewer `trick_winner_seat` calls per round). The per-event `copy.deepcopy` in `ScoreAccumulator.update_state` is gone (~20 deepcopies/round saved); replaced with a shallow `dict(...)` plus a scalar-only invariant test that locks the contract. Hard-AI's `_score_card_play` precomputes hand suit counts and trump tallies once per turn instead of per candidate.
|
|
80
|
+
- **Cleanup** — the `modifier_patch` underscore-prefix shim is gone (23 boss `apply()` methods rewritten to use unprefixed field names; the `getattr(state, "_X", False)` anti-pattern is now locked against by a regression test). `slots=True` added to `Statistics`, `SessionStats`, `ScoreAccumulator`. Bare `except Exception:` in key-press parsing narrowed; `print → logging` in stats.
|
|
81
|
+
- **Test coverage** — 525 tests (up from 510). Strict gates still clean: pytest 525/525, mypy 0 errors, ruff 0 violations.
|
|
82
|
+
|
|
83
|
+
## What's new in 3.0.3
|
|
84
|
+
|
|
85
|
+
- **Full-codebase audit** — three-agent pass over the classic engine, BelAtro content wiring, and perf / code-quality hotspots (~7,100 LOC). Headline: engine is rule-correct against canonical French Belote; BelAtro content matrix is **93/93 wired** (21 bosses, 8 planets, 36 jokers, 4 editions, 12 vouchers, 12 tarots). Prioritized findings list (1 P0 functional, 2 P0 perf, 5 P1, 7 P2) tracked for follow-up cuts; implementation landed in 3.1.0.
|
|
86
|
+
- **Doc accuracy** — README boss-count corrected (18 → 21; 3.0.0 added Le Sauvage / L'Iconoclaste / Le Mime), and two stale `(435 tests)` references bumped to 510 to match the figure already present elsewhere in the file.
|
|
87
|
+
|
|
48
88
|
## What's new in 3.0.2
|
|
49
89
|
|
|
50
90
|
- **Replay analyzer + Ghost run wired up** — both shipped in 3.0.0 as code modules but were never called from the running game. Now opt-in behind `BELOTE_REPLAY=1` (post-round Hard-AI comparison) and `BELOTE_GHOST=1` (per-run JSON dump to `~/.local/share/belote/ghosts/`). See DEVELOPMENT.md › Optional Runtime Flags.
|
|
51
91
|
- **Performance** — `score_round()` now caches per-trick winners once instead of recomputing them in each boss-modifier helper (2-3× walks → 1× walk per round). `register_all_items()` is now idempotent so test setup no longer re-walks every items module per `BelAtroRun`. Bidding's special-bid path (TA / SA) hoists `_suit_lengths` out of the per-difficulty branches.
|
|
52
92
|
- **Defensive pin** — every entry in `ALL_BOSS_MODIFIERS` is now asserted to actually toggle a `BossModifiers` field via `.flags()`. Catches typo'd `state.patch("_misspelled", True)` keys at test time rather than letting the boss silently no-op.
|
|
53
|
-
- **Test coverage** —
|
|
93
|
+
- **Test coverage** — 525 tests (up from 509).
|
|
54
94
|
|
|
55
95
|
## What's new in 3.0.1
|
|
56
96
|
|
|
@@ -222,7 +262,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
|
|
|
222
262
|
|
|
223
263
|
- **BelAtro Roguelite Mode:** A massive expansion featuring 36 Jokers, 12 Tarot cards, 8 Planets, 12 Vouchers, and permanent upgrades.
|
|
224
264
|
- **Collection (Almanac):** Persistent tracker to browse every Joker, Planet, and Voucher you've discovered across your runs.
|
|
225
|
-
- **Full Boss Blind Suite:** All
|
|
265
|
+
- **Full Boss Blind Suite:** All 21 unique bosses implemented, including complex mechanics like *L'Anarchie* (dynamic trump) and *La Rupture* (no consecutive wins).
|
|
226
266
|
- **Multiplier Scoring:** Use items to stack Multipliers and reach scores in the millions.
|
|
227
267
|
- **Partner Trust:** Build a relationship with your AI partner to unlock synergies.
|
|
228
268
|
- **Rich Terminal UI:** Full-screen green felt table with detailed card graphics and "You" vs "Partner" terminology.
|
|
@@ -274,7 +314,7 @@ belote/
|
|
|
274
314
|
│ ├── input.py # Platform-dispatched key reader and interruptible sleep
|
|
275
315
|
│ ├── stats.py # Global and session statistics tracking
|
|
276
316
|
│ └── rules.py # Game rules content
|
|
277
|
-
├── tests/ # Comprehensive test suite (
|
|
317
|
+
├── tests/ # Comprehensive test suite (528 tests)
|
|
278
318
|
├── scripts/ # Performance benchmarks
|
|
279
319
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
280
320
|
├── LICENSE # MIT License
|
|
@@ -290,14 +330,14 @@ belote/
|
|
|
290
330
|
PYTHONPATH=src pytest
|
|
291
331
|
```
|
|
292
332
|
|
|
293
|
-
Currently **
|
|
333
|
+
Currently **528 tests** passing with 100% coverage on game-logic modules.
|
|
294
334
|
|
|
295
335
|
## Technical Integrity
|
|
296
336
|
|
|
297
337
|
The codebase is strictly validated with the following tools:
|
|
298
338
|
- **mypy**: 0 errors (strict type safety)
|
|
299
339
|
- **ruff**: 0 violations (linting & formatting)
|
|
300
|
-
- **pytest**:
|
|
340
|
+
- **pytest**: 528/528 passed
|
|
301
341
|
- **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
|
|
302
342
|
- **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
|
|
303
343
|
|
|
@@ -2,12 +2,52 @@
|
|
|
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.1
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
- **Boss interactions** — Le Zéro Final (`no_dix_de_der`) is now honored by the chute branch and by La Compétition's per-player tally (both used to unconditionally add +10 de der). La Compétition's `ban_clubs` check now matches the main scoring path (`any` rather than `lead-only`).
|
|
9
|
+
- **Determinism** — `AIPlayer` now accepts and threads a seeded `Random` from the round driver. Pre-3.3.1 it constructed an unseeded `Random()` regardless of the run seed, breaking ghost-run reproducibility and replay determinism. Partner's `should_coinche` (Le Flambeur) was also using the global `random` module; now uses the driver's seeded RNG and is actually wired into the coinche flow (it had no production caller before).
|
|
10
|
+
- **AI memory across undo** — Hard AI's void inference is no longer stuck with stale voids after a mid-round undo: `update_memory` detects state regression and rebuilds inferences from the live tricks.
|
|
11
|
+
- **Hard AI under Sans Atout** — Used to deterministically return `legal[0]`; now falls back to easy (random) like medium, removing the worst-case under SA.
|
|
12
|
+
- **Audit fixes** — `ban_clubs` HUD/final divergence (was real); per-round stats now flush to disk (achievement unlocks no longer lost on crash); `fuse_jokers` now carries over edition (FOIL/HOLO/POLY) and corruption; `assert` in `modifier_patch` replaced with `raise ValueError` (was strippable by `python -O`); unlock notifications routed through the TUI banner instead of raw `print()` (no more alt-screen scroll); ante themes (Café/Tournoi) wired into the live game loop (the Phase 3.1 module was previously test-only).
|
|
13
|
+
- **UI** — Trust bar is three-tier (red ≤3, gold 4–6, green ≥7). Pre-3.3.1 the default trust of 5 rendered red, falsely signalling distrust at run start.
|
|
14
|
+
- **Test coverage** — 535 tests still passing. Strict gates clean: pytest 535/535, mypy 0 errors, ruff 0 violations.
|
|
15
|
+
|
|
16
|
+
## What's new in 3.3.0
|
|
17
|
+
|
|
18
|
+
- **BelAtro [H] history overlay** — Pressing **H** during a BelAtro run now opens a populated, run-aware ledger: one row per blind with ante, blind label (Small/Big/Boss), target, boss name (when active), taker, contract, NS/EW trick split, BelAtro score, status (`WON` / `FAILED` / `CAPOT` / `SURVIVED`), and money delta. Before 3.3, [H] always showed "No rounds completed yet." in BelAtro because the round driver never invoked `apply_round_score` — the classic-mode writer of `state.score_history`. The fix keeps a parallel `BelAtroRun.history` ledger and routes [H] to a new BelAtro renderer via a small override hook in `belote.ui.prompts`. Classic Belote's [H] path is unchanged.
|
|
19
|
+
- **Test coverage** — 535 tests (up from 528). Strict gates still clean: pytest 535/535, mypy 0 errors, ruff 0 violations.
|
|
20
|
+
|
|
21
|
+
## What's new in 3.2.0
|
|
22
|
+
|
|
23
|
+
- **Joker correctness** — La Sentinelle and Le Dernier Mot both used to key on `Seat.SOUTH` instead of the NS team, so the joker silently no-op'd when North (the AI partner) held the trump Jack or won the last trick. Both now correctly fire on team membership.
|
|
24
|
+
- **Score floor** — L'Égoïste's `add_chips = -event.card_points` per partner-won trick could drive the running total negative and produce a negative final round score; `ScoreAccumulator.get_total` now clamps at 0 so the intermediate log can still show the deduction without the visible score going below zero.
|
|
25
|
+
- **Auto-coinche event parity** — The NS-taker `auto_coinche` boss path now re-emits `BidMadeEvent` with the new `coinche_level`, matching the EW-taker branch. Pre-3.2 jokers and HUD subscribed to `on_bid` silently missed this code path.
|
|
26
|
+
- **Determinism** — The shop and the three RNG-using tarots (`LeJugement`, `LaPretresse`, `LeFou`) now all draw from the run's seeded `_get_rng()` instead of the module-level `random`. Ghost-run replays are now reproducible across shop generations. `LaPretresse` additionally `sample`s instead of `choice`-ing twice, so the two planets it grants are always distinct.
|
|
27
|
+
- **Registry / boss-field hygiene** — `register_joker/planet/tarot/voucher` now assert against duplicate IDs (typo'd registrations used to silently overwrite the original). `boss_fields` in `modifier_patch.py` is now derived from `BossModifiers`' dataclass fields, so adding a new flag no longer requires updating an out-of-band allowlist.
|
|
28
|
+
- **UI fix** — `patch_trick_card` now re-applies `render`'s vertical-centering offset; on tall terminals (>40 rows) it used to draw single-card patches too high.
|
|
29
|
+
- **Audit reconciliation** — This release consolidates the verified findings from two independent LLM code audits (Qwen 3.6 27B + Ring 1T). Both audits had load-bearing false positives — Qwen's two P0 "dead voucher / dead boss flag" claims were both wrong (the flags are consumed); Ring's "critical IllegalMoveError" only fires under test mocks. Eleven rejected claims are catalogued in the changelog so they aren't re-investigated.
|
|
30
|
+
- **Test coverage** — 528 tests (up from 525). Strict gates still clean: pytest 528/528, mypy 0 errors, ruff 0 violations.
|
|
31
|
+
|
|
32
|
+
## What's new in 3.1.0
|
|
33
|
+
|
|
34
|
+
- **Bug fixes** — HUD running-total no longer drifts under multi-boss combos (`Les Clubs Bannis + Le Roi Mort` style: pre-3.1.0 the rank-zero recompute silently overwrote the `ban_clubs` zeroing). `Shop.buy_item` no longer charges money when consumable slots are full — the "Slots full — sell first" banner now fires before any spend.
|
|
35
|
+
- **TierceForge wired up** — the voucher shipped in 3.0.0 with a working backend but no UI caller; the feature was unreachable. The shop now shows a "Forge ×N/3" tile when the voucher is owned, opens a numbered planet picker on Enter, and confirms the level-up via a banner.
|
|
36
|
+
- **Performance** — `score_round` and `apply_round_score` no longer re-walk the trick list (~16 fewer `trick_winner_seat` calls per round). The per-event `copy.deepcopy` in `ScoreAccumulator.update_state` is gone (~20 deepcopies/round saved); replaced with a shallow `dict(...)` plus a scalar-only invariant test that locks the contract. Hard-AI's `_score_card_play` precomputes hand suit counts and trump tallies once per turn instead of per candidate.
|
|
37
|
+
- **Cleanup** — the `modifier_patch` underscore-prefix shim is gone (23 boss `apply()` methods rewritten to use unprefixed field names; the `getattr(state, "_X", False)` anti-pattern is now locked against by a regression test). `slots=True` added to `Statistics`, `SessionStats`, `ScoreAccumulator`. Bare `except Exception:` in key-press parsing narrowed; `print → logging` in stats.
|
|
38
|
+
- **Test coverage** — 525 tests (up from 510). Strict gates still clean: pytest 525/525, mypy 0 errors, ruff 0 violations.
|
|
39
|
+
|
|
40
|
+
## What's new in 3.0.3
|
|
41
|
+
|
|
42
|
+
- **Full-codebase audit** — three-agent pass over the classic engine, BelAtro content wiring, and perf / code-quality hotspots (~7,100 LOC). Headline: engine is rule-correct against canonical French Belote; BelAtro content matrix is **93/93 wired** (21 bosses, 8 planets, 36 jokers, 4 editions, 12 vouchers, 12 tarots). Prioritized findings list (1 P0 functional, 2 P0 perf, 5 P1, 7 P2) tracked for follow-up cuts; implementation landed in 3.1.0.
|
|
43
|
+
- **Doc accuracy** — README boss-count corrected (18 → 21; 3.0.0 added Le Sauvage / L'Iconoclaste / Le Mime), and two stale `(435 tests)` references bumped to 510 to match the figure already present elsewhere in the file.
|
|
44
|
+
|
|
5
45
|
## What's new in 3.0.2
|
|
6
46
|
|
|
7
47
|
- **Replay analyzer + Ghost run wired up** — both shipped in 3.0.0 as code modules but were never called from the running game. Now opt-in behind `BELOTE_REPLAY=1` (post-round Hard-AI comparison) and `BELOTE_GHOST=1` (per-run JSON dump to `~/.local/share/belote/ghosts/`). See DEVELOPMENT.md › Optional Runtime Flags.
|
|
8
48
|
- **Performance** — `score_round()` now caches per-trick winners once instead of recomputing them in each boss-modifier helper (2-3× walks → 1× walk per round). `register_all_items()` is now idempotent so test setup no longer re-walks every items module per `BelAtroRun`. Bidding's special-bid path (TA / SA) hoists `_suit_lengths` out of the per-difficulty branches.
|
|
9
49
|
- **Defensive pin** — every entry in `ALL_BOSS_MODIFIERS` is now asserted to actually toggle a `BossModifiers` field via `.flags()`. Catches typo'd `state.patch("_misspelled", True)` keys at test time rather than letting the boss silently no-op.
|
|
10
|
-
- **Test coverage** —
|
|
50
|
+
- **Test coverage** — 525 tests (up from 509).
|
|
11
51
|
|
|
12
52
|
## What's new in 3.0.1
|
|
13
53
|
|
|
@@ -179,7 +219,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
|
|
|
179
219
|
|
|
180
220
|
- **BelAtro Roguelite Mode:** A massive expansion featuring 36 Jokers, 12 Tarot cards, 8 Planets, 12 Vouchers, and permanent upgrades.
|
|
181
221
|
- **Collection (Almanac):** Persistent tracker to browse every Joker, Planet, and Voucher you've discovered across your runs.
|
|
182
|
-
- **Full Boss Blind Suite:** All
|
|
222
|
+
- **Full Boss Blind Suite:** All 21 unique bosses implemented, including complex mechanics like *L'Anarchie* (dynamic trump) and *La Rupture* (no consecutive wins).
|
|
183
223
|
- **Multiplier Scoring:** Use items to stack Multipliers and reach scores in the millions.
|
|
184
224
|
- **Partner Trust:** Build a relationship with your AI partner to unlock synergies.
|
|
185
225
|
- **Rich Terminal UI:** Full-screen green felt table with detailed card graphics and "You" vs "Partner" terminology.
|
|
@@ -231,7 +271,7 @@ belote/
|
|
|
231
271
|
│ ├── input.py # Platform-dispatched key reader and interruptible sleep
|
|
232
272
|
│ ├── stats.py # Global and session statistics tracking
|
|
233
273
|
│ └── rules.py # Game rules content
|
|
234
|
-
├── tests/ # Comprehensive test suite (
|
|
274
|
+
├── tests/ # Comprehensive test suite (528 tests)
|
|
235
275
|
├── scripts/ # Performance benchmarks
|
|
236
276
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
237
277
|
├── LICENSE # MIT License
|
|
@@ -247,14 +287,14 @@ belote/
|
|
|
247
287
|
PYTHONPATH=src pytest
|
|
248
288
|
```
|
|
249
289
|
|
|
250
|
-
Currently **
|
|
290
|
+
Currently **528 tests** passing with 100% coverage on game-logic modules.
|
|
251
291
|
|
|
252
292
|
## Technical Integrity
|
|
253
293
|
|
|
254
294
|
The codebase is strictly validated with the following tools:
|
|
255
295
|
- **mypy**: 0 errors (strict type safety)
|
|
256
296
|
- **ruff**: 0 violations (linting & formatting)
|
|
257
|
-
- **pytest**:
|
|
297
|
+
- **pytest**: 528/528 passed
|
|
258
298
|
- **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
|
|
259
299
|
- **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
|
|
260
300
|
|
|
@@ -41,18 +41,31 @@ class AIMemory:
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
class AIPlayer:
|
|
44
|
-
def __init__(
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
seat: Seat,
|
|
47
|
+
difficulty: Difficulty = Difficulty.MEDIUM,
|
|
48
|
+
rng: random.Random | None = None,
|
|
49
|
+
) -> None:
|
|
45
50
|
self.seat = seat
|
|
46
51
|
self.difficulty = difficulty
|
|
47
52
|
self.memory = AIMemory()
|
|
48
|
-
|
|
53
|
+
# Accept the caller's seeded RNG (round driver / replay tooling) so
|
|
54
|
+
# easy-AI plays, personality jitter, and any other stochastic AI
|
|
55
|
+
# decisions are reproducible under a fixed seed. Falls back to an
|
|
56
|
+
# unseeded Random() for legacy callers that construct an AIPlayer
|
|
57
|
+
# directly (e.g. test fixtures).
|
|
58
|
+
self._rng = rng if rng is not None else random.Random()
|
|
49
59
|
# Set per decide_card() call from state.boss_modifiers.seven_eight_trump.
|
|
50
60
|
# All ranking helpers in this class read it via self._se.
|
|
51
61
|
self._se = False
|
|
52
62
|
|
|
53
63
|
def update_memory(self, state: GameState) -> None:
|
|
54
64
|
"""Update memory with currently visible information."""
|
|
55
|
-
|
|
65
|
+
completed_count = len(state.completed_tricks)
|
|
66
|
+
current_count = len(state.current_trick)
|
|
67
|
+
|
|
68
|
+
if completed_count == 0 and current_count == 0:
|
|
56
69
|
# New round - reset memory. Including the void-cache key — without
|
|
57
70
|
# this a (0, 0) / (0, 1) key from the first decision of *this* round
|
|
58
71
|
# could coincidentally match a leftover from the previous round and
|
|
@@ -63,6 +76,20 @@ class AIPlayer:
|
|
|
63
76
|
self.memory.partner_hand.clear()
|
|
64
77
|
self.memory.processed_tricks_count = 0
|
|
65
78
|
self.memory.last_voids_key = None
|
|
79
|
+
elif (
|
|
80
|
+
self.memory.last_voids_key is not None
|
|
81
|
+
and (completed_count, current_count) < self.memory.last_voids_key
|
|
82
|
+
):
|
|
83
|
+
# Mid-round undo: the state regressed below the highest point
|
|
84
|
+
# we've processed. `known_voids` and `processed_tricks_count`
|
|
85
|
+
# are monotonic and would carry stale inferences forward
|
|
86
|
+
# (a void inferred from a now-rolled-back trick). Rebuild from
|
|
87
|
+
# the current state instead of trying to subtract.
|
|
88
|
+
self.memory.played.clear()
|
|
89
|
+
for s in Seat:
|
|
90
|
+
self.memory.known_voids[s].clear()
|
|
91
|
+
self.memory.processed_tricks_count = 0
|
|
92
|
+
self.memory.last_voids_key = None
|
|
66
93
|
|
|
67
94
|
# Track all cards in completed tricks
|
|
68
95
|
for trick in state.completed_tricks:
|
|
@@ -473,7 +500,12 @@ class AIPlayer:
|
|
|
473
500
|
trick = state.current_trick
|
|
474
501
|
|
|
475
502
|
if not trump:
|
|
476
|
-
|
|
503
|
+
# Sans Atout: the lookahead scoring uses `trick_rank(c, trump, ...)`
|
|
504
|
+
# which is meaningless without a trump suit. Fall back to easy
|
|
505
|
+
# (random over legal) rather than `legal[0]` so we don't degrade
|
|
506
|
+
# to a fully deterministic worst-case under SA — matches what
|
|
507
|
+
# `_medium_play` does at its own trump==None guard.
|
|
508
|
+
return self._easy_play(state, legal)
|
|
477
509
|
|
|
478
510
|
# Update void inferences from completed tricks
|
|
479
511
|
self._update_voids(state)
|
|
@@ -490,12 +522,31 @@ class AIPlayer:
|
|
|
490
522
|
)
|
|
491
523
|
partner_winning = current_winner is not None and current_winner == p
|
|
492
524
|
|
|
525
|
+
# Precompute per-call counters used by every scoring branch — pre-3.1.0
|
|
526
|
+
# these were recomputed per candidate card (n×4 walks of the hand and
|
|
527
|
+
# memory.played for each legal card).
|
|
528
|
+
from collections import Counter
|
|
529
|
+
|
|
530
|
+
my_hand = state.hand_of(self.seat)
|
|
531
|
+
hand_suit_counts: dict[Suit, int] = Counter(c.suit for c in my_hand)
|
|
532
|
+
my_trumps = hand_suit_counts.get(trump, 0)
|
|
533
|
+
opp_trumps = 8 - sum(1 for c in self.memory.played if c.suit == trump)
|
|
534
|
+
|
|
493
535
|
# Score each legal card by expected outcome
|
|
494
536
|
best_card = legal[0]
|
|
495
537
|
best_score: float = -999.0
|
|
496
538
|
|
|
497
539
|
for card in legal:
|
|
498
|
-
score = self._score_card_play(
|
|
540
|
+
score = self._score_card_play(
|
|
541
|
+
card,
|
|
542
|
+
state,
|
|
543
|
+
trump,
|
|
544
|
+
trick,
|
|
545
|
+
partner_winning,
|
|
546
|
+
hand_suit_counts,
|
|
547
|
+
my_trumps,
|
|
548
|
+
opp_trumps,
|
|
549
|
+
)
|
|
499
550
|
if score > best_score:
|
|
500
551
|
best_score = score
|
|
501
552
|
best_card = card
|
|
@@ -509,6 +560,9 @@ class AIPlayer:
|
|
|
509
560
|
trump: Suit,
|
|
510
561
|
trick: tuple[TrickCard, ...],
|
|
511
562
|
partner_winning: bool,
|
|
563
|
+
hand_suit_counts: dict[Suit, int],
|
|
564
|
+
my_trumps: int,
|
|
565
|
+
opp_trumps: int,
|
|
512
566
|
) -> float:
|
|
513
567
|
"""Score a card play decision with advanced heuristics."""
|
|
514
568
|
score = 0.0
|
|
@@ -517,20 +571,20 @@ class AIPlayer:
|
|
|
517
571
|
score += points * 0.1
|
|
518
572
|
|
|
519
573
|
if not trick:
|
|
520
|
-
return self._score_leading_strategy(card,
|
|
574
|
+
return self._score_leading_strategy(card, trump, my_trumps, opp_trumps)
|
|
521
575
|
|
|
522
576
|
if partner_winning and trick[0].card.suit != trump:
|
|
523
|
-
return self._score_discarding_strategy(card,
|
|
577
|
+
return self._score_discarding_strategy(card, trump, points, hand_suit_counts)
|
|
524
578
|
|
|
525
579
|
return self._score_winning_strategy(card, state, trump, trick, partner_winning, points)
|
|
526
580
|
|
|
527
|
-
def _score_leading_strategy(
|
|
581
|
+
def _score_leading_strategy(
|
|
582
|
+
self, card: Card, trump: Suit, my_trumps: int, opp_trumps: int
|
|
583
|
+
) -> float:
|
|
528
584
|
"""Heuristics for when we are leading the trick."""
|
|
529
585
|
score = 0.0
|
|
530
586
|
if card.suit == trump:
|
|
531
587
|
# Leading trump is good for pulling if opponents still have them
|
|
532
|
-
opp_trumps = 8 - sum(1 for c in self.memory.played if c.suit == trump)
|
|
533
|
-
my_trumps = sum(1 for c in state.hand_of(self.seat) if c.suit == trump)
|
|
534
588
|
if opp_trumps > my_trumps:
|
|
535
589
|
score += 4
|
|
536
590
|
else:
|
|
@@ -543,7 +597,7 @@ class AIPlayer:
|
|
|
543
597
|
return score
|
|
544
598
|
|
|
545
599
|
def _score_discarding_strategy(
|
|
546
|
-
self, card: Card,
|
|
600
|
+
self, card: Card, trump: Suit, points: int, hand_suit_counts: dict[Suit, int]
|
|
547
601
|
) -> float:
|
|
548
602
|
"""Heuristics for when partner is winning and we can discard."""
|
|
549
603
|
score = 0.0
|
|
@@ -551,9 +605,7 @@ class AIPlayer:
|
|
|
551
605
|
score -= points * 0.7 # Penalize throwing away points
|
|
552
606
|
|
|
553
607
|
# Prefer discarding from short suits (to establish voids)
|
|
554
|
-
|
|
555
|
-
suit_count = sum(1 for c in my_hand if c.suit == card.suit)
|
|
556
|
-
if suit_count == 1:
|
|
608
|
+
if hand_suit_counts.get(card.suit, 0) == 1:
|
|
557
609
|
score += 3
|
|
558
610
|
|
|
559
611
|
# Prefer keeping cards that partner is void in (to trump later)
|