belote-cli 3.0.2__tar.gz → 3.2.0__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.2.0}/.claude/settings.local.json +2 -1
- belote_cli-3.2.0/AGENT.md +12 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/CHANGELOG.md +120 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/DEVELOPMENT.md +3 -3
- {belote_cli-3.0.2 → belote_cli-3.2.0}/PKG-INFO +30 -6
- {belote_cli-3.0.2 → belote_cli-3.2.0}/README.md +29 -5
- {belote_cli-3.0.2 → belote_cli-3.2.0}/pyproject.toml +1 -1
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/__init__.py +1 -1
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/ai.py +30 -10
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/core/run_state.py +12 -1
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/core/scoring.py +12 -12
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/engine/modifier_patch.py +20 -22
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/engine/round_driver.py +13 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/jokers/hand_comp.py +7 -9
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/jokers/trick_timing.py +7 -2
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/registry.py +20 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/tarots.py +21 -9
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/main.py +11 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/run/boss.py +23 -23
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/run/shop.py +55 -20
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ui/shop.py +90 -3
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/game.py +20 -29
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/input.py +2 -2
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/replay.py +3 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/scoring.py +33 -24
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/stats.py +6 -5
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/ui/layout.py +3 -1
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/ui/render.py +14 -1
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_belatro.py +100 -4
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_boss_modifiers_integration.py +35 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_dead_flag_fixes.py +41 -5
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_phase1_plumbing.py +106 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_phase2_content.py +104 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_phase3_meta.py +28 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_round_driver.py +6 -4
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_belote.py +24 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_official_rules.py +34 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/.gitignore +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/.python-version +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/LICENSE +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/scripts/benchmark.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/a11y.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/achievements.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/ansi.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/core/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/core/economy.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/engine/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/engine/event_bus.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ghost_run.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/base.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/jokers/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/jokers/annonces.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/jokers/coinche.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/jokers/contract.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/jokers/corrupted.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/jokers/economy.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/planets.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/items/vouchers.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/partner/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/partner/partner_state.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/partner/personality.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/partner/trust.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/progression/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/progression/save.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/progression/unlocks.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/run/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/run/ante.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/run/ante_themes.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/run/decks.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/run_summary.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ui/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ui/announce.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ui/collection.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ui/hud.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ui/menu.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ui/rules.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/belatro/ui/trust_bar.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/config.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/context.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/deck.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/gameflow.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/main.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/rules.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/themes.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/ui/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/ui/announce.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/ui/menu.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/src/belote/ui/prompts.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/__init__.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_collection_logic.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_contract_unlocks.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_deck_variants.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_ghost_run.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_hud_synergy.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_partner_trust.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_phase0_coverage.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/belatro/test_progression.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_a11y.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_achievements.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_ai.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_ansi_helpers.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_extended.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_game_logic.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_gameflow.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_layout.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_new_coverage.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_properties.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/tests/test_replay.py +0 -0
- {belote_cli-3.0.2 → belote_cli-3.2.0}/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
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
You are auditing a Python codebase for bugs.
|
|
2
|
+
|
|
3
|
+
Rules:
|
|
4
|
+
1. Every finding has fields: file:line, claim, evidence, confidence (CONFIRMED|LIKELY|SPECULATIVE), severity.
|
|
5
|
+
2. CONFIRMED requires citing both the buggy code AND the code that proves the bug (e.g., the missing reader for a "dead
|
|
6
|
+
flag", the caller that passes the bad input).
|
|
7
|
+
3. Severity P0/P1 requires CONFIRMED. LIKELY findings max out at P2.
|
|
8
|
+
4. For any "X is unused/dead/never called" claim: paste `grep -rn "X" .` output. Zero non-test hits required.
|
|
9
|
+
5. For any "Y crashes" claim: name the input that triggers the crash and the call path that delivers it.
|
|
10
|
+
6. End the report with a "Findings I considered but rejected" section — at least 3 items. This forces you to
|
|
11
|
+
demonstrate you tried to falsify.
|
|
12
|
+
7. Use the tools provided (Read, Grep, Bash). Do not reason from memory about file contents.
|
|
@@ -5,6 +5,126 @@ 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.2.0] - 2026-05-10
|
|
9
|
+
|
|
10
|
+
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.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`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`.
|
|
15
|
+
- **`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`.
|
|
16
|
+
- **`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.
|
|
17
|
+
- **`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.
|
|
18
|
+
- **`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.
|
|
19
|
+
- **`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.
|
|
20
|
+
- **`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.
|
|
21
|
+
|
|
22
|
+
### Determinism
|
|
23
|
+
|
|
24
|
+
- **`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`.
|
|
25
|
+
- **`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.
|
|
26
|
+
|
|
27
|
+
### Improved
|
|
28
|
+
|
|
29
|
+
- **`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.
|
|
30
|
+
- **`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.
|
|
31
|
+
- **`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).
|
|
32
|
+
- **`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.
|
|
33
|
+
|
|
34
|
+
### Rejected (catalogued so they aren't re-investigated)
|
|
35
|
+
|
|
36
|
+
Eleven claims from the input audits were rejected after verification against the actual code:
|
|
37
|
+
|
|
38
|
+
- 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.
|
|
39
|
+
- LeFou tarot "chain broken" — `run_state.py::consume` sets `last_consumable_id` *before* `item.use()` runs, so chaining works as intended.
|
|
40
|
+
- `no_belote_rebelote` deck-mod flag — consumed at `src/belote/scoring.py:630`.
|
|
41
|
+
- `_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.
|
|
42
|
+
- `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.
|
|
43
|
+
- IllegalMoveError in `round_driver.py:291` — reachable only via test MockCallbacks; production `prompt_card` has a guard.
|
|
44
|
+
- `_card_beats` defensive `assert trump is not None` — unreachable under current contract invariants.
|
|
45
|
+
- `display_hud` no clear-to-EOL — HUD is rebuilt fresh per call; the claim was wrong.
|
|
46
|
+
- Libra planet description — "×4 instead of ×3" matches the payout; mechanism is additive per coinche level but the description references the result.
|
|
47
|
+
- `get_total()` float precision — explicit `int()` guard at scoring.py:248-249.
|
|
48
|
+
- KeyboardInterrupt save — profile is saved *before* the loop starts; only intra-run delta is lost.
|
|
49
|
+
|
|
50
|
+
### Internal
|
|
51
|
+
|
|
52
|
+
- **Tests**: 525 → 528 (+3 net: −1 test renamed/repurposed for LeDernierMot team check, +2 new for La Sentinelle partner-detection and EW opponent rejection).
|
|
53
|
+
- **Strict gates**: pytest 528/528, mypy 0 errors, ruff 0 violations across `src/` and `tests/`.
|
|
54
|
+
- **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.
|
|
55
|
+
|
|
56
|
+
## [3.1.0] - 2026-05-08
|
|
57
|
+
|
|
58
|
+
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.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- **`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`.
|
|
63
|
+
- **`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`.
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- **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`.
|
|
68
|
+
- **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`.
|
|
69
|
+
- **`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).
|
|
70
|
+
- **`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.
|
|
71
|
+
- **`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.
|
|
72
|
+
- **`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`.
|
|
73
|
+
- **`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)`.
|
|
74
|
+
|
|
75
|
+
### Improved
|
|
76
|
+
|
|
77
|
+
- **`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.
|
|
78
|
+
- **`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.
|
|
79
|
+
- **`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.
|
|
80
|
+
- **`@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`).
|
|
81
|
+
- **`src/belote/stats.py:97-98`** — `print(..., file=sys.stderr)` on save failure swapped for `logging.getLogger(__name__).warning`. Removed unused `import sys`.
|
|
82
|
+
- **`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.
|
|
83
|
+
- **`src/belote/replay.py:46`** — explanatory comment added above the `# noqa: BLE001` so the broad-except rationale is visible at the call site.
|
|
84
|
+
- **`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`.
|
|
85
|
+
|
|
86
|
+
### Removed
|
|
87
|
+
|
|
88
|
+
- **`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`.
|
|
89
|
+
|
|
90
|
+
### Internal
|
|
91
|
+
|
|
92
|
+
- **Tests**: 510 → 525 (+15).
|
|
93
|
+
- **Strict gates**: pytest 525/525, mypy 0 errors, ruff 0 violations across `src/` and `tests/`.
|
|
94
|
+
- **Audit plan**: `~/.claude/plans/bug-hunt-code-performance-sleepy-ritchie.md`.
|
|
95
|
+
|
|
96
|
+
## [3.0.3] - 2026-05-08
|
|
97
|
+
|
|
98
|
+
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.
|
|
99
|
+
|
|
100
|
+
### Fixed (documentation)
|
|
101
|
+
|
|
102
|
+
- **`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.
|
|
103
|
+
- **`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").
|
|
104
|
+
|
|
105
|
+
### Audit findings (planning only — implementation deferred)
|
|
106
|
+
|
|
107
|
+
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).
|
|
108
|
+
|
|
109
|
+
Findings tracked at `~/.claude/plans/bug-hunt-code-performance-atomic-sutton.md`:
|
|
110
|
+
- **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.
|
|
111
|
+
- **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.
|
|
112
|
+
- **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`.
|
|
113
|
+
- **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.
|
|
114
|
+
- **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`.
|
|
115
|
+
- **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.
|
|
116
|
+
- **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.
|
|
117
|
+
- **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).
|
|
118
|
+
|
|
119
|
+
### Internal
|
|
120
|
+
|
|
121
|
+
- **Tests**: 510 (unchanged).
|
|
122
|
+
- **Strict gates**: pytest 510/510, mypy 0 errors, ruff 0 violations (all unchanged from 3.0.2).
|
|
123
|
+
|
|
124
|
+
### Carried forward
|
|
125
|
+
|
|
126
|
+
- `EventBus.emit` wiring fix (P0-1 above) remains deferred. Now planned for 3.0.4 alongside the perf wins.
|
|
127
|
+
|
|
8
128
|
## [3.0.2] - 2026-05-08
|
|
9
129
|
|
|
10
130
|
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 (528 tests expected)
|
|
88
88
|
PYTHONPATH=src pytest
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
Current baseline (3.0
|
|
91
|
+
Current baseline (3.2.0):
|
|
92
92
|
- **mypy**: 0 errors (strict mode)
|
|
93
93
|
- **ruff**: 0 violations
|
|
94
|
-
- **pytest**:
|
|
94
|
+
- **pytest**: 528 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.0
|
|
3
|
+
Version: 3.2.0
|
|
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,36 @@ 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.2.0
|
|
49
|
+
|
|
50
|
+
- **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.
|
|
51
|
+
- **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.
|
|
52
|
+
- **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.
|
|
53
|
+
- **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.
|
|
54
|
+
- **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.
|
|
55
|
+
- **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.
|
|
56
|
+
- **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.
|
|
57
|
+
- **Test coverage** — 528 tests (up from 525). Strict gates still clean: pytest 528/528, mypy 0 errors, ruff 0 violations.
|
|
58
|
+
|
|
59
|
+
## What's new in 3.1.0
|
|
60
|
+
|
|
61
|
+
- **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.
|
|
62
|
+
- **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.
|
|
63
|
+
- **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.
|
|
64
|
+
- **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.
|
|
65
|
+
- **Test coverage** — 525 tests (up from 510). Strict gates still clean: pytest 525/525, mypy 0 errors, ruff 0 violations.
|
|
66
|
+
|
|
67
|
+
## What's new in 3.0.3
|
|
68
|
+
|
|
69
|
+
- **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.
|
|
70
|
+
- **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.
|
|
71
|
+
|
|
48
72
|
## What's new in 3.0.2
|
|
49
73
|
|
|
50
74
|
- **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
75
|
- **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
76
|
- **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** —
|
|
77
|
+
- **Test coverage** — 525 tests (up from 509).
|
|
54
78
|
|
|
55
79
|
## What's new in 3.0.1
|
|
56
80
|
|
|
@@ -222,7 +246,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
|
|
|
222
246
|
|
|
223
247
|
- **BelAtro Roguelite Mode:** A massive expansion featuring 36 Jokers, 12 Tarot cards, 8 Planets, 12 Vouchers, and permanent upgrades.
|
|
224
248
|
- **Collection (Almanac):** Persistent tracker to browse every Joker, Planet, and Voucher you've discovered across your runs.
|
|
225
|
-
- **Full Boss Blind Suite:** All
|
|
249
|
+
- **Full Boss Blind Suite:** All 21 unique bosses implemented, including complex mechanics like *L'Anarchie* (dynamic trump) and *La Rupture* (no consecutive wins).
|
|
226
250
|
- **Multiplier Scoring:** Use items to stack Multipliers and reach scores in the millions.
|
|
227
251
|
- **Partner Trust:** Build a relationship with your AI partner to unlock synergies.
|
|
228
252
|
- **Rich Terminal UI:** Full-screen green felt table with detailed card graphics and "You" vs "Partner" terminology.
|
|
@@ -274,7 +298,7 @@ belote/
|
|
|
274
298
|
│ ├── input.py # Platform-dispatched key reader and interruptible sleep
|
|
275
299
|
│ ├── stats.py # Global and session statistics tracking
|
|
276
300
|
│ └── rules.py # Game rules content
|
|
277
|
-
├── tests/ # Comprehensive test suite (
|
|
301
|
+
├── tests/ # Comprehensive test suite (528 tests)
|
|
278
302
|
├── scripts/ # Performance benchmarks
|
|
279
303
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
280
304
|
├── LICENSE # MIT License
|
|
@@ -290,14 +314,14 @@ belote/
|
|
|
290
314
|
PYTHONPATH=src pytest
|
|
291
315
|
```
|
|
292
316
|
|
|
293
|
-
Currently **
|
|
317
|
+
Currently **528 tests** passing with 100% coverage on game-logic modules.
|
|
294
318
|
|
|
295
319
|
## Technical Integrity
|
|
296
320
|
|
|
297
321
|
The codebase is strictly validated with the following tools:
|
|
298
322
|
- **mypy**: 0 errors (strict type safety)
|
|
299
323
|
- **ruff**: 0 violations (linting & formatting)
|
|
300
|
-
- **pytest**:
|
|
324
|
+
- **pytest**: 528/528 passed
|
|
301
325
|
- **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
|
|
302
326
|
- **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
|
|
303
327
|
|
|
@@ -2,12 +2,36 @@
|
|
|
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.2.0
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
- **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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- **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.
|
|
12
|
+
- **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.
|
|
13
|
+
- **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.
|
|
14
|
+
- **Test coverage** — 528 tests (up from 525). Strict gates still clean: pytest 528/528, mypy 0 errors, ruff 0 violations.
|
|
15
|
+
|
|
16
|
+
## What's new in 3.1.0
|
|
17
|
+
|
|
18
|
+
- **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.
|
|
19
|
+
- **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.
|
|
20
|
+
- **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.
|
|
21
|
+
- **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.
|
|
22
|
+
- **Test coverage** — 525 tests (up from 510). Strict gates still clean: pytest 525/525, mypy 0 errors, ruff 0 violations.
|
|
23
|
+
|
|
24
|
+
## What's new in 3.0.3
|
|
25
|
+
|
|
26
|
+
- **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.
|
|
27
|
+
- **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.
|
|
28
|
+
|
|
5
29
|
## What's new in 3.0.2
|
|
6
30
|
|
|
7
31
|
- **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
32
|
- **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
33
|
- **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** —
|
|
34
|
+
- **Test coverage** — 525 tests (up from 509).
|
|
11
35
|
|
|
12
36
|
## What's new in 3.0.1
|
|
13
37
|
|
|
@@ -179,7 +203,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
|
|
|
179
203
|
|
|
180
204
|
- **BelAtro Roguelite Mode:** A massive expansion featuring 36 Jokers, 12 Tarot cards, 8 Planets, 12 Vouchers, and permanent upgrades.
|
|
181
205
|
- **Collection (Almanac):** Persistent tracker to browse every Joker, Planet, and Voucher you've discovered across your runs.
|
|
182
|
-
- **Full Boss Blind Suite:** All
|
|
206
|
+
- **Full Boss Blind Suite:** All 21 unique bosses implemented, including complex mechanics like *L'Anarchie* (dynamic trump) and *La Rupture* (no consecutive wins).
|
|
183
207
|
- **Multiplier Scoring:** Use items to stack Multipliers and reach scores in the millions.
|
|
184
208
|
- **Partner Trust:** Build a relationship with your AI partner to unlock synergies.
|
|
185
209
|
- **Rich Terminal UI:** Full-screen green felt table with detailed card graphics and "You" vs "Partner" terminology.
|
|
@@ -231,7 +255,7 @@ belote/
|
|
|
231
255
|
│ ├── input.py # Platform-dispatched key reader and interruptible sleep
|
|
232
256
|
│ ├── stats.py # Global and session statistics tracking
|
|
233
257
|
│ └── rules.py # Game rules content
|
|
234
|
-
├── tests/ # Comprehensive test suite (
|
|
258
|
+
├── tests/ # Comprehensive test suite (528 tests)
|
|
235
259
|
├── scripts/ # Performance benchmarks
|
|
236
260
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
237
261
|
├── LICENSE # MIT License
|
|
@@ -247,14 +271,14 @@ belote/
|
|
|
247
271
|
PYTHONPATH=src pytest
|
|
248
272
|
```
|
|
249
273
|
|
|
250
|
-
Currently **
|
|
274
|
+
Currently **528 tests** passing with 100% coverage on game-logic modules.
|
|
251
275
|
|
|
252
276
|
## Technical Integrity
|
|
253
277
|
|
|
254
278
|
The codebase is strictly validated with the following tools:
|
|
255
279
|
- **mypy**: 0 errors (strict type safety)
|
|
256
280
|
- **ruff**: 0 violations (linting & formatting)
|
|
257
|
-
- **pytest**:
|
|
281
|
+
- **pytest**: 528/528 passed
|
|
258
282
|
- **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
|
|
259
283
|
- **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
|
|
260
284
|
|
|
@@ -490,12 +490,31 @@ class AIPlayer:
|
|
|
490
490
|
)
|
|
491
491
|
partner_winning = current_winner is not None and current_winner == p
|
|
492
492
|
|
|
493
|
+
# Precompute per-call counters used by every scoring branch — pre-3.1.0
|
|
494
|
+
# these were recomputed per candidate card (n×4 walks of the hand and
|
|
495
|
+
# memory.played for each legal card).
|
|
496
|
+
from collections import Counter
|
|
497
|
+
|
|
498
|
+
my_hand = state.hand_of(self.seat)
|
|
499
|
+
hand_suit_counts: dict[Suit, int] = Counter(c.suit for c in my_hand)
|
|
500
|
+
my_trumps = hand_suit_counts.get(trump, 0)
|
|
501
|
+
opp_trumps = 8 - sum(1 for c in self.memory.played if c.suit == trump)
|
|
502
|
+
|
|
493
503
|
# Score each legal card by expected outcome
|
|
494
504
|
best_card = legal[0]
|
|
495
505
|
best_score: float = -999.0
|
|
496
506
|
|
|
497
507
|
for card in legal:
|
|
498
|
-
score = self._score_card_play(
|
|
508
|
+
score = self._score_card_play(
|
|
509
|
+
card,
|
|
510
|
+
state,
|
|
511
|
+
trump,
|
|
512
|
+
trick,
|
|
513
|
+
partner_winning,
|
|
514
|
+
hand_suit_counts,
|
|
515
|
+
my_trumps,
|
|
516
|
+
opp_trumps,
|
|
517
|
+
)
|
|
499
518
|
if score > best_score:
|
|
500
519
|
best_score = score
|
|
501
520
|
best_card = card
|
|
@@ -509,6 +528,9 @@ class AIPlayer:
|
|
|
509
528
|
trump: Suit,
|
|
510
529
|
trick: tuple[TrickCard, ...],
|
|
511
530
|
partner_winning: bool,
|
|
531
|
+
hand_suit_counts: dict[Suit, int],
|
|
532
|
+
my_trumps: int,
|
|
533
|
+
opp_trumps: int,
|
|
512
534
|
) -> float:
|
|
513
535
|
"""Score a card play decision with advanced heuristics."""
|
|
514
536
|
score = 0.0
|
|
@@ -517,20 +539,20 @@ class AIPlayer:
|
|
|
517
539
|
score += points * 0.1
|
|
518
540
|
|
|
519
541
|
if not trick:
|
|
520
|
-
return self._score_leading_strategy(card,
|
|
542
|
+
return self._score_leading_strategy(card, trump, my_trumps, opp_trumps)
|
|
521
543
|
|
|
522
544
|
if partner_winning and trick[0].card.suit != trump:
|
|
523
|
-
return self._score_discarding_strategy(card,
|
|
545
|
+
return self._score_discarding_strategy(card, trump, points, hand_suit_counts)
|
|
524
546
|
|
|
525
547
|
return self._score_winning_strategy(card, state, trump, trick, partner_winning, points)
|
|
526
548
|
|
|
527
|
-
def _score_leading_strategy(
|
|
549
|
+
def _score_leading_strategy(
|
|
550
|
+
self, card: Card, trump: Suit, my_trumps: int, opp_trumps: int
|
|
551
|
+
) -> float:
|
|
528
552
|
"""Heuristics for when we are leading the trick."""
|
|
529
553
|
score = 0.0
|
|
530
554
|
if card.suit == trump:
|
|
531
555
|
# 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
556
|
if opp_trumps > my_trumps:
|
|
535
557
|
score += 4
|
|
536
558
|
else:
|
|
@@ -543,7 +565,7 @@ class AIPlayer:
|
|
|
543
565
|
return score
|
|
544
566
|
|
|
545
567
|
def _score_discarding_strategy(
|
|
546
|
-
self, card: Card,
|
|
568
|
+
self, card: Card, trump: Suit, points: int, hand_suit_counts: dict[Suit, int]
|
|
547
569
|
) -> float:
|
|
548
570
|
"""Heuristics for when partner is winning and we can discard."""
|
|
549
571
|
score = 0.0
|
|
@@ -551,9 +573,7 @@ class AIPlayer:
|
|
|
551
573
|
score -= points * 0.7 # Penalize throwing away points
|
|
552
574
|
|
|
553
575
|
# 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:
|
|
576
|
+
if hand_suit_counts.get(card.suit, 0) == 1:
|
|
557
577
|
score += 3
|
|
558
578
|
|
|
559
579
|
# Prefer keeping cards that partner is void in (to trump later)
|
|
@@ -70,6 +70,12 @@ class BelAtroRun:
|
|
|
70
70
|
# ── Last consumable used (read by LeFou tarot) ─────────
|
|
71
71
|
last_consumable_id: str | None = None
|
|
72
72
|
|
|
73
|
+
# ── Last tarot status message ──────────────────────────
|
|
74
|
+
# Set by tarots that need to surface a non-fatal failure (e.g. LeJugement
|
|
75
|
+
# rolling a joker when joker_slots are full). The UI may read and display
|
|
76
|
+
# this; tests assert it. Cleared whenever a new tarot is used.
|
|
77
|
+
last_tarot_message: str | None = None
|
|
78
|
+
|
|
73
79
|
# ── Determinism ────────────────────────────────────────
|
|
74
80
|
seed: int | None = None
|
|
75
81
|
_rng: Any = None
|
|
@@ -178,10 +184,15 @@ class BelAtroRun:
|
|
|
178
184
|
self.endless_ante_offset += 1
|
|
179
185
|
self.blind_index = 0
|
|
180
186
|
return
|
|
181
|
-
# Standard run completion.
|
|
187
|
+
# Standard run completion. Set both flags so the terminal-state
|
|
188
|
+
# invariant (run_over ⇔ run is over, run_won ⇔ run is over AND we won)
|
|
189
|
+
# is consistent; main.py's loop break still handles the exit but
|
|
190
|
+
# downstream callers can now rely on run_over alone.
|
|
182
191
|
self.run_won = True
|
|
192
|
+
self.run_over = True
|
|
183
193
|
|
|
184
194
|
def enter_endless(self) -> None:
|
|
185
195
|
"""Toggle endless mode after beating ante 8."""
|
|
186
196
|
self.endless = True
|
|
187
197
|
self.run_won = False # endless overrides run-won state
|
|
198
|
+
self.run_over = False # ...and re-opens the run so the main loop continues
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from dataclasses import dataclass, field
|
|
3
|
+
from dataclasses import dataclass, field, replace
|
|
4
4
|
from typing import TYPE_CHECKING, Any
|
|
5
5
|
|
|
6
6
|
if TYPE_CHECKING:
|
|
@@ -28,7 +28,7 @@ _SUIT_TO_CONTRACT: dict[Suit, str] = {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
@dataclass
|
|
31
|
+
@dataclass(slots=True)
|
|
32
32
|
class ScoreAccumulator:
|
|
33
33
|
"""
|
|
34
34
|
Accumulates Chips and Mult across one round,
|
|
@@ -65,7 +65,6 @@ class ScoreAccumulator:
|
|
|
65
65
|
new_chips = state._chips + self.permanent_chips
|
|
66
66
|
new_mult = state._mult * self.permanent_mult if self.permanent_mult != 1.0 else state._mult
|
|
67
67
|
|
|
68
|
-
from dataclasses import replace
|
|
69
68
|
return replace(state, _joker_state=joker_state, _chips=new_chips, _mult=new_mult)
|
|
70
69
|
|
|
71
70
|
def update_state(self, state: GameState, event: object) -> GameState:
|
|
@@ -73,12 +72,11 @@ class ScoreAccumulator:
|
|
|
73
72
|
new_chips = state._chips
|
|
74
73
|
new_mult = state._mult
|
|
75
74
|
new_money = state._bonus_money
|
|
76
|
-
#
|
|
77
|
-
# (
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
joker_state = copy.deepcopy(state._joker_state)
|
|
75
|
+
# Shallow copy is sufficient: every value written into _joker_state
|
|
76
|
+
# is a scalar (bool / int / str). The pre-3.1.0 deepcopy ran on every
|
|
77
|
+
# event (~20×/round) — see test_joker_state_only_contains_scalar_values
|
|
78
|
+
# in tests/belatro/test_phase1_plumbing.py for the locking invariant.
|
|
79
|
+
joker_state = dict(state._joker_state)
|
|
82
80
|
|
|
83
81
|
def _apply(result: JokerResult, source: str) -> None:
|
|
84
82
|
nonlocal new_chips, new_mult, new_money
|
|
@@ -235,7 +233,6 @@ class ScoreAccumulator:
|
|
|
235
233
|
_fire_jokers("on_bid", event)
|
|
236
234
|
|
|
237
235
|
# Update GameState with new values
|
|
238
|
-
from dataclasses import replace
|
|
239
236
|
return replace(
|
|
240
237
|
state,
|
|
241
238
|
_chips=new_chips,
|
|
@@ -245,8 +242,11 @@ class ScoreAccumulator:
|
|
|
245
242
|
)
|
|
246
243
|
|
|
247
244
|
def get_total(self, state: GameState) -> int:
|
|
248
|
-
#
|
|
249
|
-
chips
|
|
245
|
+
# Clamp at 0: corrupted jokers (L'Égoïste in particular) can subtract
|
|
246
|
+
# chips per trick won by partner, and with enough partner tricks the
|
|
247
|
+
# running total can go negative, producing a negative final score.
|
|
248
|
+
# Final score should never be negative — clamp at the scoring boundary.
|
|
249
|
+
chips: int = max(0, state._chips)
|
|
250
250
|
mult: float = state._mult
|
|
251
251
|
if mult == float(int(mult)):
|
|
252
252
|
return chips * int(mult)
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import dataclasses
|
|
3
4
|
from typing import Any
|
|
4
5
|
|
|
5
|
-
from belote.game import GameState
|
|
6
|
+
from belote.game import BossModifiers, GameState
|
|
7
|
+
|
|
8
|
+
# Derived from BossModifiers so new flags added there are picked up
|
|
9
|
+
# automatically; previously this was a hardcoded set that silently no-op'd
|
|
10
|
+
# any new boss field not added here in lockstep.
|
|
11
|
+
_BOSS_FIELDS: frozenset[str] = frozenset(f.name for f in dataclasses.fields(BossModifiers))
|
|
6
12
|
|
|
7
13
|
|
|
8
14
|
class PatchedGameState:
|
|
@@ -18,22 +24,21 @@ class PatchedGameState:
|
|
|
18
24
|
# ── Patch registration ──────────────────────────────────────────────
|
|
19
25
|
|
|
20
26
|
def patch(self, attr: str, value: Any) -> None:
|
|
21
|
-
"""Override a specific attribute for this round.
|
|
22
|
-
if attr.startswith("_"):
|
|
23
|
-
# Strip leading underscore if it was from the old system
|
|
24
|
-
attr = attr[1:]
|
|
27
|
+
"""Override a specific attribute for this round.
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
Boss field names are unprefixed (e.g. "no_belote", not "_no_belote").
|
|
30
|
+
The 3.0.x backward-compat shim that stripped a leading underscore was
|
|
31
|
+
removed in 3.1.0 — call sites in `run/boss.py` were rewritten in lock-
|
|
32
|
+
step. The `getattr(state, "_X", False)` reading anti-pattern is locked
|
|
33
|
+
against in tests/belatro/test_boss_modifiers_integration.py
|
|
34
|
+
`test_invariant_no_underscore_boss_attrs`.
|
|
35
|
+
"""
|
|
36
|
+
assert not attr.startswith("_"), (
|
|
37
|
+
f"patch() received leading-underscore attr {attr!r}; the 3.0.x shim "
|
|
38
|
+
"was removed in 3.1.0 — use the unprefixed boss field name."
|
|
39
|
+
)
|
|
35
40
|
|
|
36
|
-
if attr in
|
|
41
|
+
if attr in _BOSS_FIELDS:
|
|
37
42
|
current_bm = self.boss_modifiers
|
|
38
43
|
from belote.game import replace
|
|
39
44
|
new_bm = replace(current_bm, **{attr: value})
|
|
@@ -47,13 +52,6 @@ class PatchedGameState:
|
|
|
47
52
|
patches = object.__getattribute__(self, "_patches")
|
|
48
53
|
if name in patches:
|
|
49
54
|
return patches[name]
|
|
50
|
-
|
|
51
|
-
# Backward compatibility for old underscored names
|
|
52
|
-
if name.startswith("_"):
|
|
53
|
-
stripped = name[1:]
|
|
54
|
-
if hasattr(self.boss_modifiers, stripped):
|
|
55
|
-
return getattr(self.boss_modifiers, stripped)
|
|
56
|
-
|
|
57
55
|
return getattr(object.__getattribute__(self, "_state"), name)
|
|
58
56
|
|
|
59
57
|
def __setattr__(self, name: str, value: Any) -> None:
|