belote-cli 3.9.0__tar.gz → 3.9.3__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.9.0 → belote_cli-3.9.3}/CHANGELOG.md +59 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/DEVELOPMENT.md +14 -4
- {belote_cli-3.9.0 → belote_cli-3.9.3}/PKG-INFO +4 -4
- {belote_cli-3.9.0 → belote_cli-3.9.3}/README.md +3 -3
- {belote_cli-3.9.0 → belote_cli-3.9.3}/pyproject.toml +1 -1
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/__init__.py +1 -1
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/ai.py +32 -6
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/core/run_state.py +10 -5
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/base.py +22 -2
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/jokers/economy.py +12 -5
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/vouchers.py +12 -12
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/main.py +17 -1
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/progression/unlocks.py +21 -3
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/run/shop.py +6 -8
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/ui/hud.py +55 -22
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/game.py +42 -13
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/gameflow.py +33 -4
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/main.py +23 -34
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/scoring.py +12 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/ui/prompts.py +0 -1
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/ui/render.py +86 -3
- belote_cli-3.9.3/tests/belatro/test_endless.py +95 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_phase2_content.py +67 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_progression.py +90 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_voucher_idempotency.py +43 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_ai.py +59 -1
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_belote.py +82 -0
- belote_cli-3.9.3/tests/test_render_diff.py +144 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_undo.py +65 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/.claude/settings.local.json +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/.gitignore +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/.python-version +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/LICENSE +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/scripts/benchmark.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/__init__.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/a11y.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/achievements.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/ansi.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/__init__.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/core/__init__.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/core/economy.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/core/scoring.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/engine/__init__.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/engine/event_bus.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/engine/modifier_patch.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/engine/round_driver.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/ghost_run.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/__init__.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/jokers/__init__.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/jokers/annonces.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/jokers/coinche.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/jokers/contract.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/jokers/corrupted.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/planets.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/registry.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/items/tarots.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/partner/__init__.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/partner/partner_state.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/partner/personality.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/partner/trust.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/progression/__init__.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/progression/save.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/run/__init__.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/run/ante.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/run/ante_themes.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/run/boss.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/run/decks.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/run_summary.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/ui/__init__.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/ui/announce.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/ui/collection.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/ui/consumables.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/ui/history.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/ui/menu.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/ui/rules.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/ui/shop.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/belatro/ui/trust_bar.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/config.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/context.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/deck.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/input.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/replay.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/rules.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/stats.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/themes.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/ui/__init__.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/ui/announce.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/ui/fit_guard.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/ui/layout.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/src/belote/ui/menu.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/__init__.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/__init__.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_belatro.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_boss_modifiers_integration.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_collection_logic.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_consumables_ui.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_contract_unlocks.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_dead_flag_fixes.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_deck_variants.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_event_bus.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_ghost_run.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_history_overlay.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_hud_synergy.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_partner_jokers.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_partner_trust.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_phase0_coverage.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_phase1_plumbing.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_phase3_meta.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_round_driver.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_run_summary.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/belatro/test_shop_empty_pools.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_a11y.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_achievements.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_ansi_helpers.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_benchmark_smoke.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_bidding_all_pass.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_declaration_tiebreak.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_extended.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_game_logic.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_gameflow.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_input_eof.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_layout.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_new_coverage.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_no_color.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_official_rules.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_properties.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/tests/test_replay.py +0 -0
- {belote_cli-3.9.0 → belote_cli-3.9.3}/uv.lock +0 -0
|
@@ -5,6 +5,65 @@ 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.9.3] - 2026-05-15
|
|
9
|
+
|
|
10
|
+
Multi-agent audit pass (six Explore agents — three broad-sweep + three targeted deep-dives on AI, scoring edge cases, per-joker mechanics, endless mode, meta-progression). Every load-bearing finding verified against the live code before patching; **8 false positives** caught and documented (corrupted-joker seat semantics being intentional, ToutStreak persistence working correctly via `card_enhancements`, `_card_back` LRU cache being theme-keyed, etc.). **Six real fixes (R1–R7)** landed plus three larger phases: render-diff layer, endless-mode boss-variety guard, and a Quinte Royale unlock that had been marked `is_unlockable` for releases without a trigger. **Rematch entirely removed** at user request — game-over screen keeps only `[Enter/Q] Menu` and `[H] History`; players who want to play again use the menu. **+30 regression tests** (661 → 691).
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`src/belote/ai.py::_hard_special` + `_hard_bid` (R1, MED) — AI bidding now honors zero-rank boss flags.** Pre-3.9.3 the heuristic summed raw `card_points` even when `jacks_zero` / `aces_zero` / `kings_zero` / `tens_zero` / `ban_clubs` were active, so the AI overbid Tout Atout on a jack-heavy hand under Le Sauvage even though those jacks would score 0. The fix routes evaluation through the new public `scoring.card_points_with_modifiers(card, trump, bm)` helper (a thin wrapper over the existing `_card_points_with_zero_ranks` — same canonical zero-rank logic the live HUD and final scoring use). Honor-counting in the regular-suit branch also drops a rank if its boss flag is active. Regression: `tests/test_ai.py::test_hard_ai_does_not_bid_ta_when_jacks_zero_suppresses_jacks`.
|
|
15
|
+
- **`src/belote/game.py::_record_belote_announcement` + new `GameState.belote_trump` field (R2, LOW) — L'Anarchie preserves rebelote across mid-belote trump rotation.** When trump rotates after trick 2/4/6 (`dynamic_trump`), a K-trump played in trick 2 and Q-of-original-trump in trick 3 would silently drop the rebelote because the K/Q match used `card.suit == current_trump`. The fix captures the trump at first-belote into a new `belote_trump: Suit | None` GameState field (mirrors the existing `belote_announcer` pattern) and matches the rebelote K/Q against the captured suit. Regression: `tests/test_belote.py::test_anarchie_rebelote_survives_cross_rotation_play` + non-rotation sanity test.
|
|
16
|
+
- **`src/belote/belatro/ui/hud.py::BelAtroHUD.render` + `_render_compact` (R3, LOW) — HUD score line gated under La Compétition boss.** The live running total (sequential trick-point sum) diverges from the final score under `separate_scoring` (which picks per-seat max). The HUD now shows a "Compétition: score par siège — total final caché" disclaimer instead of a misleading running total when this boss is active.
|
|
17
|
+
- **`src/belote/belatro/items/jokers/economy.py::LeBanquier` (R4, LOW) — bonus cash now suppressed on failed rounds and on EW-taker rounds.** Description says "Earn $1 for every 10 card points you score above the Blind target"; that framing presumes NS held a successful contract. Pre-3.9.3 the joker paid out unconditionally — even on chute, even when EW was taker. Now gates on `not breakdown.is_failed and taker_seat in (SOUTH, NORTH)`. Other on-round-end jokers (LAvare, LeFantome, LaSentinelle, LAccumulateur, LaSentinelleP) were audited and intentionally remain ungated — their descriptions are state-based (cards in hand, jack never used to win, etc.), not win-conditional.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **`src/belote/belatro/items/base.py::Voucher` + `belatro/items/vouchers.py` + `belatro/run/shop.py` (R5) — voucher idempotency guard relocated from `Shop._apply_item` into the `Voucher` base class.** Subclasses now implement `_apply_once(run)`; the base's `apply(run)` consults `run._applied_voucher_ids` and short-circuits a second invocation. Any future caller (replay reconstruction, deck-builder preview, hypothetical save/load) inherits the same protection automatically — pre-3.9.3 the guard only fired when the shop was the call site, so a different caller would silently double-stack `+=` vouchers (LaTelescope, LaDoubleDonne, LesCartesDorees, LeCouteau). Regression: `tests/belatro/test_voucher_idempotency.py::test_direct_voucher_apply_is_idempotent_without_shop`.
|
|
22
|
+
- **`src/belote/belatro/ui/hud.py` (R7) — every bare `print()` call batched into one `sys.stdout.write` + `flush`** per render entry point (`render`, `_render_compact`, `render_joker_pip_strip`, `render_synergy_tooltip`). ~9 fewer syscalls per BelAtro HUD frame. Mirrors the canonical `render.py:1002` pattern.
|
|
23
|
+
- **`src/belote/ai.py:627` (R6) — comment fix.** Said "prefer keeping high value cards if not winning" but the code adds points to the play-score (i.e. biases toward *playing* high cards). Rewritten as "Small per-card tiebreaker: when win/loss heuristics are otherwise equal, slightly bias toward playing the higher-value card."
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **`src/belote/ui/render.py` (Phase 6) — diff-based render emit.** `display()` now compares each frame's per-row line list against the previously committed frame and writes only the rows that actually changed. Idle re-renders (polling input between keystrokes, hovering on a card without moving) collapse from ~28 row writes to zero. New module-level `_last_emitted_lines` baseline; reset on layout change (via the existing `_last_render_key` invalidation), theme change (via the existing `theme_manager.register_callback(clear_card_cache)` callback — `clear_card_cache` now also clears `_card_back` LRU + the diff baseline), and explicit `display(state, force=True)`. Escape hatch: `BELOTE_NO_DIFF=1`. The full `render()` string contract is unchanged — tests calling `render(state)` directly still get the complete frame. Regression: `tests/test_render_diff.py` (6 tests).
|
|
28
|
+
- **`src/belote/scoring.py::card_points_with_modifiers` (public)** — thin wrapper exposing the existing `_card_points_with_zero_ranks` for callers outside `scoring.py` (notably `ai.py` for R1).
|
|
29
|
+
- **`src/belote/game.py::GameState.belote_trump: Suit | None`** — new field set when `belote_tracker[0]` first flips True, read by the rebelote check (R2).
|
|
30
|
+
- **`src/belote/belatro/core/run_state.py::BelAtroRun._recent_boss_ids: list[str]` (Phase 5) — endless-mode boss variety guard.** The boss selector in `belatro/main.py` now suppresses immediate boss repeats in endless by rerolling against the last-2 window (capped at 8 reroll attempts to never loop on degenerate pools / monkeypatched single-boss tests). Normal-run boss selection is unchanged — variety is only enforced when `run.endless` is True. Regression: `tests/belatro/test_endless.py::test_recent_boss_window_is_bounded_to_two`.
|
|
31
|
+
- **`src/belote/belatro/progression/unlocks.py::UnlockTracker._handle_declaration` (Phase 8) — Quinte Royale now unlocks on a Quinte announcement.** The Legendary joker was marked `is_unlockable = True` in `annonces.py:71` for several releases but had no path to actually unlock — `_handle_round_end` never set the flag, so the joker was filtered out of every shop pool. `UnlockTracker.on_event` now also dispatches `DeclarationScoredEvent`; when NS announces a sequence ≥ 100 pts, `quinte_royale` unlocks and the pending-announcement banner fires. Regression: `tests/belatro/test_progression.py::test_quinte_declaration_unlocks_quinte_royale` + negative tests (short sequence / opponent quinte).
|
|
32
|
+
- **`src/belote/gameflow.py::_undo_pop_to_south` + visible undo banner (Phase 7b user request).** Pressing `Z` previously popped one history-stack entry — but the stack records *every* play including AI moves, so a Z after the AI finished a trick landed on an AI mid-trick state, and the AI re-played deterministically with no visible effect. The new helper pops until the restored state has `turn == SOUTH` (the user's actual prior decision point), bounded by `stack_base` so undo can't cross round boundaries. After each successful pop, `announce("↶ Undo", duration=0.8, reader=reader)` paints a 0.8 s banner before the rolled-back position is redrawn so the player sees the undo take effect. Same UX applied to the bidding-undo path. Regression: `tests/test_undo.py::test_undo_pop_to_south_skips_intermediate_ai_states` + 2 boundary tests.
|
|
33
|
+
|
|
34
|
+
### Removed
|
|
35
|
+
|
|
36
|
+
- **Rematch feature (user request).** `src/belote/main.py` no longer offers `[R] Rematch` on the game-over screen; the only post-game choices are `[Enter/Q] Menu` and `[H] History`. The `rematch` variable, the conditional-menu-skip flag, and the dedicated reset path are all gone. `src/belote/ui/prompts.py::show_help` line `"[R] Rematch (Game Over)"` deleted.
|
|
37
|
+
|
|
38
|
+
### Audit verdict — verified clean, false positives caught
|
|
39
|
+
|
|
40
|
+
Six Explore agents flagged a number of "critical" issues that were verified against the actual code and rejected:
|
|
41
|
+
|
|
42
|
+
1. **Corrupted jokers (Le Traître / Le Démon / L'Égoïste / L'Agent Double) using `event.winner == Seat.SOUTH` instead of team scope** — intentional design. The class names and descriptions ("partner is irrelevant", "partner plays for opponents", "partner throws one trick per round") explicitly mark these as themed sabotage jokers. South-only is the design contract.
|
|
43
|
+
2. **Tout Streak streak persistence at `main.py:367-369` being "dead code"** — false. Verified at `round_driver.py:121-123`: `card_enhancements` is merged into `state._joker_state` at the start of every round, so the persisted `tout_streak_streak` flows back in. The persistence works.
|
|
44
|
+
3. **`_card_back` LRU cache going stale on theme change** — false. The cache is keyed by `theme_name` so a new theme produces a new key and a fresh render; old entries are LRU-evicted naturally. (3.9.3 still adds `_card_back.cache_clear()` to `clear_card_cache()` for memory hygiene since the diff-layer change now invalidates the frame baseline on theme change too — but the original "stale render" concern was wrong.)
|
|
45
|
+
4. **Negative-edition jokers "bypassing" the slot check** — intentional. `_can_accept` at `shop.py:152-154` returns True for Negative jokers by design; that's the whole point of the Negative edition.
|
|
46
|
+
5. **The Sun (Tout Atout) planet firing regardless of taker** — false. The parent block at `belatro/core/scoring.py:198+` gates on `event.winner in _NS_TEAM` already.
|
|
47
|
+
6. **BelAtro HUD `print()` calls causing "torn frames"** — theoretical only. Python stdout is single-threaded line-buffered; a bare `print()` is atomic per call in a TTY. We batched anyway (R7) as a micro-perf win, but there was no visual bug.
|
|
48
|
+
7. **L'Anarchie rebelote always broken** — partially false. Within the standard sequence (K then Q in the same trick or in trick 1+2 before the first rotation), rebelote already worked. Only the cross-rotation case was real (fixed as R2).
|
|
49
|
+
8. **`clear_card_cache()` being "incomplete"** — cosmetic at worst. Both LRU caches are theme-keyed, so staleness is impossible.
|
|
50
|
+
|
|
51
|
+
The "fake risky" agent finding (jokers in `risky.py` / `shaper.py` needing `is_failed` gates) cited files that don't exist — the real joker files are `annonces` / `coinche` / `contract` / `corrupted` / `economy` / `hand_comp` / `trick_timing` + `partner_jokers/{passive,shaper}`. Audit re-done against the actual files; only LeBanquier needed gating (R4).
|
|
52
|
+
|
|
53
|
+
### Performance verdict
|
|
54
|
+
|
|
55
|
+
- Benchmark smoke: 203–235 rounds/sec end-to-end (was 235 pre-3.9.3; well within budget, variance is system load).
|
|
56
|
+
- New render diff layer: idle re-render byte count drops to < 25% of the first frame (regression test pins this).
|
|
57
|
+
- No measured hotspot in scoring, AI decision, or trick-play paths. The 3.6 / 3.7.1 / 3.8.0 audits already covered the obvious cost centers.
|
|
58
|
+
|
|
59
|
+
### Internal
|
|
60
|
+
|
|
61
|
+
- **Tests**: 661 → 691 (+30). R1 (×1), R2 (×2), R4 (×3), R5 (×2), Phase 6 (×6), Phase 5 (×6), Phase 7b (×3), Phase 8 (×7).
|
|
62
|
+
- **Strict gates**: pytest 691/691 green; benchmark smoke green.
|
|
63
|
+
- **Version markers bumped**: `pyproject.toml`, `src/belote/__init__.py`.
|
|
64
|
+
- **Plan file**: `/home/mrrobot/.claude/plans/bug-hunt-code-performance-mutable-blanket.md`.
|
|
65
|
+
|
|
66
|
+
|
|
8
67
|
## [3.9.0] - 2026-05-14
|
|
9
68
|
|
|
10
69
|
Comprehensive bug-hunt, logic, and performance audit pass. Three parallel Explore agents covered the classic engine (`game.py` / `scoring.py` / `ai.py` / `gameflow.py` / `deck.py`), the BelAtro engine (`round_driver.py` / `modifier_patch.py` / `event_bus.py` / `belatro/core/scoring.py` / `boss.py` / `run_state.py` / `shop.py` / `belatro/main.py`), and the items catalogue + UI hot paths (`registry.py` + every joker / planet / tarot / voucher + `render.py` / `hud.py` / shop UI). Every load-bearing claim was spot-checked against the current code. **One real bug** (LOW), **one feature gap** filled, and a defensive cosmetic cleanup. Performance verdict: no measured hotspot — prior 3.6 / 3.7.1 / 3.8.0 audits already addressed the obvious paths. All 21 boss modifiers, 36 jokers, 8 planets, 12 tarots, 12 vouchers verified wired end-to-end. **+6 regression tests** (655 → 661). Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-wise-puzzle.md`.
|
|
@@ -84,17 +84,27 @@ 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 (691 tests expected)
|
|
88
88
|
PYTHONPATH=src pytest
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
Current baseline (3.9.
|
|
91
|
+
Current baseline (3.9.3):
|
|
92
92
|
|
|
93
|
-
- **
|
|
94
|
-
- 3.9.
|
|
93
|
+
- **691 tests** passing (3.9.0 had 661; +30 in 3.9.3 across R1/R2/R4/R5 regressions + 3 new test files: `test_render_diff.py`, `test_endless.py`, expanded `test_progression.py` / `test_undo.py` / `test_voucher_idempotency.py` / `test_phase2_content.py`).
|
|
94
|
+
- 3.9.3 ships a multi-agent audit pass (six Explore agents — three broad-sweep + three targeted deep-dives on AI, scoring edge cases, per-joker mechanics, endless mode, meta-progression). **8 false positives caught and documented** (corrupted-joker seat semantics intentional, ToutStreak persistence working via `card_enhancements`, `_card_back` LRU theme-keyed, etc.). **Six real fixes (R1–R7)** plus three larger phases (render-diff layer, endless boss-variety guard, Quinte Royale unlock). **Rematch removed** at user request — game-over screen now `[Enter/Q] Menu [H] History` only. Headline changes:
|
|
95
|
+
- **R1 — AI bidding zero-rank awareness** (`src/belote/ai.py::_hard_special` / `_hard_bid`): new public `scoring.card_points_with_modifiers(card, trump, bm)` helper threads the same canonical zero-rank logic the live HUD uses into the AI's bid evaluation; AI no longer overbids Tout Atout on jack-heavy hands under Le Sauvage. Honor-counting in regular-suit branch also boss-aware.
|
|
96
|
+
- **R2 — L'Anarchie rebelote preservation** (`src/belote/game.py:919-920` + new `GameState.belote_trump: Suit | None` field): rebelote check matches against the captured belote trump, not `state.trump`, so a K-trump in trick 2 + Q in trick 3 still fires rebelote across the post-trick-2 rotation.
|
|
97
|
+
- **R5 — voucher idempotency relocated to `Voucher.apply()`** (`src/belote/belatro/items/base.py`): subclasses now implement `_apply_once(run)`; the base wrapper consults `run._applied_voucher_ids` so any future caller (replay, save/load) gets idempotency, not just the shop.
|
|
98
|
+
- **Phase 6 — diff-based render emit** (`src/belote/ui/render.py::display`): idle re-renders drop from ~28 row writes to zero by diffing the post-vcenter line list against `_last_emitted_lines`. Layout/theme changes invalidate the baseline automatically. `BELOTE_NO_DIFF=1` escape hatch.
|
|
99
|
+
- **Phase 5 — endless boss-variety guard** (`belatro/main.py:285+`): in endless, reroll if the picked boss is in the last-2 window (`run._recent_boss_ids`).
|
|
100
|
+
- **Phase 7b — undo improvements** (`gameflow.py::_undo_pop_to_south`): `Z` now pops past AI snapshots to the player's actual prior decision point and displays a 0.8 s "↶ Undo" banner so the action is visible.
|
|
101
|
+
- **Phase 8 — Quinte Royale unlock** (`belatro/progression/unlocks.py::_handle_declaration`): the Legendary joker was marked `is_unlockable` but had no path to actually unlock; `UnlockTracker.on_event` now dispatches `DeclarationScoredEvent` and grants on NS sequence ≥ 100 pts.
|
|
102
|
+
Performance verdict: 203–235 rounds/sec end-to-end (unchanged); render-diff idle byte count < 25% of full frame. Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-mutable-blanket.md`.
|
|
95
103
|
|
|
96
104
|
Past baselines:
|
|
97
105
|
|
|
106
|
+
- 3.9.0 shipped a clean three-agent audit pass (classic engine / BelAtro engine / items+UI). One LOW bug — `BelAtroAnnounce.yes_no()` hung on `Key.EOF`. Added `NO_COLOR` env-var support and the `--smoke` benchmark flag. Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-wise-puzzle.md`.
|
|
107
|
+
|
|
98
108
|
- 3.8.2 completes the five-agent audit pass. Final hardening includes Tout Atout streak persistence in BelAtro, Quinte trigger refinement, belote-pair timing fixes for jokers, and declaration scoring correctness for carrés and long sequences.
|
|
99
109
|
- Performance: test suite speed increased by mocking `interruptible_sleep`.
|
|
100
110
|
- Regression coverage maintained at 100% for game-logic modules.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: belote-cli
|
|
3
|
-
Version: 3.9.
|
|
3
|
+
Version: 3.9.3
|
|
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
|
|
@@ -254,7 +254,7 @@ belote/
|
|
|
254
254
|
│ ├── input.py # Platform-dispatched key reader and interruptible sleep
|
|
255
255
|
│ ├── stats.py # Global and session statistics tracking
|
|
256
256
|
│ └── rules.py # Game rules content
|
|
257
|
-
├── tests/ # Comprehensive test suite (
|
|
257
|
+
├── tests/ # Comprehensive test suite (691 tests)
|
|
258
258
|
├── scripts/ # Performance benchmarks
|
|
259
259
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
260
260
|
├── LICENSE # MIT License
|
|
@@ -270,14 +270,14 @@ belote/
|
|
|
270
270
|
PYTHONPATH=src pytest
|
|
271
271
|
```
|
|
272
272
|
|
|
273
|
-
Currently **
|
|
273
|
+
Currently **691 tests** passing with 100% coverage on game-logic modules (3.9.3).
|
|
274
274
|
|
|
275
275
|
## Technical Integrity
|
|
276
276
|
|
|
277
277
|
The codebase is strictly validated with the following tools:
|
|
278
278
|
- **mypy**: 0 errors (strict type safety)
|
|
279
279
|
- **ruff**: 0 violations (linting & formatting)
|
|
280
|
-
- **pytest**:
|
|
280
|
+
- **pytest**: 691/691 passed
|
|
281
281
|
- **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
|
|
282
282
|
- **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
|
|
283
283
|
|
|
@@ -211,7 +211,7 @@ belote/
|
|
|
211
211
|
│ ├── input.py # Platform-dispatched key reader and interruptible sleep
|
|
212
212
|
│ ├── stats.py # Global and session statistics tracking
|
|
213
213
|
│ └── rules.py # Game rules content
|
|
214
|
-
├── tests/ # Comprehensive test suite (
|
|
214
|
+
├── tests/ # Comprehensive test suite (691 tests)
|
|
215
215
|
├── scripts/ # Performance benchmarks
|
|
216
216
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
217
217
|
├── LICENSE # MIT License
|
|
@@ -227,14 +227,14 @@ belote/
|
|
|
227
227
|
PYTHONPATH=src pytest
|
|
228
228
|
```
|
|
229
229
|
|
|
230
|
-
Currently **
|
|
230
|
+
Currently **691 tests** passing with 100% coverage on game-logic modules (3.9.3).
|
|
231
231
|
|
|
232
232
|
## Technical Integrity
|
|
233
233
|
|
|
234
234
|
The codebase is strictly validated with the following tools:
|
|
235
235
|
- **mypy**: 0 errors (strict type safety)
|
|
236
236
|
- **ruff**: 0 violations (linting & formatting)
|
|
237
|
-
- **pytest**:
|
|
237
|
+
- **pytest**: 691/691 passed
|
|
238
238
|
- **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
|
|
239
239
|
- **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
|
|
240
240
|
|
|
@@ -16,6 +16,7 @@ from .game import (
|
|
|
16
16
|
legal_cards,
|
|
17
17
|
partner,
|
|
18
18
|
)
|
|
19
|
+
from .scoring import card_points_with_modifiers
|
|
19
20
|
|
|
20
21
|
# trick_rank(Card(trump, Rank.NINE), trump) == 8 + 6 == 14 for any trump
|
|
21
22
|
_NINE_TRUMP_RANK = 14
|
|
@@ -249,12 +250,25 @@ class AIPlayer:
|
|
|
249
250
|
TA: every card scores on the trump scale; threshold against the average
|
|
250
251
|
taker total (~62 raw). Plus a Jack-count bonus.
|
|
251
252
|
SA: every card scores on the non-trump scale; flat-Aces hand favored.
|
|
253
|
+
|
|
254
|
+
3.9.3: card values are evaluated through `card_points_with_modifiers`
|
|
255
|
+
so active zero-rank bosses (jacks_zero / aces_zero / kings_zero /
|
|
256
|
+
tens_zero / ban_clubs) suppress those ranks in the bid heuristic.
|
|
257
|
+
Pre-3.9.3 the raw `card_points` totals made the AI overbid TA on a
|
|
258
|
+
jack-heavy hand under Le Sauvage even though those jacks would score
|
|
259
|
+
zero in actual play.
|
|
252
260
|
"""
|
|
253
|
-
|
|
254
|
-
|
|
261
|
+
bm = state.boss_modifiers
|
|
262
|
+
ta_pts = sum(card_points_with_modifiers(c, Suit.TOUT_ATOUT, bm) for c in hand)
|
|
263
|
+
# The Jack-count bonus is the AI's TA strength signal — drop it if
|
|
264
|
+
# jacks are suppressed.
|
|
265
|
+
jack_bonus = (
|
|
266
|
+
0 if bm.jacks_zero
|
|
267
|
+
else sum(1 for c in hand if c.rank == Rank.JACK) * 6
|
|
268
|
+
)
|
|
255
269
|
ta_score = ta_pts + jack_bonus
|
|
256
270
|
|
|
257
|
-
sa_pts = sum(
|
|
271
|
+
sa_pts = sum(card_points_with_modifiers(c, None, bm) for c in hand)
|
|
258
272
|
# Long suits are bad under SA — opponents won't follow your suit.
|
|
259
273
|
long_suit_penalty = sum(max(0, n - 3) ** 2 for n in lengths.values()) * 4
|
|
260
274
|
sa_score = sa_pts - long_suit_penalty
|
|
@@ -487,10 +501,19 @@ class AIPlayer:
|
|
|
487
501
|
if c.suit in suit_cards:
|
|
488
502
|
suit_cards[c.suit].append(c)
|
|
489
503
|
|
|
504
|
+
bm = state.boss_modifiers
|
|
490
505
|
for suit in card_suits:
|
|
491
506
|
trump_cards = suit_cards[suit]
|
|
492
|
-
|
|
493
|
-
|
|
507
|
+
# Honors are J/9/A; drop a rank from the count if it's zeroed by a
|
|
508
|
+
# boss flag (3.9.3 — honor-counting was previously boss-blind).
|
|
509
|
+
def _is_honor(c: Card) -> bool:
|
|
510
|
+
if c.rank == Rank.JACK and bm.jacks_zero:
|
|
511
|
+
return False
|
|
512
|
+
if c.rank == Rank.ACE and bm.aces_zero:
|
|
513
|
+
return False
|
|
514
|
+
return c.rank in (Rank.JACK, Rank.NINE, Rank.ACE)
|
|
515
|
+
honor_count = sum(1 for c in trump_cards if _is_honor(c))
|
|
516
|
+
point_total = sum(card_points_with_modifiers(c, suit, bm) for c in trump_cards)
|
|
494
517
|
|
|
495
518
|
suit_scores[suit] = point_total * 0.5 + honor_count * 3
|
|
496
519
|
|
|
@@ -601,7 +624,10 @@ class AIPlayer:
|
|
|
601
624
|
"""Score a card play decision with advanced heuristics."""
|
|
602
625
|
score = 0.0
|
|
603
626
|
points = card_points_fn(card, trump, self._se)
|
|
604
|
-
#
|
|
627
|
+
# Small per-card tiebreaker: when win/loss heuristics are otherwise
|
|
628
|
+
# equal, slightly bias toward playing the higher-value card. The
|
|
629
|
+
# 0.1 coefficient keeps this strictly subordinate to the win/loss
|
|
630
|
+
# bonuses (~+5 to ~-9) below.
|
|
605
631
|
score += points * 0.1
|
|
606
632
|
|
|
607
633
|
if not trick:
|
|
@@ -99,13 +99,18 @@ class BelAtroRun:
|
|
|
99
99
|
|
|
100
100
|
# ── Idempotency guard for voucher.apply() ──────────────
|
|
101
101
|
# Several vouchers (LaTelescope, LaDoubleDonne, LesCartesDorees, LeCouteau)
|
|
102
|
-
# use `+=` against run-level counters in their `apply()`.
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
# future save/load path ever re-invokes apply() on a voucher already in
|
|
106
|
-
# `vouchers` — preventing silent double-stacking.
|
|
102
|
+
# use `+=` against run-level counters in their `apply()`. The guard now
|
|
103
|
+
# lives on `Voucher.apply()` (3.9.3 R5) so any caller — shop, replay, a
|
|
104
|
+
# future save/load round-trip — gets the same protection automatically.
|
|
107
105
|
_applied_voucher_ids: set[str] = field(default_factory=set)
|
|
108
106
|
|
|
107
|
+
# ── Recent-boss tracker (3.9.3 Phase 5) ────────────────
|
|
108
|
+
# Used by the BelAtro main loop to suppress immediate boss repeats in
|
|
109
|
+
# endless mode. The deque holds at most 2 recent boss ids; the selector
|
|
110
|
+
# rerolls if its pick is in this window. Empty by default — bounded so
|
|
111
|
+
# the run-state JSON snapshot stays small.
|
|
112
|
+
_recent_boss_ids: list[str] = field(default_factory=list)
|
|
113
|
+
|
|
109
114
|
def consume(self, item: Any, context: object = None) -> None:
|
|
110
115
|
"""Centralised consumable activation.
|
|
111
116
|
|
|
@@ -144,6 +144,19 @@ class Tarot(ABC):
|
|
|
144
144
|
|
|
145
145
|
|
|
146
146
|
class Voucher(ABC):
|
|
147
|
+
"""Permanent run-level upgrade.
|
|
148
|
+
|
|
149
|
+
Subclasses implement `_apply_once(run)` for the actual effect. The base
|
|
150
|
+
class wraps it with `apply(run)` which consults `run._applied_voucher_ids`
|
|
151
|
+
to guarantee a no-op on a second invocation — important for vouchers that
|
|
152
|
+
use `+=` semantics (LaTelescope's `+=` on joker_slots, LeCouteau's `+=`
|
|
153
|
+
on consumable_slots, etc.) that would silently double-stack on a future
|
|
154
|
+
save/load or replay round-trip.
|
|
155
|
+
|
|
156
|
+
3.9.3 — guard relocated here from `Shop._apply_item` so any future
|
|
157
|
+
caller of `voucher.apply()` (replays, deck-builder previews, etc.)
|
|
158
|
+
inherits the same protection.
|
|
159
|
+
"""
|
|
147
160
|
id: str
|
|
148
161
|
name: str
|
|
149
162
|
description: str
|
|
@@ -151,9 +164,16 @@ class Voucher(ABC):
|
|
|
151
164
|
rarity: Rarity = Rarity.COMMON
|
|
152
165
|
purchased: bool = False
|
|
153
166
|
|
|
154
|
-
@abstractmethod
|
|
155
167
|
def apply(self, run: BelAtroRun) -> None:
|
|
156
|
-
"""
|
|
168
|
+
"""Idempotent wrapper. Calls `_apply_once` only the first time per run."""
|
|
169
|
+
if self.id in run._applied_voucher_ids:
|
|
170
|
+
return
|
|
171
|
+
run._applied_voucher_ids.add(self.id)
|
|
172
|
+
self._apply_once(run)
|
|
173
|
+
|
|
174
|
+
@abstractmethod
|
|
175
|
+
def _apply_once(self, run: BelAtroRun) -> None:
|
|
176
|
+
"""Subclass-defined permanent effect. Called exactly once per run."""
|
|
157
177
|
...
|
|
158
178
|
|
|
159
179
|
|
|
@@ -15,11 +15,18 @@ class LeBanquier(Joker):
|
|
|
15
15
|
cost = 7
|
|
16
16
|
|
|
17
17
|
def on_round_end(self, event: RoundEndEvent, state: dict[str, Any]) -> JokerResult | None:
|
|
18
|
-
points
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
# Description: "Earn $1 for every 10 card points you score above the
|
|
19
|
+
# Blind target." That framing presumes NS won the contract — under a
|
|
20
|
+
# chute the points aren't "scored" in the meaningful sense, so the
|
|
21
|
+
# bonus doesn't apply. 3.9.3.
|
|
22
|
+
if event.breakdown.is_failed:
|
|
23
|
+
return None
|
|
24
|
+
# Bonus only makes sense when NS held the contract — defender points
|
|
25
|
+
# under chute are computed differently and don't represent "above
|
|
26
|
+
# target" score.
|
|
27
|
+
if event.taker_seat not in (Seat.SOUTH, Seat.NORTH):
|
|
28
|
+
return None
|
|
29
|
+
points = event.breakdown.taker_total
|
|
23
30
|
# Use a dynamic threshold from state or default to 80
|
|
24
31
|
threshold = state.get("target_score", 80)
|
|
25
32
|
bonus = max(0, (points - threshold) // 10)
|
|
@@ -13,7 +13,7 @@ class LaTelescope(Voucher):
|
|
|
13
13
|
name = "La Télescope"
|
|
14
14
|
description = "Permanent: Earn +$1 bonus after each round."
|
|
15
15
|
|
|
16
|
-
def
|
|
16
|
+
def _apply_once(self, run: BelAtroRun) -> None:
|
|
17
17
|
run.economy.bonus_per_round += 1
|
|
18
18
|
|
|
19
19
|
|
|
@@ -22,7 +22,7 @@ class LaVoute(Voucher):
|
|
|
22
22
|
name = "La Voûte"
|
|
23
23
|
description = "Earn $1 per $5 held at round end, max $5/round."
|
|
24
24
|
|
|
25
|
-
def
|
|
25
|
+
def _apply_once(self, run: BelAtroRun) -> None:
|
|
26
26
|
# Use max() rather than `=` so LaVoute can't wipe additive bonuses
|
|
27
27
|
# already granted by LesCartesDorees (which is `+=` against the same
|
|
28
28
|
# fields). LaVoute defines a floor of (rate=1, cap=5).
|
|
@@ -35,7 +35,7 @@ class LeGrimoire(Voucher):
|
|
|
35
35
|
name = "Le Grimoire"
|
|
36
36
|
description = "Shop always stocks at least one Tarot card. Permanent."
|
|
37
37
|
|
|
38
|
-
def
|
|
38
|
+
def _apply_once(self, run: BelAtroRun) -> None:
|
|
39
39
|
run.guarantee_tarot_in_shop = True
|
|
40
40
|
|
|
41
41
|
|
|
@@ -44,7 +44,7 @@ class LaDoubleDonne(Voucher):
|
|
|
44
44
|
name = "La Double Donne"
|
|
45
45
|
description = "Gain one extra Joker slot (default 5 → 6)."
|
|
46
46
|
|
|
47
|
-
def
|
|
47
|
+
def _apply_once(self, run: BelAtroRun) -> None:
|
|
48
48
|
run.joker_slots += 1
|
|
49
49
|
|
|
50
50
|
|
|
@@ -53,7 +53,7 @@ class LEncyclopedie(Voucher):
|
|
|
53
53
|
name = "L'Encyclopédie"
|
|
54
54
|
description = "Know your AI partner's bidding tendency before each round. Permanent."
|
|
55
55
|
|
|
56
|
-
def
|
|
56
|
+
def _apply_once(self, run: BelAtroRun) -> None:
|
|
57
57
|
run.show_partner_bid_tendency = True
|
|
58
58
|
|
|
59
59
|
|
|
@@ -62,7 +62,7 @@ class LesCartesDorees(Voucher):
|
|
|
62
62
|
name = "Les Cartes Dorées"
|
|
63
63
|
description = "Permanently gain +1 interest rate and +5 interest cap."
|
|
64
64
|
|
|
65
|
-
def
|
|
65
|
+
def _apply_once(self, run: BelAtroRun) -> None:
|
|
66
66
|
run.economy.interest_rate += 1
|
|
67
67
|
run.economy.max_interest += 5
|
|
68
68
|
|
|
@@ -72,7 +72,7 @@ class LeCouteau(Voucher):
|
|
|
72
72
|
name = "Le Couteau"
|
|
73
73
|
description = "Gain one extra consumable slot."
|
|
74
74
|
|
|
75
|
-
def
|
|
75
|
+
def _apply_once(self, run: BelAtroRun) -> None:
|
|
76
76
|
run.consumable_slots += 1
|
|
77
77
|
|
|
78
78
|
|
|
@@ -81,7 +81,7 @@ class LaBalance(Voucher):
|
|
|
81
81
|
name = "La Balance"
|
|
82
82
|
description = "If both teams tie in card points, your team wins the round automatically."
|
|
83
83
|
|
|
84
|
-
def
|
|
84
|
+
def _apply_once(self, run: BelAtroRun) -> None:
|
|
85
85
|
run.tie_breaks_for_taker = True
|
|
86
86
|
|
|
87
87
|
|
|
@@ -91,7 +91,7 @@ class LaSurcoinche(Voucher):
|
|
|
91
91
|
description = "Unlocks the Surcoinche contract (AI may surcoinche when you coinche)."
|
|
92
92
|
is_unlockable = True
|
|
93
93
|
|
|
94
|
-
def
|
|
94
|
+
def _apply_once(self, run: BelAtroRun) -> None:
|
|
95
95
|
run.surcoinche_unlocked = True
|
|
96
96
|
|
|
97
97
|
|
|
@@ -100,7 +100,7 @@ class LeCarnet(Voucher):
|
|
|
100
100
|
name = "Le Carnet"
|
|
101
101
|
description = "You see partner's full hand. +1 Mult each time YOU (South) win a trick."
|
|
102
102
|
|
|
103
|
-
def
|
|
103
|
+
def _apply_once(self, run: BelAtroRun) -> None:
|
|
104
104
|
run.show_north_hand = True
|
|
105
105
|
|
|
106
106
|
|
|
@@ -110,7 +110,7 @@ class CapotInsurance(Voucher):
|
|
|
110
110
|
description = "One-shot: if you chute next round, the cash penalty is halved."
|
|
111
111
|
cost = 8
|
|
112
112
|
|
|
113
|
-
def
|
|
113
|
+
def _apply_once(self, run: BelAtroRun) -> None:
|
|
114
114
|
run.capot_insurance = True
|
|
115
115
|
|
|
116
116
|
|
|
@@ -120,7 +120,7 @@ class TierceForge(Voucher):
|
|
|
120
120
|
description = "Spend 3 Tierce charges in the shop to level up a Planet contract for free."
|
|
121
121
|
cost = 6
|
|
122
122
|
|
|
123
|
-
def
|
|
123
|
+
def _apply_once(self, run: BelAtroRun) -> None:
|
|
124
124
|
# No-op at apply time. Owning the voucher enables `run.forge_tierce`
|
|
125
125
|
# which the shop UI calls when the player chooses to spend charges.
|
|
126
126
|
pass
|
|
@@ -288,8 +288,24 @@ class BelAtroGame:
|
|
|
288
288
|
# determinism fix the 3.2.0 release applied to shop generation
|
|
289
289
|
# and the three RNG-using tarots. Boss assignment was the last
|
|
290
290
|
# unseeded RNG site in the BelAtro round flow.
|
|
291
|
-
|
|
291
|
+
#
|
|
292
|
+
# 3.9.3 (Phase 5): in endless mode, suppress immediate boss
|
|
293
|
+
# repeats by rejecting a pick that's in the last-2 window.
|
|
294
|
+
# We cap the reroll attempts so we never loop on a degenerate
|
|
295
|
+
# pool (e.g. tests that monkeypatch a single boss).
|
|
296
|
+
rng = self.run._get_rng()
|
|
297
|
+
recent = self.run._recent_boss_ids
|
|
298
|
+
boss_cls = rng.choice(ALL_BOSS_MODIFIERS)
|
|
299
|
+
if self.run.endless and len(ALL_BOSS_MODIFIERS) > 3:
|
|
300
|
+
attempts = 0
|
|
301
|
+
while boss_cls().id in recent and attempts < 8:
|
|
302
|
+
boss_cls = rng.choice(ALL_BOSS_MODIFIERS)
|
|
303
|
+
attempts += 1
|
|
292
304
|
boss = boss_cls()
|
|
305
|
+
# Update the recent-boss window (keep last 2).
|
|
306
|
+
recent.append(boss.id)
|
|
307
|
+
if len(recent) > 2:
|
|
308
|
+
del recent[: len(recent) - 2]
|
|
293
309
|
BelAtroAnnounce.boss_reveal(boss, self.reader)
|
|
294
310
|
|
|
295
311
|
# Boss-specific pre-round setup, driven by boss_modifiers flags rather
|
|
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
|
|
|
4
4
|
|
|
5
5
|
from belote.game import Seat
|
|
6
6
|
|
|
7
|
-
from ..engine.event_bus import RoundEndEvent
|
|
7
|
+
from ..engine.event_bus import DeclarationScoredEvent, RoundEndEvent
|
|
8
8
|
from .save import Profile, SaveManager
|
|
9
9
|
|
|
10
10
|
if TYPE_CHECKING:
|
|
@@ -40,12 +40,30 @@ class UnlockTracker:
|
|
|
40
40
|
|
|
41
41
|
if isinstance(event, RoundEndEvent):
|
|
42
42
|
dirty |= self._handle_round_end(event)
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
elif isinstance(event, DeclarationScoredEvent):
|
|
44
|
+
dirty |= self._handle_declaration(event)
|
|
45
45
|
|
|
46
46
|
if dirty:
|
|
47
47
|
self.save_manager.save_profile(self.profile)
|
|
48
48
|
|
|
49
|
+
def _handle_declaration(self, event: DeclarationScoredEvent) -> bool:
|
|
50
|
+
"""3.9.3 Phase 8: unlock Quinte Royale (legendary joker) when NS
|
|
51
|
+
announces a Quinte. Pre-3.9.3 the joker was marked `is_unlockable`
|
|
52
|
+
but had no path to actually unlock, leaving it unreachable in the
|
|
53
|
+
shop pool.
|
|
54
|
+
"""
|
|
55
|
+
if (
|
|
56
|
+
event.seat in (Seat.SOUTH, Seat.NORTH)
|
|
57
|
+
and event.declaration_type == "sequence"
|
|
58
|
+
and event.points >= 100
|
|
59
|
+
and self.profile.unlock("quinte_royale")
|
|
60
|
+
):
|
|
61
|
+
self.pending_announcements.append(
|
|
62
|
+
"UNLOCKED: Quinte Royale (Legendary — announced a Quinte)"
|
|
63
|
+
)
|
|
64
|
+
return True
|
|
65
|
+
return False
|
|
66
|
+
|
|
49
67
|
def _handle_round_end(self, event: RoundEndEvent) -> bool:
|
|
50
68
|
dirty = False
|
|
51
69
|
|
|
@@ -172,13 +172,11 @@ class Shop:
|
|
|
172
172
|
item.on_purchase(self.run)
|
|
173
173
|
elif isinstance(item, Voucher):
|
|
174
174
|
self.run.vouchers.append(item)
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
|
|
181
|
-
self.run._applied_voucher_ids.add(item.id)
|
|
182
|
-
item.apply(self.run)
|
|
175
|
+
# 3.9.3: idempotency guard relocated to Voucher.apply() itself,
|
|
176
|
+
# so any caller (shop, replay, future save/load) gets the same
|
|
177
|
+
# protection. The base wraps the subclass's `_apply_once` and
|
|
178
|
+
# consults `run._applied_voucher_ids` automatically. Calling
|
|
179
|
+
# apply() unconditionally is now safe.
|
|
180
|
+
item.apply(self.run)
|
|
183
181
|
elif len(self.run.consumables) < self.run.consumable_slots:
|
|
184
182
|
self.run.consumables.append(item)
|