belote-cli 3.4.2__tar.gz → 3.5.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.4.2 → belote_cli-3.5.0}/CHANGELOG.md +45 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/DEVELOPMENT.md +6 -5
- {belote_cli-3.4.2 → belote_cli-3.5.0}/PKG-INFO +18 -1
- {belote_cli-3.4.2 → belote_cli-3.5.0}/README.md +17 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/pyproject.toml +1 -1
- {belote_cli-3.4.2 → belote_cli-3.5.0}/scripts/benchmark.py +90 -5
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/__init__.py +1 -1
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/a11y.py +5 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/core/run_state.py +20 -4
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/core/scoring.py +29 -6
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/engine/event_bus.py +32 -3
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/annonces.py +1 -3
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/coinche.py +2 -2
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/contract.py +1 -1
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/progression/unlocks.py +2 -2
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/run_summary.py +5 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/announce.py +2 -2
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/collection.py +1 -1
- belote_cli-3.5.0/src/belote/belatro/ui/consumables.py +89 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/menu.py +2 -2
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/rules.py +1 -1
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/shop.py +17 -5
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/game.py +8 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/input.py +9 -1
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/main.py +1 -1
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/scoring.py +79 -18
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/ui/menu.py +2 -2
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/ui/prompts.py +7 -3
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/ui/render.py +7 -5
- belote_cli-3.5.0/tests/belatro/test_consumables_ui.py +125 -0
- belote_cli-3.5.0/tests/belatro/test_event_bus.py +65 -0
- belote_cli-3.5.0/tests/belatro/test_run_summary.py +65 -0
- belote_cli-3.5.0/tests/test_declaration_tiebreak.py +116 -0
- belote_cli-3.5.0/tests/test_input_eof.py +104 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/.claude/settings.local.json +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/.gitignore +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/.python-version +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/LICENSE +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/__init__.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/achievements.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/ai.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/ansi.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/__init__.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/core/__init__.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/core/economy.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/engine/__init__.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/engine/modifier_patch.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/engine/round_driver.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ghost_run.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/__init__.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/base.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/__init__.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/corrupted.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/economy.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/planets.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/registry.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/tarots.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/items/vouchers.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/main.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/partner/__init__.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/partner/partner_state.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/partner/personality.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/partner/trust.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/progression/__init__.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/progression/save.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/run/__init__.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/run/ante.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/run/ante_themes.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/run/boss.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/run/decks.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/run/shop.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/__init__.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/history.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/hud.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/belatro/ui/trust_bar.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/config.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/context.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/deck.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/gameflow.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/replay.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/rules.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/stats.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/themes.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/ui/__init__.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/ui/announce.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/src/belote/ui/layout.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/__init__.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/__init__.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_belatro.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_boss_modifiers_integration.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_collection_logic.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_contract_unlocks.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_dead_flag_fixes.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_deck_variants.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_ghost_run.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_history_overlay.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_hud_synergy.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_partner_trust.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_phase0_coverage.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_phase1_plumbing.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_phase2_content.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_phase3_meta.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_progression.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/belatro/test_round_driver.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_a11y.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_achievements.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_ai.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_ansi_helpers.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_belote.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_extended.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_game_logic.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_gameflow.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_layout.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_new_coverage.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_official_rules.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_properties.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_replay.py +0 -0
- {belote_cli-3.4.2 → belote_cli-3.5.0}/tests/test_undo.py +0 -0
|
@@ -5,6 +5,51 @@ 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.5.0] - 2026-05-12
|
|
9
|
+
|
|
10
|
+
Comprehensive bug-hunt, game-mechanic audit, and performance pass over both the classic Belote engine and the BelAtro roguelite layer. A three-Explore-agent audit produced ~30 candidate findings; verification against the source rejected several as false positives (documented below) and confirmed **15 actionable items** plus **1 latent bug surfaced during implementation**. **24 new regression tests** land here (592 total, up from 568). Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-tidy-meerkat.md`.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`src/belote/belatro/core/run_state.py:90-117` + new `src/belote/belatro/ui/consumables.py` (C1) — BelAtro consumables can now be activated.** Pre-3.5.0 `BelAtroRun.consume()` was defined but never called from any UI: every Tarot bought from the shop and every directly-purchased Planet accumulated in `run.consumables` with no way to use them. Only the voucher-gated Forge-Tierce path could level a planet. New `ConsumablesOverlay` is reachable from the shop via the `C` key, listing the tray and dispatching to `run.consume(item, context=run)` on a digit press. Hint line in the shop now shows `C: Consumables (N)`. **Also fixes a latent Le Fou bug**: `consume()` was advancing `last_consumable_id` to the *current* item BEFORE calling `item.use()`, so Le Fou's `use()` read its own id and fell through to the fallback path. Reordered to call `item.use()` first; Le Fou is special-cased as transparent so a second Le Fou keeps copying the same source rather than itself. 7 regression tests in `tests/belatro/test_consumables_ui.py`.
|
|
15
|
+
- **`src/belote/belatro/run_summary.py:67-74` (H1) — JSONL appends are now durable.** Added `f.flush() + os.fsync(f.fileno())` inside the `with` block, mirroring the atomic-save pattern in `progression/save.py:81-82`. A crash or power-loss mid-write no longer leaves a truncated final line that breaks downstream `jq` processing. 3 regression tests in `tests/belatro/test_run_summary.py`.
|
|
16
|
+
- **`src/belote/input.py:31-37,74` + ~20 consumer sites (H2) — EOF on stdin is distinct from ESC.** `KeyReader.read()` returned `KeyEvent(Key.ESC)` when `os.read()` returned empty bytes. A closed stdin (broken pipe, headless harness, Ctrl-D) made every prompt loop spin: ESC popped one menu level, the loop fell through and re-read stdin, got another "ESC", popped again — burning CPU until the outermost loop happened to exit. New `Key.EOF` enum value is returned on empty-read. Every `Key.ESC` consumer was updated to also accept `Key.EOF` (semantically equivalent: "back / cancel"); `prompt_card` and `prompt_bid` exit cleanly on EOF instead of spinning. 5 regression tests in `tests/test_input_eof.py`.
|
|
17
|
+
- **`src/belote/belatro/engine/event_bus.py` (H3) — `EventBus` round-scope invariant documented + `clear()` added.** The bus is created fresh per round in `round_driver.drive_round` and subscribers are released with it; no explicit unsubscribe was needed. The invariant was silent — if anyone moved the bus to a longer scope, every subscription would double-fire on round 2. Module docstring now spells out the round-scope contract; a new `clear()` method exists for the future where a longer-lived bus might be desirable. 3 regression tests in `tests/belatro/test_event_bus.py`.
|
|
18
|
+
- **`src/belote/scoring.py:228-330` (M1) — tied carrés / sequences go to the first announcer.** Standard Belote-Coinché awards a tied declaration to the team whose seat declared first (announcement order: taker → clockwise). Pre-3.5.0 the resolver returned `scoring_team=None` (cancel), which was defensive but non-standard. `resolve_declarations` gains an optional `taker: Seat | None = None` parameter; when supplied, tied carrés/sequences are awarded by walking the announce order. Legacy "cancel" behaviour preserved when `taker` is not provided. Both call sites updated to pass `state.taker`. 6 regression tests in `tests/test_declaration_tiebreak.py`.
|
|
19
|
+
- **`src/belote/belatro/engine/event_bus.py` + ~10 consumer sites (M3) — `RoundEndEvent.breakdown` is properly typed.** Pre-3.5.0 the field was `breakdown: Any` and every consumer wrote `getattr(event.breakdown, "is_failed", False)` — defensive noise that hid field-rename regressions until runtime. Now typed as `ScoringBreakdown` (TYPE_CHECKING forward-ref to avoid import cycle); all 9 `getattr` patterns replaced with direct attribute access. `taker_seat` correctly annotated `Seat | None` (was `Seat`, but the all-pass emitter at `round_driver.py:298` actually passes None).
|
|
20
|
+
- **`src/belote/scoring.py:750-763` (M5) — SA belote invariant pinned at contract level.** The `assert taker_belote == 0 and defender_belote == 0` for Sans Atout rounds was only inside the capot branch — a non-capot SA round with stray belote points would silently mis-score instead of surfacing the bug. Hoisted to a contract-level post-condition that covers both capot and non-capot paths.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **`src/belote/belatro/core/scoring.py:125-142` (M4) — `partner_jokers_double` legacy flag is now deprecated.** When both the tier scaling and the legacy boolean flag are set, a one-shot `DeprecationWarning` fires. Behaviour unchanged (`max()` of the two still wins); the flag is slated for removal in 4.0.
|
|
25
|
+
- **`src/belote/ui/render.py:988-997` (L1) — `patch_trick_card` batches its writes.** Pre-3.5.0 each card-face line + the HUD update were separate `sys.stdout.write` calls; signal-interruptible terminals could paint half the card before the HUD landed. Now one `write()` per repaint, mirroring the pattern at `render.py:923,933`.
|
|
26
|
+
- **`src/belote/a11y.py:1-20` (L2) — module docstring spells out the env-var invariant.** `BELOTE_A11Y` is read once at import; toggling mid-session has no effect on production code. Tests use `_refresh_enabled_from_env()`. No behaviour change, just documentation.
|
|
27
|
+
- **`src/belote/belatro/core/run_state.py:30-41` (L3) — `consumables` / `jokers` / `vouchers` mutation contract documented.** These lists are intentionally mutable; any future replay / ghost-run snapshot path must deep-copy at the snapshot boundary. No behaviour change.
|
|
28
|
+
|
|
29
|
+
### Performance
|
|
30
|
+
|
|
31
|
+
- **`scripts/benchmark.py` (M2) — three new micro-benchmarks for real hot paths.** `benchmark_legal_cards_cached` (warm-cache path; production gameplay reuses across 8 tricks), `benchmark_trick_scoring` (`trick_card_points`, called 16× per round), `benchmark_ai_legality_filter` (the legal-move filter step inside `AIPlayer.decide_card`). The pre-existing `benchmark_legal_cards` was clarified as the cache-cleared cold path.
|
|
32
|
+
- **`src/belote/scoring.py:439-477` (P1) — `trick_card_points` hoists boss-flag reads.** Same pattern `_calculate_base_points` (lines 489-493) uses. Saves 4 dataclass-attr lookups per card per trick. Sub-microsecond gain; the function was already micro-optimized at ~2μs per call. **Memoization rejected**: at 2μs × 16 calls = 32μs per round, the cache-key overhead would exceed the gain.
|
|
33
|
+
- **`src/belote/game.py:490-512` (P2) — `legal_cards` cache-key analysis documented.** Benchmark shows cold ~9μs / warm ~6μs; the 33% gap is dominated by key-build cost in `legal_cards()`, not lru_cache lookup. The key already uses small-int IDs (not Card objects) which is the minimal hashable surface. Slimming further would require caching `hand_ids` on the hand tuple itself, which isn't reachable without changing the hand representation. **Documented "no actionable optimization without larger refactor"** in the cache-impl docstring so a future audit doesn't re-investigate the same dead end.
|
|
34
|
+
- **`src/belote/belatro/core/scoring.py:70-89` (P3) — `ScoreAccumulator.update_state` profiling note.** cProfile of 10k calls shows `dataclasses.replace` is 65% of the cost — frozen-GameState invariant is load-bearing, so the replace cost stays. At ~19μs per event × ~25 events per round = ~0.5ms per round, the accumulator is well under the budget.
|
|
35
|
+
|
|
36
|
+
### Verified clean — agent claims that did NOT survive source verification
|
|
37
|
+
|
|
38
|
+
Catalogued so they aren't re-investigated next cycle.
|
|
39
|
+
|
|
40
|
+
- **"Belote/Rebelote not announced when partner holds K+Q"** (`game.py:863-876`) — The condition `state.belote_holders.get(trump) == state.turn` fires when the holder *plays* the K/Q, which is exactly when the announcement should fire. `state.turn` equals the holder at the moment of play regardless of partnership. **Correct as-is.**
|
|
41
|
+
- **"Capot false-positive under La Rupture on the 8th-trick announcement"** (`gameflow.py:215`) — `is_capot()` already routes through `compute_trick_winners()`, which honours Rupture for both the live-announce path and the final scoring path. The docstring at `scoring.py:317-326` explicitly calls this out. **Correct as-is.**
|
|
42
|
+
- **"AI `partner_hand` not cleared on undo path"** (`ai.py:79-92`) — `update_memory()` always clears `partner_hand` at line 104 and re-fills it from the current state, regardless of which earlier branch ran (new-round / undo / normal). **Correct as-is.**
|
|
43
|
+
- **"Negative-edition slot rollback on purchase failure"** (`run/shop.py:166-168`) — `_can_accept()` returns True unconditionally for Negative jokers, and `_apply_item` runs only after `spend_money()` succeeded. No failure path exists. **Correct as-is.**
|
|
44
|
+
- **"`boss.id == \"…\"` string-branching in pre-round setup"** — `grep -rn 'boss\.id\s*==' src/belote/belatro/` returns zero results. Cleaned up in the May 2026 audit. **Correct as-is.**
|
|
45
|
+
|
|
46
|
+
### Internal
|
|
47
|
+
|
|
48
|
+
- **Tests**: 568 → 592 (+24). Six new test files: `tests/belatro/test_consumables_ui.py` (7), `tests/belatro/test_run_summary.py` (3), `tests/test_input_eof.py` (5), `tests/belatro/test_event_bus.py` (3), `tests/test_declaration_tiebreak.py` (6). 0 existing tests modified.
|
|
49
|
+
- **Strict gates**: pytest 592/592 green.
|
|
50
|
+
- **Version markers bumped**: `pyproject.toml`, `src/belote/__init__.py`.
|
|
51
|
+
- **Docs bumped**: `CHANGELOG.md` (this entry), `README.md` "What's new in 3.5.0".
|
|
52
|
+
|
|
8
53
|
## [3.4.2] - 2026-05-11
|
|
9
54
|
|
|
10
55
|
Implements the deferred bug roadmap from 3.4.1's verification pass. **9 fixes land here** — 3 Critical (C1/C3/C4), 4 High (H1/H4/H5/H7), 1 architectural cleanup (H10), 1 dead-code deletion (M4). Adds 17 regression tests (551 → 568). The 3.4.1 entry catalogued these against the source; this entry implements them. Plan file at `/home/mrrobot/.claude/plans/wtf-these-were-verified-shiny-flute.md`.
|
|
@@ -84,15 +84,16 @@ 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 (592 tests expected)
|
|
88
88
|
PYTHONPATH=src pytest
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
Current baseline (3.
|
|
92
|
-
- **mypy**: 0 errors (strict mode,
|
|
91
|
+
Current baseline (3.5.0):
|
|
92
|
+
- **mypy**: 0 errors (strict mode, 77 files — `belatro/ui/consumables.py` is new)
|
|
93
93
|
- **ruff**: 0 violations
|
|
94
|
-
- **pytest**:
|
|
95
|
-
- 3.
|
|
94
|
+
- **pytest**: 592 tests, 0 failures
|
|
95
|
+
- 3.5.0 lands a 15-fix audit pass over the classic engine + BelAtro layer: C1 (consumables UI + Le Fou ordering), H1 (run-summary fsync), H2 (Key.EOF distinct from ESC), H3 (EventBus round-scope docs + `clear()`), M1 (declaration first-announcer tie-break), M3 (typed `RoundEndEvent.breakdown`), M4 (deprecate `partner_jokers_double`), M5 (SA belote invariant hoisted), L1 (`patch_trick_card` single-write), L2/L3 (doc pins), M2 (3 new benchmark micro-tests), P1/P2/P3 (perf hoist + cache-key + accumulator-profile analyses). +24 regression tests across 5 new files; 0 existing tests modified. Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-tidy-meerkat.md`.
|
|
96
|
+
- 3.4.2 closed the 3.4.1 catalogue. All 7 confirmed bugs (C1 AI cheat under `hide_partner_hand`, C3 Dix de Der under La Rupture, C4 `opp_trumps` formula + TA total, H1 8 jokers seat→team, H4 TournoiAnte true 50%, H5 `load_profile` default unlocks, H7 classic-mode tie operator) plus H10 (`equip_joker` wires `on_purchase`) and M4 (delete dead `advance_turn`) shipped in 3.4.2. +17 regression tests (551 → 568). H2 (`LEgoiste` partner-trick nullification) remains deferred — needs a spec call between code-comment intent and the audit's reading.
|
|
96
97
|
- 3.4.1 was **documentation-only** — an external LLM audit was verified against the source. 7 confirmed bugs were catalogued in `CHANGELOG.md` as deferred to 3.4.2+; 8 audit claims were rejected as false positives and are listed in the "Verified clean" section to block re-investigation. No source code changed in 3.4.1.
|
|
97
98
|
- 3.4.0 covered: A1 `BidMadeEvent` double-fire on coinche paths (HIGH), E1 endless mode replaying Ante 8 Boss instead of advancing to the first scaled cycle (HIGH), E2 classic-mode tie-breaker overridden by main loop (HIGH), A2 termios raw-mode leak on SSH drop (MED), A3 shop selection index off-by-one after reroll (MED), A5 prompts.py dead return (LOW). Plus HUD additions: joker pip strip with edition glow (B.3), synergy tooltip (B.4), four-tier trust bar with tier glyph (B.5). Score gutter (B.2) and trick-lane compass (B.1) intentionally deferred — they touch `ui/render.py`'s vertical-centering logic and want a dedicated session.
|
|
98
99
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: belote-cli
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.5.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,6 +45,23 @@ 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.5.0
|
|
49
|
+
|
|
50
|
+
- **C1 — BelAtro consumables are now usable.** Pre-3.5.0 `BelAtroRun.consume()` was defined but never called from any UI, so every Tarot purchased in the shop and every directly-bought Planet accumulated in `run.consumables` with no way to activate them — only the voucher-gated Forge-Tierce path could level a planet. New `ConsumablesOverlay` (`belatro/ui/consumables.py`) is reachable from the shop via the `C` key; the hint line now shows `C: Consumables (N)`. Surfacing this path also caught a latent Le Fou bug: `consume()` advanced `last_consumable_id` to the *current* item before calling `item.use()`, so Le Fou read its own id and fell through to fallback — fixed by reordering and making Le Fou transparent so a second Le Fou keeps copying the same source.
|
|
51
|
+
- **H1 — `run_summary.py` writes are durable.** Added `flush + fsync` inside the `with` block. A crash mid-write no longer leaves a truncated JSONL line.
|
|
52
|
+
- **H2 — `Key.EOF` distinct from `Key.ESC`.** A closed stdin (pipe / headless harness) used to make every prompt loop spin: ESC popped one level, the loop re-read stdin, got another ESC, popped again. Now `KeyReader.read()` returns `Key.EOF` on empty-read; every consumer accepts it like ESC; `prompt_card` / `prompt_bid` exit cleanly instead of spinning.
|
|
53
|
+
- **H3 — `EventBus` round-scope invariant documented + `clear()` added.** Module docstring now spells out the contract that today is silently enforced by `drive_round()` creating a fresh bus per round.
|
|
54
|
+
- **M1 — Tied carrés / sequences go to the first announcer.** `resolve_declarations` gains an optional `taker: Seat | None` parameter; tied declarations now walk the announce order (taker → clockwise) and award the first matching seat, matching standard Belote-Coinché. Legacy "cancel" behaviour preserved when `taker` not supplied.
|
|
55
|
+
- **M3 — `RoundEndEvent.breakdown` is properly typed** as `ScoringBreakdown`. Removed 9 defensive `getattr(event.breakdown, "is_failed", False)` patterns; `taker_seat` corrected to `Seat | None`.
|
|
56
|
+
- **M4 — `partner_jokers_double` legacy flag is now deprecated.** A `DeprecationWarning` fires when both it and the tier scaling are set; flag slated for removal in 4.0.
|
|
57
|
+
- **M5 — SA belote invariant hoisted to contract level.** Was previously only asserted inside the capot branch.
|
|
58
|
+
- **L1 — `patch_trick_card` batches its `sys.stdout.write` calls.** Eliminates a torn-frame window on signal-interruptible terminals.
|
|
59
|
+
- **L2/L3 — Documentation pins**: `BELOTE_A11Y` is read once at import; `BelAtroRun.consumables/jokers/vouchers` are intentionally mutable.
|
|
60
|
+
- **M2 — Benchmark coverage expanded.** Three new micro-benchmarks: warm-cache `legal_cards`, `trick_card_points`, and the AI legality-filter step. The pre-existing cold-cache `legal_cards` benchmark is now labelled accordingly.
|
|
61
|
+
- **P1/P2/P3 — Performance pass.** `trick_card_points` flag-hoist (already at ~2μs/call — sub-μs gain). `legal_cards` cache-key analysis: warm/cold gap is dominated by key-build, not lru_cache lookup; no actionable optimisation without changing the hand representation — documented to prevent re-investigation. `ScoreAccumulator.update_state` profile shows `dataclasses.replace` is 65% of cost; frozen-GameState invariant is load-bearing, accumulator is ~0.5ms/round — acceptable.
|
|
62
|
+
- **Verified false positives** (catalogued so they aren't re-raised): Belote/Rebelote partner non-announcement, Capot Rupture false-positive, AI `partner_hand` undo clearing, Negative-edition slot rollback, and `boss.id == "..."` branching — five agent flags that didn't survive source verification.
|
|
63
|
+
- **Tests + gates**: 592 tests (up from 568, +24 regression tests across 5 new files, 0 existing tests modified). pytest 592/592 green.
|
|
64
|
+
|
|
48
65
|
## What's new in 3.4.2
|
|
49
66
|
|
|
50
67
|
- **The 3.4.1 catalogue is closed.** All 7 confirmed bugs plus the H10 architectural cleanup and the M4 dead-code deletion ship here. **C1 — AI cheating under Le Fantôme Partenaire:** AI memory now respects `hide_partner_hand`; the boss flag's visibility cost is paid by both sides. **C3 — Dix de Der announcement under La Rupture:** the 8th-trick "Team X" line now uses the Rupture-aware `compute_trick_winners` helper, so the named team matches what scoring credits. **C4 — `opp_trumps` formula:** subtracts South's own trumps, played trumps, and partner-visible trumps; under Tout Atout the total switches to 32 (every card is a trump). **H1 — 8 BelAtro jokers:** `LIdeologue`, `LeFanatique`, `LeDiplomate`, `LePatriote`, `LIllusionniste`, `LePremierSang`, `LeSergent`, `LExecuteur` now gate on `team_of(event.winner) == 0` instead of `event.winner == Seat.SOUTH`, matching the 3.2.0 fix that landed `LaSentinelle` and `LeDernierMot`. **H4 — TournoiAnte:** `on_blind_won` now receives `blind_payout` and pays a true 50% (was a flat function of `bonus_per_round`). **H5 — `load_profile`:** saves missing the `unlocked_ids` key fall back to the Profile dataclass default starter unlocks. **H7 — classic win attribution on ties:** `update_stats_game` operator aligned to `ns > ew` so the stats line agrees with `menu.py`'s visible winner on an exact tie at target.
|
|
@@ -2,6 +2,23 @@
|
|
|
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.5.0
|
|
6
|
+
|
|
7
|
+
- **C1 — BelAtro consumables are now usable.** Pre-3.5.0 `BelAtroRun.consume()` was defined but never called from any UI, so every Tarot purchased in the shop and every directly-bought Planet accumulated in `run.consumables` with no way to activate them — only the voucher-gated Forge-Tierce path could level a planet. New `ConsumablesOverlay` (`belatro/ui/consumables.py`) is reachable from the shop via the `C` key; the hint line now shows `C: Consumables (N)`. Surfacing this path also caught a latent Le Fou bug: `consume()` advanced `last_consumable_id` to the *current* item before calling `item.use()`, so Le Fou read its own id and fell through to fallback — fixed by reordering and making Le Fou transparent so a second Le Fou keeps copying the same source.
|
|
8
|
+
- **H1 — `run_summary.py` writes are durable.** Added `flush + fsync` inside the `with` block. A crash mid-write no longer leaves a truncated JSONL line.
|
|
9
|
+
- **H2 — `Key.EOF` distinct from `Key.ESC`.** A closed stdin (pipe / headless harness) used to make every prompt loop spin: ESC popped one level, the loop re-read stdin, got another ESC, popped again. Now `KeyReader.read()` returns `Key.EOF` on empty-read; every consumer accepts it like ESC; `prompt_card` / `prompt_bid` exit cleanly instead of spinning.
|
|
10
|
+
- **H3 — `EventBus` round-scope invariant documented + `clear()` added.** Module docstring now spells out the contract that today is silently enforced by `drive_round()` creating a fresh bus per round.
|
|
11
|
+
- **M1 — Tied carrés / sequences go to the first announcer.** `resolve_declarations` gains an optional `taker: Seat | None` parameter; tied declarations now walk the announce order (taker → clockwise) and award the first matching seat, matching standard Belote-Coinché. Legacy "cancel" behaviour preserved when `taker` not supplied.
|
|
12
|
+
- **M3 — `RoundEndEvent.breakdown` is properly typed** as `ScoringBreakdown`. Removed 9 defensive `getattr(event.breakdown, "is_failed", False)` patterns; `taker_seat` corrected to `Seat | None`.
|
|
13
|
+
- **M4 — `partner_jokers_double` legacy flag is now deprecated.** A `DeprecationWarning` fires when both it and the tier scaling are set; flag slated for removal in 4.0.
|
|
14
|
+
- **M5 — SA belote invariant hoisted to contract level.** Was previously only asserted inside the capot branch.
|
|
15
|
+
- **L1 — `patch_trick_card` batches its `sys.stdout.write` calls.** Eliminates a torn-frame window on signal-interruptible terminals.
|
|
16
|
+
- **L2/L3 — Documentation pins**: `BELOTE_A11Y` is read once at import; `BelAtroRun.consumables/jokers/vouchers` are intentionally mutable.
|
|
17
|
+
- **M2 — Benchmark coverage expanded.** Three new micro-benchmarks: warm-cache `legal_cards`, `trick_card_points`, and the AI legality-filter step. The pre-existing cold-cache `legal_cards` benchmark is now labelled accordingly.
|
|
18
|
+
- **P1/P2/P3 — Performance pass.** `trick_card_points` flag-hoist (already at ~2μs/call — sub-μs gain). `legal_cards` cache-key analysis: warm/cold gap is dominated by key-build, not lru_cache lookup; no actionable optimisation without changing the hand representation — documented to prevent re-investigation. `ScoreAccumulator.update_state` profile shows `dataclasses.replace` is 65% of cost; frozen-GameState invariant is load-bearing, accumulator is ~0.5ms/round — acceptable.
|
|
19
|
+
- **Verified false positives** (catalogued so they aren't re-raised): Belote/Rebelote partner non-announcement, Capot Rupture false-positive, AI `partner_hand` undo clearing, Negative-edition slot rollback, and `boss.id == "..."` branching — five agent flags that didn't survive source verification.
|
|
20
|
+
- **Tests + gates**: 592 tests (up from 568, +24 regression tests across 5 new files, 0 existing tests modified). pytest 592/592 green.
|
|
21
|
+
|
|
5
22
|
## What's new in 3.4.2
|
|
6
23
|
|
|
7
24
|
- **The 3.4.1 catalogue is closed.** All 7 confirmed bugs plus the H10 architectural cleanup and the M4 dead-code deletion ship here. **C1 — AI cheating under Le Fantôme Partenaire:** AI memory now respects `hide_partner_hand`; the boss flag's visibility cost is paid by both sides. **C3 — Dix de Der announcement under La Rupture:** the 8th-trick "Team X" line now uses the Rupture-aware `compute_trick_winners` helper, so the named team matches what scoring credits. **C4 — `opp_trumps` formula:** subtracts South's own trumps, played trumps, and partner-visible trumps; under Tout Atout the total switches to 32 (every card is a trump). **H1 — 8 BelAtro jokers:** `LIdeologue`, `LeFanatique`, `LeDiplomate`, `LePatriote`, `LIllusionniste`, `LePremierSang`, `LeSergent`, `LExecuteur` now gate on `team_of(event.winner) == 0` instead of `event.winner == Seat.SOUTH`, matching the 3.2.0 fix that landed `LaSentinelle` and `LeDernierMot`. **H4 — TournoiAnte:** `on_blind_won` now receives `blind_payout` and pays a true 50% (was a flat function of `bonus_per_round`). **H5 — `load_profile`:** saves missing the `unlocked_ids` key fall back to the Profile dataclass default starter unlocks. **H7 — classic win attribution on ties:** `update_stats_game` operator aligned to `ns > ew` so the stats line agrees with `menu.py`'s visible winner on an exact tie at target.
|
|
@@ -149,21 +149,103 @@ def benchmark_deal(iterations: int = 1000) -> float:
|
|
|
149
149
|
|
|
150
150
|
def benchmark_legal_cards(iterations: int = 1000) -> float:
|
|
151
151
|
from belote.game import legal_cards, clear_legal_cards_cache, replace
|
|
152
|
-
print(f"Benchmarking legal_cards() over {iterations} iterations...")
|
|
153
|
-
|
|
152
|
+
print(f"Benchmarking legal_cards() (cache cleared per call) over {iterations} iterations...")
|
|
153
|
+
|
|
154
154
|
state = new_game()
|
|
155
155
|
state = start_round(state, random.Random(42))
|
|
156
156
|
state = replace(state, phase=Phase.PLAYING, trump=Suit.SPADES, turn=Seat.SOUTH)
|
|
157
|
-
|
|
157
|
+
|
|
158
158
|
times = []
|
|
159
159
|
for _ in range(iterations):
|
|
160
160
|
clear_legal_cards_cache()
|
|
161
161
|
start = time.perf_counter()
|
|
162
162
|
_ = legal_cards(state, Seat.SOUTH)
|
|
163
163
|
times.append(time.perf_counter() - start)
|
|
164
|
-
|
|
164
|
+
|
|
165
|
+
avg = statistics.mean(times) * 1000
|
|
166
|
+
print(f" Legal Cards (cold) Time: {avg:.3f}ms")
|
|
167
|
+
return avg
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def benchmark_legal_cards_cached(iterations: int = 1000) -> float:
|
|
171
|
+
"""Measure the cache-hit path. Production gameplay reuses the cache across
|
|
172
|
+
multiple AI rollouts and HUD redraws for the same `(state, seat)` pair —
|
|
173
|
+
`benchmark_legal_cards` above invalidates every iteration and so reflects
|
|
174
|
+
worst-case-only time.
|
|
175
|
+
"""
|
|
176
|
+
from belote.game import legal_cards, clear_legal_cards_cache, replace
|
|
177
|
+
print(f"Benchmarking legal_cards() (warm cache) over {iterations} iterations...")
|
|
178
|
+
|
|
179
|
+
state = new_game()
|
|
180
|
+
state = start_round(state, random.Random(42))
|
|
181
|
+
state = replace(state, phase=Phase.PLAYING, trump=Suit.SPADES, turn=Seat.SOUTH)
|
|
182
|
+
clear_legal_cards_cache()
|
|
183
|
+
legal_cards(state, Seat.SOUTH) # warm the cache once
|
|
184
|
+
|
|
185
|
+
times = []
|
|
186
|
+
for _ in range(iterations):
|
|
187
|
+
start = time.perf_counter()
|
|
188
|
+
_ = legal_cards(state, Seat.SOUTH)
|
|
189
|
+
times.append(time.perf_counter() - start)
|
|
190
|
+
|
|
191
|
+
avg = statistics.mean(times) * 1000
|
|
192
|
+
print(f" Legal Cards (warm) Time: {avg:.3f}ms")
|
|
193
|
+
return avg
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def benchmark_trick_scoring(iterations: int = 1000) -> float:
|
|
197
|
+
"""Measure `trick_card_points` — called 8× per round from `game.py::play_card`
|
|
198
|
+
(HUD running total) and again from `scoring.py::_calculate_base_points`
|
|
199
|
+
(final round score). One of the hottest functions in a played round.
|
|
200
|
+
"""
|
|
201
|
+
from belote.game import TrickCard, replace
|
|
202
|
+
from belote.deck import Card, Rank
|
|
203
|
+
from belote.scoring import trick_card_points
|
|
204
|
+
|
|
205
|
+
print(f"Benchmarking trick_card_points() over {iterations} iterations...")
|
|
206
|
+
|
|
207
|
+
state = new_game()
|
|
208
|
+
state = replace(state, trump=Suit.SPADES, contract="normal", taker=Seat.SOUTH)
|
|
209
|
+
trick = (
|
|
210
|
+
TrickCard(Seat.SOUTH, Card(Suit.SPADES, Rank.JACK)),
|
|
211
|
+
TrickCard(Seat.WEST, Card(Suit.SPADES, Rank.NINE)),
|
|
212
|
+
TrickCard(Seat.NORTH, Card(Suit.HEARTS, Rank.ACE)),
|
|
213
|
+
TrickCard(Seat.EAST, Card(Suit.SPADES, Rank.SEVEN)),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
times = []
|
|
217
|
+
for _ in range(iterations):
|
|
218
|
+
start = time.perf_counter()
|
|
219
|
+
_ = trick_card_points(state, trick)
|
|
220
|
+
times.append(time.perf_counter() - start)
|
|
221
|
+
|
|
222
|
+
avg = statistics.mean(times) * 1000
|
|
223
|
+
print(f" Trick Scoring Time: {avg:.3f}ms")
|
|
224
|
+
return avg
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def benchmark_ai_legality_filter(iterations: int = 500) -> float:
|
|
228
|
+
"""Isolate the legal-move filter step inside `AIPlayer.decide_card`. The
|
|
229
|
+
AI calls `legal_cards` once per decision; an unrepresentative cold-cache
|
|
230
|
+
benchmark above would over-attribute AI time to legality checks.
|
|
231
|
+
"""
|
|
232
|
+
from belote.game import legal_cards, replace
|
|
233
|
+
print(f"Benchmarking AI legality filter over {iterations} iterations...")
|
|
234
|
+
|
|
235
|
+
state = new_game()
|
|
236
|
+
state = start_round(state, random.Random(42))
|
|
237
|
+
state = replace(state, phase=Phase.PLAYING, trump=Suit.SPADES, taker=Seat.SOUTH, turn=Seat.NORTH)
|
|
238
|
+
|
|
239
|
+
times = []
|
|
240
|
+
for _ in range(iterations):
|
|
241
|
+
start = time.perf_counter()
|
|
242
|
+
legal = legal_cards(state, Seat.NORTH)
|
|
243
|
+
# The filter step: callers usually check membership in a 6-8 card hand.
|
|
244
|
+
_ = [c for c in state.hands[Seat.NORTH.value] if c in legal]
|
|
245
|
+
times.append(time.perf_counter() - start)
|
|
246
|
+
|
|
165
247
|
avg = statistics.mean(times) * 1000
|
|
166
|
-
print(f"
|
|
248
|
+
print(f" Legality Filter Time: {avg:.3f}ms")
|
|
167
249
|
return avg
|
|
168
250
|
|
|
169
251
|
|
|
@@ -180,6 +262,9 @@ def run_benchmarks() -> None:
|
|
|
180
262
|
benchmark_scoring()
|
|
181
263
|
benchmark_deal()
|
|
182
264
|
benchmark_legal_cards()
|
|
265
|
+
benchmark_legal_cards_cached()
|
|
266
|
+
benchmark_trick_scoring()
|
|
267
|
+
benchmark_ai_legality_filter()
|
|
183
268
|
print("========================================")
|
|
184
269
|
|
|
185
270
|
|
|
@@ -5,6 +5,11 @@ line to stderr — readable by terminal screen readers such as Orca, NVDA in WSL
|
|
|
5
5
|
or VoiceOver via iTerm2. Disabled by default so it doesn't pollute output for
|
|
6
6
|
sighted players.
|
|
7
7
|
|
|
8
|
+
**Invariant**: ``BELOTE_A11Y`` is read **once at module import**. Toggling the
|
|
9
|
+
env var mid-session has no effect on production code — restart the process
|
|
10
|
+
to enable/disable. Tests that mutate the env may call ``_refresh_enabled_from_env()``
|
|
11
|
+
to re-read the cached flag.
|
|
12
|
+
|
|
8
13
|
Hooked from gameflow.py (card plays, trick winners, round results) and from
|
|
9
14
|
belatro/main.py (boss reveal, ante advance, run won/lost). Each hook is a
|
|
10
15
|
single line — no rich formatting — so the screen reader can speak it cleanly.
|
|
@@ -28,6 +28,11 @@ class BelAtroRun:
|
|
|
28
28
|
profile: Profile | None = None
|
|
29
29
|
|
|
30
30
|
# ── Collectibles ───────────────────────────────────────
|
|
31
|
+
# These lists are intentionally mutable: jokers append on purchase, vouchers
|
|
32
|
+
# apply their effects in-place, and `consume()` removes items via
|
|
33
|
+
# `consumables.remove(item)`. If a future feature needs to snapshot a run
|
|
34
|
+
# for replay / ghost-run reconstruction, deep-copy these lists at the
|
|
35
|
+
# snapshot boundary — they alias live state otherwise.
|
|
31
36
|
jokers: list[Joker] = field(default_factory=list)
|
|
32
37
|
vouchers: list[Voucher] = field(default_factory=list)
|
|
33
38
|
consumables: list[Any] = field(default_factory=list) # Tarot/Planet instances
|
|
@@ -90,9 +95,17 @@ class BelAtroRun:
|
|
|
90
95
|
def consume(self, item: Any, context: object = None) -> None:
|
|
91
96
|
"""Centralised consumable activation.
|
|
92
97
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
Removes the item from `consumables` if present, dispatches to the right
|
|
99
|
+
hook based on item type (Tarot vs Planet), then advances
|
|
100
|
+
`last_consumable_id` so Le Fou can copy the prior consumable later.
|
|
101
|
+
|
|
102
|
+
Apply order matters: `item.use()` must run BEFORE updating
|
|
103
|
+
`last_consumable_id`, because Le Fou's `use()` reads that field to
|
|
104
|
+
find what to copy — if we advanced it to `le_fou` first, Le Fou would
|
|
105
|
+
see itself and fall through to the fallback path. Le Fou itself is
|
|
106
|
+
treated as transparent: the bookmark stays pointed at the source it
|
|
107
|
+
copied, so a second Le Fou keeps copying the same source rather than
|
|
108
|
+
copying itself.
|
|
96
109
|
"""
|
|
97
110
|
import contextlib
|
|
98
111
|
|
|
@@ -100,11 +113,14 @@ class BelAtroRun:
|
|
|
100
113
|
|
|
101
114
|
with contextlib.suppress(ValueError):
|
|
102
115
|
self.consumables.remove(item)
|
|
103
|
-
self.last_consumable_id = getattr(item, "id", None)
|
|
104
116
|
if isinstance(item, Tarot):
|
|
105
117
|
item.use(self, context)
|
|
106
118
|
elif isinstance(item, Planet):
|
|
107
119
|
item.use(self)
|
|
120
|
+
# Le Fou is transparent — leave the bookmark at the source it copied.
|
|
121
|
+
# Every other consumable advances the bookmark to its own id.
|
|
122
|
+
if getattr(item, "id", None) != "le_fou":
|
|
123
|
+
self.last_consumable_id = getattr(item, "id", None)
|
|
108
124
|
|
|
109
125
|
def _get_rng(self) -> Any:
|
|
110
126
|
"""Per-run random.Random instance, seeded from `seed` when given."""
|
|
@@ -68,7 +68,17 @@ class ScoreAccumulator:
|
|
|
68
68
|
return replace(state, _joker_state=joker_state, _chips=new_chips, _mult=new_mult)
|
|
69
69
|
|
|
70
70
|
def update_state(self, state: GameState, event: object) -> GameState:
|
|
71
|
-
"""Process an event and return an updated GameState with new score/joker state.
|
|
71
|
+
"""Process an event and return an updated GameState with new score/joker state.
|
|
72
|
+
|
|
73
|
+
Perf note (3.5.0 P3 investigation): the dominant cost (~65% of the
|
|
74
|
+
function) is the final `dataclasses.replace(state, ...)` call. The
|
|
75
|
+
frozen-GameState invariant is load-bearing — many call sites assume
|
|
76
|
+
`state is final_state` once a round is sealed — so we accept the
|
|
77
|
+
replace cost rather than mutating in place. At ~19μs per event and
|
|
78
|
+
~25 events per round (8 tricks + 2-4 bids + decls + round-end) the
|
|
79
|
+
accumulator contributes ~0.5ms to a full round, well below the
|
|
80
|
+
~1ms-per-frame budget where it would matter.
|
|
81
|
+
"""
|
|
72
82
|
new_chips = state._chips
|
|
73
83
|
new_mult = state._mult
|
|
74
84
|
new_money = state._bonus_money
|
|
@@ -120,10 +130,23 @@ class ScoreAccumulator:
|
|
|
120
130
|
# tier 2 (boost) / 3 (strong) → +1 apply (≈ ×2 effect),
|
|
121
131
|
# matches legacy partner_jokers_double at trust ≥ 7.
|
|
122
132
|
# tier 4 (elite) → +2 applies (≈ ×3 effect).
|
|
123
|
-
#
|
|
124
|
-
#
|
|
133
|
+
#
|
|
134
|
+
# `partner_jokers_double` is the legacy boolean flag (pre-3.5.0
|
|
135
|
+
# back-compat for tests that set it directly). When both are
|
|
136
|
+
# set, `max()` picks whichever is larger; a one-shot
|
|
137
|
+
# DeprecationWarning fires so callers migrate to tier. The flag
|
|
138
|
+
# is slated for removal in 4.0; new code should use `partner_tier`.
|
|
125
139
|
if getattr(joker, "is_partner_joker", False):
|
|
126
140
|
tier_extras = (0, 0, 1, 1, 2)[self.partner_tier]
|
|
141
|
+
if self.partner_jokers_double and tier_extras > 0:
|
|
142
|
+
import warnings
|
|
143
|
+
warnings.warn(
|
|
144
|
+
"ScoreAccumulator.partner_jokers_double is deprecated "
|
|
145
|
+
"alongside partner_tier; set only one. The flag will "
|
|
146
|
+
"be removed in 4.0.",
|
|
147
|
+
DeprecationWarning,
|
|
148
|
+
stacklevel=2,
|
|
149
|
+
)
|
|
127
150
|
extra_applies = max(
|
|
128
151
|
tier_extras, 1 if self.partner_jokers_double else 0
|
|
129
152
|
)
|
|
@@ -202,11 +225,11 @@ class ScoreAccumulator:
|
|
|
202
225
|
contract_id = _SUIT_TO_CONTRACT.get(event.trump) if event.trump else None
|
|
203
226
|
if contract_id:
|
|
204
227
|
reward = self.contract_levels.get(contract_id, {})
|
|
205
|
-
if reward.get("add_money") and not
|
|
228
|
+
if reward.get("add_money") and not event.breakdown.is_failed:
|
|
206
229
|
new_money += reward["add_money"]
|
|
207
230
|
self._log.append(f"Planet ({contract_id}): +${reward['add_money']}")
|
|
208
231
|
# Pluto (Capot bonus)
|
|
209
|
-
if event.capot and not
|
|
232
|
+
if event.capot and not event.breakdown.is_failed:
|
|
210
233
|
pluto_reward = self.contract_levels.get("capot", {})
|
|
211
234
|
if pluto_reward.get("capot_bonus"):
|
|
212
235
|
new_chips += pluto_reward["capot_bonus"]
|
|
@@ -215,7 +238,7 @@ class ScoreAccumulator:
|
|
|
215
238
|
if (
|
|
216
239
|
event.coinche_level > 0
|
|
217
240
|
and event.taker_seat in _NS_TEAM
|
|
218
|
-
and not
|
|
241
|
+
and not event.breakdown.is_failed
|
|
219
242
|
):
|
|
220
243
|
libra_reward = self.contract_levels.get("coinche", {})
|
|
221
244
|
libra_mult = libra_reward.get("coinche_multiplier", 0)
|
|
@@ -1,12 +1,28 @@
|
|
|
1
|
+
"""Round-scoped pub/sub bus for BelAtro joker / unlock / score-accumulator wiring.
|
|
2
|
+
|
|
3
|
+
**Scope invariant**: an `EventBus` is created once per round in
|
|
4
|
+
`round_driver.drive_round`, subscribed to by the round's accumulator and the
|
|
5
|
+
process-wide `UnlockTracker`, then dropped when the round ends. Subscribers
|
|
6
|
+
do not need to unsubscribe explicitly — the bus instance and all its
|
|
7
|
+
subscriber references are released together.
|
|
8
|
+
|
|
9
|
+
If you ever extend the bus's scope (run-level, session-level), you MUST also
|
|
10
|
+
add explicit unsubscribe calls so subscribers don't accumulate across rounds
|
|
11
|
+
and double-fire. The `clear()` method exists for that future use.
|
|
12
|
+
"""
|
|
13
|
+
|
|
1
14
|
from __future__ import annotations
|
|
2
15
|
|
|
3
16
|
from collections.abc import Callable
|
|
4
17
|
from dataclasses import dataclass
|
|
5
|
-
from typing import
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
6
19
|
|
|
7
20
|
from belote.deck import Card, Suit
|
|
8
21
|
from belote.game import Seat
|
|
9
22
|
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from belote.scoring import ScoringBreakdown
|
|
25
|
+
|
|
10
26
|
# ── Event types ────────────────────────────────────────────────────────────
|
|
11
27
|
|
|
12
28
|
|
|
@@ -36,8 +52,9 @@ class DeclarationScoredEvent:
|
|
|
36
52
|
|
|
37
53
|
@dataclass(frozen=True)
|
|
38
54
|
class RoundEndEvent:
|
|
39
|
-
breakdown:
|
|
40
|
-
taker_seat
|
|
55
|
+
breakdown: "ScoringBreakdown"
|
|
56
|
+
# `taker_seat` is None when the round ended on an all-pass (no contract).
|
|
57
|
+
taker_seat: Seat | None
|
|
41
58
|
trump: Suit | None
|
|
42
59
|
capot: bool
|
|
43
60
|
hand_remainder: tuple[Card, ...] = ()
|
|
@@ -68,6 +85,8 @@ Handler = Callable[[AnyEvent], None]
|
|
|
68
85
|
|
|
69
86
|
|
|
70
87
|
class EventBus:
|
|
88
|
+
"""Round-scoped event bus. See module docstring for the lifetime contract."""
|
|
89
|
+
|
|
71
90
|
def __init__(self) -> None:
|
|
72
91
|
self._handlers: list[Handler] = []
|
|
73
92
|
|
|
@@ -83,3 +102,13 @@ class EventBus:
|
|
|
83
102
|
def emit(self, event: AnyEvent) -> None:
|
|
84
103
|
for h in list(self._handlers):
|
|
85
104
|
h(event)
|
|
105
|
+
|
|
106
|
+
def clear(self) -> None:
|
|
107
|
+
"""Drop every subscriber.
|
|
108
|
+
|
|
109
|
+
Today the round-scoped bus is created fresh per round so this is
|
|
110
|
+
unused, but exists for the future where a longer-lived bus might
|
|
111
|
+
share lifetime across rounds (debug overlays, replay recorders, etc).
|
|
112
|
+
Call before re-using a bus across round boundaries.
|
|
113
|
+
"""
|
|
114
|
+
self._handlers.clear()
|
|
@@ -81,8 +81,6 @@ class QuinteRoyale(Joker):
|
|
|
81
81
|
def on_round_end(
|
|
82
82
|
self, event: RoundEndEvent, state: dict[str, Any]
|
|
83
83
|
) -> JokerResult | None:
|
|
84
|
-
if state.pop(f"{self.id}_armed", False) and not
|
|
85
|
-
event.breakdown, "is_failed", False
|
|
86
|
-
):
|
|
84
|
+
if state.pop(f"{self.id}_armed", False) and not event.breakdown.is_failed:
|
|
87
85
|
return JokerResult(times_mult=4.0)
|
|
88
86
|
return None
|
|
@@ -24,7 +24,7 @@ class CoincheStack(Joker):
|
|
|
24
24
|
def on_round_end(self, event: RoundEndEvent, state: dict[str, Any]) -> JokerResult | None:
|
|
25
25
|
if event.coinche_level <= 0:
|
|
26
26
|
return None
|
|
27
|
-
if
|
|
27
|
+
if event.breakdown.is_failed:
|
|
28
28
|
return None
|
|
29
29
|
if event.taker_seat not in (Seat.SOUTH, Seat.NORTH):
|
|
30
30
|
return None
|
|
@@ -53,7 +53,7 @@ class ToutStreak(Joker):
|
|
|
53
53
|
is_tout = event.trump == Suit.TOUT_ATOUT
|
|
54
54
|
is_taker_won = (
|
|
55
55
|
event.taker_seat in (Seat.SOUTH, Seat.NORTH)
|
|
56
|
-
and not
|
|
56
|
+
and not event.breakdown.is_failed
|
|
57
57
|
)
|
|
58
58
|
|
|
59
59
|
if is_tout and is_taker_won:
|
|
@@ -109,7 +109,7 @@ class LePuriste(Joker):
|
|
|
109
109
|
# Sans Atout means trump is None. Flag triggers double payout in _play_blind.
|
|
110
110
|
if (
|
|
111
111
|
event.trump is None
|
|
112
|
-
and not
|
|
112
|
+
and not event.breakdown.is_failed
|
|
113
113
|
and event.taker_seat in (Seat.SOUTH, Seat.NORTH)
|
|
114
114
|
):
|
|
115
115
|
state["puriste_triggered"] = True
|
|
@@ -64,7 +64,7 @@ class UnlockTracker:
|
|
|
64
64
|
if (
|
|
65
65
|
event.trump is None
|
|
66
66
|
and event.taker_seat in (Seat.SOUTH, Seat.NORTH)
|
|
67
|
-
and not
|
|
67
|
+
and not event.breakdown.is_failed
|
|
68
68
|
):
|
|
69
69
|
self.profile.stats["sans_atout_wins"] += 1
|
|
70
70
|
dirty = True
|
|
@@ -79,7 +79,7 @@ class UnlockTracker:
|
|
|
79
79
|
if (
|
|
80
80
|
event.trump == Suit.TOUT_ATOUT
|
|
81
81
|
and event.taker_seat in (Seat.SOUTH, Seat.NORTH)
|
|
82
|
-
and not
|
|
82
|
+
and not event.breakdown.is_failed
|
|
83
83
|
):
|
|
84
84
|
self.profile.stats["tout_atout_wins"] += 1
|
|
85
85
|
dirty = True
|
|
@@ -66,6 +66,11 @@ def append_summary(run: BelAtroRun, *, won: bool) -> None:
|
|
|
66
66
|
path = _summary_path()
|
|
67
67
|
with path.open("a", encoding="utf-8") as f:
|
|
68
68
|
f.write(json.dumps(record) + "\n")
|
|
69
|
+
# flush + fsync so a crash or power-loss mid-write doesn't leave a
|
|
70
|
+
# truncated final line that breaks downstream `jq` processing.
|
|
71
|
+
# Mirrors the atomic-save pattern in `progression/save.py`.
|
|
72
|
+
f.flush()
|
|
73
|
+
os.fsync(f.fileno())
|
|
69
74
|
except OSError:
|
|
70
75
|
# Logging failure is intentionally silent — telemetry is non-essential.
|
|
71
76
|
pass
|
|
@@ -67,7 +67,7 @@ class BelAtroAnnounce:
|
|
|
67
67
|
event = reader.read_timeout(remaining)
|
|
68
68
|
if event is None:
|
|
69
69
|
break
|
|
70
|
-
if event.key in (Key.SPACE, Key.ESC, Key.ENTER):
|
|
70
|
+
if event.key in (Key.SPACE, Key.ESC, Key.ENTER, Key.EOF):
|
|
71
71
|
break
|
|
72
72
|
remaining = end - time.time()
|
|
73
73
|
|
|
@@ -118,7 +118,7 @@ class BelAtroAnnounce:
|
|
|
118
118
|
if event is None:
|
|
119
119
|
break
|
|
120
120
|
key = event.key
|
|
121
|
-
if key in (Key.SPACE, Key.ESC, Key.ENTER):
|
|
121
|
+
if key in (Key.SPACE, Key.ESC, Key.ENTER, Key.EOF):
|
|
122
122
|
break
|
|
123
123
|
remaining = end - time.time()
|
|
124
124
|
toggle_overlay()
|
|
@@ -128,7 +128,7 @@ def show_collection(reader: KeyReader, profile: Profile) -> None:
|
|
|
128
128
|
|
|
129
129
|
event = reader.read()
|
|
130
130
|
match event.key:
|
|
131
|
-
case Key.QUIT | Key.ESC:
|
|
131
|
+
case Key.QUIT | Key.ESC | Key.EOF:
|
|
132
132
|
return
|
|
133
133
|
case Key.LEFT:
|
|
134
134
|
cat_idx = (cat_idx - 1) % len(categories)
|