belote-cli 3.2.0__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.2.0 → belote_cli-3.3.2}/CHANGELOG.md +75 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/DEVELOPMENT.md +3 -3
- {belote_cli-3.2.0 → belote_cli-3.3.2}/PKG-INFO +17 -1
- {belote_cli-3.2.0 → belote_cli-3.3.2}/README.md +16 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/pyproject.toml +1 -1
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/__init__.py +1 -1
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/ai.py +36 -4
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/core/run_state.py +24 -1
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/core/scoring.py +5 -1
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/engine/modifier_patch.py +5 -4
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/engine/round_driver.py +20 -4
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/base.py +23 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/main.py +147 -3
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/partner/personality.py +9 -5
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/progression/unlocks.py +26 -5
- belote_cli-3.3.2/src/belote/belatro/ui/history.py +192 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ui/rules.py +1 -1
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ui/trust_bar.py +9 -1
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/game.py +67 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/gameflow.py +1 -1
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/replay.py +11 -2
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/scoring.py +56 -40
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/stats.py +4 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/ui/prompts.py +21 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_boss_modifiers_integration.py +51 -1
- belote_cli-3.3.2/tests/belatro/test_history_overlay.py +222 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_replay.py +40 -0
- belote_cli-3.2.0/AGENT.md +0 -12
- {belote_cli-3.2.0 → belote_cli-3.3.2}/.claude/settings.local.json +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/.gitignore +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/.python-version +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/LICENSE +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/scripts/benchmark.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/__init__.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/a11y.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/achievements.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/ansi.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/__init__.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/core/__init__.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/core/economy.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/engine/__init__.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/engine/event_bus.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ghost_run.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/__init__.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/__init__.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/annonces.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/coinche.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/contract.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/corrupted.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/economy.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/planets.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/registry.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/tarots.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/items/vouchers.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/partner/__init__.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/partner/partner_state.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/partner/trust.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/progression/__init__.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/progression/save.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/run/__init__.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/run/ante.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/run/ante_themes.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/run/boss.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/run/decks.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/run/shop.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/run_summary.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ui/__init__.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ui/announce.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ui/collection.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ui/hud.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ui/menu.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/belatro/ui/shop.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/config.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/context.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/deck.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/input.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/main.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/rules.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/themes.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/ui/__init__.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/ui/announce.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/ui/layout.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/ui/menu.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/src/belote/ui/render.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/__init__.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/__init__.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_belatro.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_collection_logic.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_contract_unlocks.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_dead_flag_fixes.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_deck_variants.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_ghost_run.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_hud_synergy.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_partner_trust.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_phase0_coverage.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_phase1_plumbing.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_phase2_content.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_phase3_meta.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_progression.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/belatro/test_round_driver.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_a11y.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_achievements.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_ai.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_ansi_helpers.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_belote.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_extended.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_game_logic.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_gameflow.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_layout.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_new_coverage.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_official_rules.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_properties.py +0 -0
- {belote_cli-3.2.0 → belote_cli-3.3.2}/tests/test_undo.py +0 -0
|
@@ -5,6 +5,81 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [3.3.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
|
+
|
|
32
|
+
## [3.3.1] - 2026-05-10
|
|
33
|
+
|
|
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.
|
|
35
|
+
|
|
36
|
+
### Fixed — audit findings
|
|
37
|
+
|
|
38
|
+
- **`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.
|
|
39
|
+
- **`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.
|
|
40
|
+
- **`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.
|
|
41
|
+
- **`src/belote/belatro/ui/rules.py` (B8)** — Reroll cost doc text now reads `$5` to match `Shop.reroll_cost = 5` in code.
|
|
42
|
+
- **`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`.
|
|
43
|
+
- **`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`.
|
|
44
|
+
- **`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.
|
|
45
|
+
- **`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.
|
|
46
|
+
- **`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.
|
|
47
|
+
|
|
48
|
+
### Fixed — independent bug-hunt pass (not in original audit)
|
|
49
|
+
|
|
50
|
+
- **`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.
|
|
51
|
+
- **`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.
|
|
52
|
+
- **`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.
|
|
53
|
+
- **`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.
|
|
54
|
+
- **`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.
|
|
55
|
+
- **`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.
|
|
56
|
+
- **`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.
|
|
57
|
+
|
|
58
|
+
### Internal
|
|
59
|
+
|
|
60
|
+
- **Tests**: 535 / 535 still passing.
|
|
61
|
+
- **Strict gates**: mypy 0 errors (75 files), ruff 0 violations.
|
|
62
|
+
- **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.
|
|
63
|
+
- **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.
|
|
64
|
+
|
|
65
|
+
## [3.3.0] - 2026-05-10
|
|
66
|
+
|
|
67
|
+
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.
|
|
68
|
+
|
|
69
|
+
### Fixed
|
|
70
|
+
|
|
71
|
+
- **`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).
|
|
72
|
+
|
|
73
|
+
### Added
|
|
74
|
+
|
|
75
|
+
- **`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.
|
|
76
|
+
- **`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.
|
|
77
|
+
|
|
78
|
+
### Internal
|
|
79
|
+
|
|
80
|
+
- **Tests**: 528 → 535 (+7).
|
|
81
|
+
- **Strict gates**: pytest 535/535, mypy 0 errors, ruff 0 violations across `src/` and `tests/`.
|
|
82
|
+
|
|
8
83
|
## [3.2.0] - 2026-05-10
|
|
9
84
|
|
|
10
85
|
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.
|
|
@@ -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.2
|
|
3
|
+
Version: 3.3.2
|
|
4
4
|
Summary: A 4-player terminal card game
|
|
5
5
|
Project-URL: Homepage, https://github.com/ElysiumDisc/belote
|
|
6
6
|
Project-URL: Repository, https://github.com/ElysiumDisc/belote
|
|
@@ -45,6 +45,22 @@ 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
|
+
|
|
48
64
|
## What's new in 3.2.0
|
|
49
65
|
|
|
50
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.
|
|
@@ -2,6 +2,22 @@
|
|
|
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
|
+
|
|
5
21
|
## What's new in 3.2.0
|
|
6
22
|
|
|
7
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.
|
|
@@ -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)
|
|
@@ -7,6 +7,7 @@ if TYPE_CHECKING:
|
|
|
7
7
|
from ..items.base import Joker, Voucher
|
|
8
8
|
from ..progression.save import Profile
|
|
9
9
|
from ..run.ante import Ante
|
|
10
|
+
from ..ui.history import BelAtroHistoryEntry
|
|
10
11
|
|
|
11
12
|
from ..partner.partner_state import PartnerState
|
|
12
13
|
from .economy import Economy
|
|
@@ -76,6 +77,12 @@ class BelAtroRun:
|
|
|
76
77
|
# this; tests assert it. Cleared whenever a new tarot is used.
|
|
77
78
|
last_tarot_message: str | None = None
|
|
78
79
|
|
|
80
|
+
# ── Per-blind history (powers the [H] overlay) ─────────
|
|
81
|
+
# Appended by `BelAtroGame._play_blind` after each Belote round. The
|
|
82
|
+
# classic `state.score_history` is never written under BelAtro (see
|
|
83
|
+
# `belatro/ui/history.py` header for the full rationale).
|
|
84
|
+
history: list[BelAtroHistoryEntry] = field(default_factory=list)
|
|
85
|
+
|
|
79
86
|
# ── Determinism ────────────────────────────────────────
|
|
80
87
|
seed: int | None = None
|
|
81
88
|
_rng: Any = None
|
|
@@ -167,7 +174,23 @@ class BelAtroRun:
|
|
|
167
174
|
|
|
168
175
|
@property
|
|
169
176
|
def target_score(self) -> int:
|
|
170
|
-
|
|
177
|
+
base = self.current_blind.target
|
|
178
|
+
theme = self.get_ante_theme()
|
|
179
|
+
if theme is None:
|
|
180
|
+
return base
|
|
181
|
+
# Soft target adjustments (e.g. Café reduces boss target by 5%).
|
|
182
|
+
# Round to int after the float multiply so downstream scoring
|
|
183
|
+
# comparisons stay integral.
|
|
184
|
+
return max(1, int(round(base * theme.target_multiplier(self.blind_index))))
|
|
185
|
+
|
|
186
|
+
def get_ante_theme(self) -> Any:
|
|
187
|
+
"""Resolve `ante_theme` (id) back to an AnteTheme instance, or None."""
|
|
188
|
+
if not self.ante_theme:
|
|
189
|
+
return None
|
|
190
|
+
from ..run.ante_themes import THEME_BY_ID
|
|
191
|
+
|
|
192
|
+
cls = THEME_BY_ID.get(self.ante_theme)
|
|
193
|
+
return cls() if cls is not None else None
|
|
171
194
|
|
|
172
195
|
def advance_blind(self) -> None:
|
|
173
196
|
if self.blind_index < 2:
|
|
@@ -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
|
|
|
@@ -33,10 +33,11 @@ class PatchedGameState:
|
|
|
33
33
|
against in tests/belatro/test_boss_modifiers_integration.py
|
|
34
34
|
`test_invariant_no_underscore_boss_attrs`.
|
|
35
35
|
"""
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
if attr.startswith("_"):
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"patch() received leading-underscore attr {attr!r}; the 3.0.x shim "
|
|
39
|
+
"was removed in 3.1.0 — use the unprefixed boss field name."
|
|
40
|
+
)
|
|
40
41
|
|
|
41
42
|
if attr in _BOSS_FIELDS:
|
|
42
43
|
current_bm = self.boss_modifiers
|
|
@@ -134,10 +134,14 @@ def drive_round(
|
|
|
134
134
|
"medium": Difficulty.MEDIUM,
|
|
135
135
|
"hard": Difficulty.HARD,
|
|
136
136
|
}.get(_north_diff_str, Difficulty.MEDIUM)
|
|
137
|
+
# Thread the round's seeded RNG into every AI seat so easy-AI plays and
|
|
138
|
+
# personality jitter stay reproducible under a fixed seed. Ghost-run and
|
|
139
|
+
# replay tooling rely on this — without it, AIPlayer's old unseeded
|
|
140
|
+
# default RNG randomised behavior per process even at a fixed seed.
|
|
137
141
|
ai_players = {
|
|
138
|
-
Seat.EAST: AIPlayer(Seat.EAST, Difficulty.MEDIUM),
|
|
139
|
-
Seat.NORTH: AIPlayer(Seat.NORTH, _north_diff),
|
|
140
|
-
Seat.WEST: AIPlayer(Seat.WEST, Difficulty.MEDIUM),
|
|
142
|
+
Seat.EAST: AIPlayer(Seat.EAST, Difficulty.MEDIUM, rng=rng),
|
|
143
|
+
Seat.NORTH: AIPlayer(Seat.NORTH, _north_diff, rng=rng),
|
|
144
|
+
Seat.WEST: AIPlayer(Seat.WEST, Difficulty.MEDIUM, rng=rng),
|
|
141
145
|
}
|
|
142
146
|
|
|
143
147
|
if acc is not None:
|
|
@@ -219,7 +223,19 @@ def drive_round(
|
|
|
219
223
|
surcoinche_unlocked = bool(state._joker_state.get("surcoinche_unlocked"))
|
|
220
224
|
if surcoinche_unlocked and rng.random() < 0.3:
|
|
221
225
|
coinche_level = 2
|
|
222
|
-
|
|
226
|
+
else:
|
|
227
|
+
# Player declined — give the AI partner (North, same defending team)
|
|
228
|
+
# a chance to coinche on its own initiative. Personality-driven and
|
|
229
|
+
# gated by trust: a degraded partner won't act independently.
|
|
230
|
+
if (
|
|
231
|
+
not partner.trust.ai_degraded
|
|
232
|
+
and partner.personality.should_coinche(state, rng)
|
|
233
|
+
):
|
|
234
|
+
coinche_level = 1
|
|
235
|
+
surcoinche_unlocked = bool(state._joker_state.get("surcoinche_unlocked"))
|
|
236
|
+
if surcoinche_unlocked and rng.random() < 0.3:
|
|
237
|
+
coinche_level = 2
|
|
238
|
+
# L'Avocat boss forces at least coinche=1 (existing auto_coinche flag).
|
|
223
239
|
if state.boss_modifiers.auto_coinche:
|
|
224
240
|
coinche_level = max(coinche_level, 1)
|
|
225
241
|
# Re-emit the final BidMadeEvent so jokers/HUD see the coinche level.
|
|
@@ -175,6 +175,29 @@ def fuse_jokers(a: Joker, b: Joker) -> Joker:
|
|
|
175
175
|
new_idx = min(base_idx + 1, _RARITY_LADDER.index(Rarity.RARE))
|
|
176
176
|
fused.rarity = _RARITY_LADDER[new_idx]
|
|
177
177
|
fused.fusable = False # one-time fusion only
|
|
178
|
+
# Carry over the better edition. type(a)() returns a fresh instance with
|
|
179
|
+
# the class default (NONE), so without this the player would silently lose
|
|
180
|
+
# any Foil/Holo/Polychrome they paid for. NEGATIVE is purchase-time only
|
|
181
|
+
# (the extra slot was already granted) so it doesn't propagate through
|
|
182
|
+
# fusion — fall back to NONE in that case.
|
|
183
|
+
fused.edition = _better_edition(a.edition, b.edition)
|
|
184
|
+
# Corruption is sticky — if either input was corrupted, so is the fusion.
|
|
185
|
+
fused.is_corrupted = a.is_corrupted or b.is_corrupted
|
|
178
186
|
# Stamp a marker so callers can identify fused jokers
|
|
179
187
|
fused.name = f"{a.name} + {b.name}"
|
|
180
188
|
return fused
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
_EDITION_RANK: dict[Edition, int] = {
|
|
192
|
+
Edition.NONE: 0,
|
|
193
|
+
Edition.NEGATIVE: 0, # purchase-time only, doesn't survive fusion
|
|
194
|
+
Edition.FOIL: 1,
|
|
195
|
+
Edition.HOLO: 2,
|
|
196
|
+
Edition.POLYCHROME: 3,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _better_edition(a: Edition, b: Edition) -> Edition:
|
|
201
|
+
pick = a if _EDITION_RANK[a] >= _EDITION_RANK[b] else b
|
|
202
|
+
# NEGATIVE collapses to NONE post-fusion (slot already counted).
|
|
203
|
+
return Edition.NONE if pick == Edition.NEGATIVE else pick
|
|
@@ -61,11 +61,24 @@ class BelAtroGame:
|
|
|
61
61
|
seed=self.run.seed if self.run.seed is not None else 0,
|
|
62
62
|
deck_id=self.run.deck_id,
|
|
63
63
|
)
|
|
64
|
+
# 3.3.0: route the in-game [H] key to the BelAtro history
|
|
65
|
+
# overlay (reading `self.run.history`) for the duration of
|
|
66
|
+
# the run. Cleared in the finally block so the classic
|
|
67
|
+
# Belote menu returns to the default `state.score_history`
|
|
68
|
+
# path on exit.
|
|
69
|
+
from ..ui.prompts import set_history_override
|
|
70
|
+
from .ui.history import show_belatro_history
|
|
71
|
+
run = self.run # capture for the closure (mypy: narrowed)
|
|
72
|
+
set_history_override(
|
|
73
|
+
lambda reader: show_belatro_history(reader, run.history)
|
|
74
|
+
)
|
|
64
75
|
self._run_loop()
|
|
65
76
|
except KeyboardInterrupt:
|
|
66
77
|
# Catch exit signals to return to the Belote main menu
|
|
67
78
|
return
|
|
68
79
|
finally:
|
|
80
|
+
from ..ui.prompts import set_history_override
|
|
81
|
+
set_history_override(None)
|
|
69
82
|
# 3.0.0: append a one-line summary of the just-ended run for the
|
|
70
83
|
# player's own analysis. Best-effort; swallowed on failure.
|
|
71
84
|
if self.run is not None:
|
|
@@ -75,6 +88,19 @@ class BelAtroGame:
|
|
|
75
88
|
label = "won" if self.run.run_won else f"ante{self.run.ante_number}"
|
|
76
89
|
self._ghost_recorder.save(label=label)
|
|
77
90
|
|
|
91
|
+
def _drain_unlock_announcements(self) -> None:
|
|
92
|
+
"""Render any queued unlock notices through the TUI banner.
|
|
93
|
+
|
|
94
|
+
Replaces the old raw-stdout `print()` notices in UnlockTracker, which
|
|
95
|
+
scrolled and corrupted the alt-screen buffer.
|
|
96
|
+
"""
|
|
97
|
+
if self.reader is None:
|
|
98
|
+
self.unlock_tracker.drain_announcements()
|
|
99
|
+
return
|
|
100
|
+
from .ui.announce import BelAtroAnnounce
|
|
101
|
+
for msg in self.unlock_tracker.drain_announcements():
|
|
102
|
+
BelAtroAnnounce.banner(msg, self.reader, hold=1.5)
|
|
103
|
+
|
|
78
104
|
def _run_loop(self) -> None:
|
|
79
105
|
"""Main game loop: Blind -> Shop -> Next."""
|
|
80
106
|
if self.run is None:
|
|
@@ -84,6 +110,7 @@ class BelAtroGame:
|
|
|
84
110
|
while not self.run.run_over:
|
|
85
111
|
# 1. Round (Blind)
|
|
86
112
|
self._play_blind()
|
|
113
|
+
self._drain_unlock_announcements()
|
|
87
114
|
|
|
88
115
|
if self.run.run_over:
|
|
89
116
|
break
|
|
@@ -97,8 +124,10 @@ class BelAtroGame:
|
|
|
97
124
|
# 3. Advance
|
|
98
125
|
self.run.advance_blind()
|
|
99
126
|
self.unlock_tracker.check_ante_unlocks(self.run.ante_number)
|
|
127
|
+
self._drain_unlock_announcements()
|
|
100
128
|
if self.run.run_won:
|
|
101
129
|
self.unlock_tracker.notify_run_won()
|
|
130
|
+
self._drain_unlock_announcements()
|
|
102
131
|
if self.reader is not None:
|
|
103
132
|
BelAtroAnnounce.banner("YOU WON!", self.reader, hold=2.5)
|
|
104
133
|
# 3.0.0: offer Endless mode after the canonical 8 antes.
|
|
@@ -121,6 +150,20 @@ class BelAtroGame:
|
|
|
121
150
|
"""Execute one Belote round for the current blind."""
|
|
122
151
|
if self.run is None or self.reader is None:
|
|
123
152
|
return
|
|
153
|
+
# Phase 3.1: roll an Ante theme at the start of each ante (blind 0).
|
|
154
|
+
# Uses the run's seeded RNG so themes are deterministic per seed.
|
|
155
|
+
# The roll runs once per ante; subsequent blinds re-use the same theme.
|
|
156
|
+
if self.run.blind_index == 0:
|
|
157
|
+
from .run.ante_themes import roll_theme
|
|
158
|
+
theme = roll_theme(self.run._get_rng().random())
|
|
159
|
+
self.run.ante_theme = theme.id if theme is not None else None
|
|
160
|
+
if theme is not None:
|
|
161
|
+
theme.on_ante_start(self.run)
|
|
162
|
+
# 3.3.0: snapshots used at end of round to build the [H] history entry.
|
|
163
|
+
history_ante = self.run.ante_number
|
|
164
|
+
history_blind_index = self.run.blind_index
|
|
165
|
+
history_target = self.run.target_score
|
|
166
|
+
money_before = self.run.economy.money
|
|
124
167
|
bus = EventBus()
|
|
125
168
|
self.unlock_tracker.subscribe_to(bus)
|
|
126
169
|
acc = ScoreAccumulator()
|
|
@@ -306,12 +349,12 @@ class BelAtroGame:
|
|
|
306
349
|
self.run.partner_mood = trust.mood()
|
|
307
350
|
|
|
308
351
|
effective_target = acc.target_score # doubled for L'Avocat, normal otherwise
|
|
352
|
+
survived_via_insurance = False
|
|
309
353
|
if total < effective_target:
|
|
310
354
|
# Phase 2.1: Capot Insurance halves the chute loss (one-shot).
|
|
311
|
-
failure_softened = False
|
|
312
355
|
if bd.is_failed and self.run.capot_insurance:
|
|
313
356
|
self.run.capot_insurance = False
|
|
314
|
-
|
|
357
|
+
survived_via_insurance = True
|
|
315
358
|
# Defer run-over by one blind: the player paid for a safety net.
|
|
316
359
|
# We treat the round as a survived chute (no run-over flag).
|
|
317
360
|
BelAtroAnnounce.banner(
|
|
@@ -319,7 +362,7 @@ class BelAtroGame:
|
|
|
319
362
|
self.reader,
|
|
320
363
|
hold=2.0,
|
|
321
364
|
)
|
|
322
|
-
if not
|
|
365
|
+
if not survived_via_insurance:
|
|
323
366
|
self.run.run_over = True
|
|
324
367
|
BelAtroAnnounce.banner(
|
|
325
368
|
f"RUN OVER — Failed to meet target {effective_target} (scored {total}).",
|
|
@@ -360,6 +403,12 @@ class BelAtroGame:
|
|
|
360
403
|
else:
|
|
361
404
|
trust.blind_beaten()
|
|
362
405
|
|
|
406
|
+
# Phase 3.1: fire the ante theme's per-blind-won hook (e.g. Tournoi
|
|
407
|
+
# awards bonus money, Café gives +1 trust on big-blind wins).
|
|
408
|
+
theme = self.run.get_ante_theme()
|
|
409
|
+
if theme is not None:
|
|
410
|
+
theme.on_blind_won(self.run, self.run.blind_index)
|
|
411
|
+
|
|
363
412
|
# Partner-specific trust events (skipped under Le Divorce)
|
|
364
413
|
if not lock_trust:
|
|
365
414
|
if bd.taker_team == 0 and bd.is_failed:
|
|
@@ -367,6 +416,101 @@ class BelAtroGame:
|
|
|
367
416
|
elif bd.is_capot and bd.taker_team == 0:
|
|
368
417
|
trust.capot_together()
|
|
369
418
|
|
|
419
|
+
# 3.3.0: append a BelAtro-side history entry (the [H] overlay reads
|
|
420
|
+
# `self.run.history` via the override hook installed in `start()`).
|
|
421
|
+
self._record_history_entry(
|
|
422
|
+
ante=history_ante,
|
|
423
|
+
blind_index=history_blind_index,
|
|
424
|
+
target=history_target,
|
|
425
|
+
boss=boss,
|
|
426
|
+
final_state=final_state,
|
|
427
|
+
bd=bd,
|
|
428
|
+
total=total,
|
|
429
|
+
money_delta=self.run.economy.money - money_before,
|
|
430
|
+
survived_via_insurance=survived_via_insurance,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def _record_history_entry(
|
|
434
|
+
self,
|
|
435
|
+
*,
|
|
436
|
+
ante: int,
|
|
437
|
+
blind_index: int,
|
|
438
|
+
target: int,
|
|
439
|
+
boss: object,
|
|
440
|
+
final_state: object,
|
|
441
|
+
bd: object,
|
|
442
|
+
total: int,
|
|
443
|
+
money_delta: int,
|
|
444
|
+
survived_via_insurance: bool,
|
|
445
|
+
) -> None:
|
|
446
|
+
"""Build and append one BelAtroHistoryEntry to `self.run.history`.
|
|
447
|
+
|
|
448
|
+
Pulled out of `_play_blind` so the long round body stays readable.
|
|
449
|
+
Kept private — callers should never construct entries directly.
|
|
450
|
+
"""
|
|
451
|
+
if self.run is None:
|
|
452
|
+
return
|
|
453
|
+
from .ui.history import BelAtroHistoryEntry
|
|
454
|
+
|
|
455
|
+
blind_label = ("Small", "Big", "Boss")[blind_index] if 0 <= blind_index <= 2 else "?"
|
|
456
|
+
boss_name = getattr(boss, "name", None) if boss is not None else None
|
|
457
|
+
|
|
458
|
+
taker = getattr(final_state, "taker", None)
|
|
459
|
+
if taker is None:
|
|
460
|
+
taker_label = "—"
|
|
461
|
+
else:
|
|
462
|
+
team = "NS" if taker.value % 2 == 0 else "EW"
|
|
463
|
+
taker_label = f"{taker.name[0]} ({team})"
|
|
464
|
+
|
|
465
|
+
contract_field = getattr(final_state, "contract", None)
|
|
466
|
+
trump = getattr(final_state, "trump", None)
|
|
467
|
+
if contract_field == "sans_atout":
|
|
468
|
+
contract_str = "SA"
|
|
469
|
+
elif contract_field == "tout_atout":
|
|
470
|
+
contract_str = "TA"
|
|
471
|
+
elif trump is not None and hasattr(trump, "symbol"):
|
|
472
|
+
contract_str = trump.symbol
|
|
473
|
+
else:
|
|
474
|
+
contract_str = "—"
|
|
475
|
+
|
|
476
|
+
is_capot = bool(getattr(bd, "is_capot", False))
|
|
477
|
+
taker_team = getattr(bd, "taker_team", None)
|
|
478
|
+
if total >= target and is_capot and taker_team == 0:
|
|
479
|
+
status = "CAPOT"
|
|
480
|
+
elif total >= target:
|
|
481
|
+
status = "WON"
|
|
482
|
+
elif survived_via_insurance:
|
|
483
|
+
status = "SURVIVED"
|
|
484
|
+
else:
|
|
485
|
+
status = "FAILED"
|
|
486
|
+
|
|
487
|
+
tricks_ns = int(getattr(bd, "tricks_ns", 0))
|
|
488
|
+
tricks_ew = int(getattr(bd, "tricks_ew", 0))
|
|
489
|
+
|
|
490
|
+
# Pull declaration summaries off the breakdown when present. score_round
|
|
491
|
+
# doesn't currently expose them, so this is best-effort and falls back
|
|
492
|
+
# to empty tuples — the renderer treats those as "─".
|
|
493
|
+
decl_ns: tuple[str, ...] = tuple(getattr(bd, "decl_summary_ns", ()) or ())
|
|
494
|
+
decl_ew: tuple[str, ...] = tuple(getattr(bd, "decl_summary_ew", ()) or ())
|
|
495
|
+
|
|
496
|
+
self.run.history.append(
|
|
497
|
+
BelAtroHistoryEntry(
|
|
498
|
+
ante=ante,
|
|
499
|
+
blind_label=blind_label,
|
|
500
|
+
target=target,
|
|
501
|
+
boss_name=boss_name,
|
|
502
|
+
taker_label=taker_label,
|
|
503
|
+
contract=contract_str,
|
|
504
|
+
tricks_ns=tricks_ns,
|
|
505
|
+
tricks_ew=tricks_ew,
|
|
506
|
+
score=total,
|
|
507
|
+
status=status,
|
|
508
|
+
money_delta=money_delta,
|
|
509
|
+
decl_summary_ns=decl_ns,
|
|
510
|
+
decl_summary_ew=decl_ew,
|
|
511
|
+
)
|
|
512
|
+
)
|
|
513
|
+
|
|
370
514
|
|
|
371
515
|
def main() -> None:
|
|
372
516
|
import argparse
|