belote-cli 3.7.1__tar.gz → 3.8.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {belote_cli-3.7.1 → belote_cli-3.8.2}/CHANGELOG.md +80 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/DEVELOPMENT.md +10 -5
- {belote_cli-3.7.1 → belote_cli-3.8.2}/PKG-INFO +6 -4
- {belote_cli-3.7.1 → belote_cli-3.8.2}/README.md +5 -3
- {belote_cli-3.7.1 → belote_cli-3.8.2}/pyproject.toml +1 -1
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/__init__.py +1 -1
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/ai.py +32 -12
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/core/run_state.py +11 -1
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/core/scoring.py +3 -3
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/engine/round_driver.py +27 -11
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/annonces.py +5 -1
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/contract.py +7 -2
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/corrupted.py +4 -10
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/economy.py +7 -2
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/main.py +9 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/run/shop.py +8 -1
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/announce.py +12 -7
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/collection.py +13 -3
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/consumables.py +22 -13
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/history.py +3 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/menu.py +39 -32
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/rules.py +3 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/shop.py +92 -38
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/game.py +7 -3
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/main.py +10 -12
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/scoring.py +17 -9
- belote_cli-3.8.2/src/belote/ui/fit_guard.py +83 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/ui/layout.py +16 -1
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/ui/menu.py +6 -3
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/ui/render.py +16 -9
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_belatro.py +1 -1
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_boss_modifiers_integration.py +77 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_phase2_content.py +52 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_round_driver.py +70 -0
- belote_cli-3.8.2/tests/belatro/test_shop_empty_pools.py +89 -0
- belote_cli-3.8.2/tests/belatro/test_voucher_idempotency.py +130 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_belote.py +11 -7
- belote_cli-3.8.2/tests/test_bidding_all_pass.py +114 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_gameflow.py +1 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/.claude/settings.local.json +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/.gitignore +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/.python-version +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/LICENSE +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/scripts/benchmark.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/__init__.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/a11y.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/achievements.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/ansi.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/__init__.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/core/__init__.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/core/economy.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/engine/__init__.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/engine/event_bus.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/engine/modifier_patch.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ghost_run.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/__init__.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/base.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/__init__.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/coinche.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/planets.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/registry.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/tarots.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/vouchers.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/partner/__init__.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/partner/partner_state.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/partner/personality.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/partner/trust.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/progression/__init__.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/progression/save.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/progression/unlocks.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/run/__init__.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/run/ante.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/run/ante_themes.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/run/boss.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/run/decks.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/run_summary.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/__init__.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/hud.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/trust_bar.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/config.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/context.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/deck.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/gameflow.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/input.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/replay.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/rules.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/stats.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/themes.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/ui/__init__.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/ui/announce.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/ui/prompts.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/__init__.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/__init__.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_collection_logic.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_consumables_ui.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_contract_unlocks.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_dead_flag_fixes.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_deck_variants.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_event_bus.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_ghost_run.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_history_overlay.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_hud_synergy.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_partner_jokers.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_partner_trust.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_phase0_coverage.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_phase1_plumbing.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_phase3_meta.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_progression.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_run_summary.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_a11y.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_achievements.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_ai.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_ansi_helpers.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_declaration_tiebreak.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_extended.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_game_logic.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_input_eof.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_layout.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_new_coverage.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_official_rules.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_properties.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_replay.py +0 -0
- {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_undo.py +0 -0
|
@@ -5,6 +5,86 @@ 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.8.2] - 2026-05-14
|
|
9
|
+
|
|
10
|
+
Final logic audit and performance hardening. This release addresses the remaining edge cases identified during the deep-dive audit, focusing on BelAtro joker persistence, declaration scoring correctness, and test suite optimization. All 655 tests passing.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`src/belote/belatro/main.py` (HIGH) — Tout Atout streak now persists between rounds.** Fixed a bug where `ToutStreak` state was lost on every round transition because it wasn't being "drained" into the run-level state. It now correctly persists in `BelAtroRun.card_enhancements`.
|
|
15
|
+
- **`src/belote/belatro/items/jokers/annonces.py::QuinteRoyale` (MEDIUM) — Fixed trigger logic.** The joker previously armed on any declaration >= 100 points, including high-rank Carrés. It now correctly only arms on sequences of 5+ cards (Quintes).
|
|
16
|
+
- **`src/belote/belatro/items/jokers/economy.py::LeNotaire` and `contract.py::LeRebelle` (MEDIUM) — Refined belote-pair timing.** These jokers now trigger on the `rebelote` (second card played) instead of the first. This ensures they only subtract the 20-point bonus once it has actually been awarded to the team.
|
|
17
|
+
- **`src/belote/scoring.py::score_round` (MEDIUM) — Sequence scoring correctness.** Fixed a bug where sequences longer than 5 cards (e.g., 6 or 7 cards) were worth 0 points. They now correctly cap at 100 points (Quinte).
|
|
18
|
+
- **`src/belote/scoring.py::get_declaration_points` (MEDIUM) — Carré scoring fix.** Fixed a logic bug where Carrés were always worth 0 points due to a rank-lookup type mismatch.
|
|
19
|
+
- **`src/belote/scoring.py::_score_capot_outcome` (MEDIUM) — Capot/Zero-Final consistency.** Fixed a bug where the Capot reward did not respect the `no_dix_de_der` boss modifier. It now correctly drops the base reward by 10 points when the last-trick bonus is suppressed.
|
|
20
|
+
- **`tests/test_gameflow.py` (PERF) — Mocked `interruptible_sleep` in tests.** Resolved a 4-second delay in the test suite by ensuring UI-centric sleeps are bypassed during unit testing.
|
|
21
|
+
|
|
22
|
+
## [3.8.1] - 2026-05-14
|
|
23
|
+
|
|
24
|
+
Bug-hunt + logic audit pass. Five parallel audit agents (BelAtro core, classic engine, BelAtro items/run/partner, UI layer, performance) ran across the codebase; verification turned 8 raw findings into **3 confirmed critical bugs**, **3 medium correctness fixes**, and **1 documentation typo**. Two agent claims (`_card_beats` under Tout Atout, `_compute_belote_points` 20-when-only-K-played) were refuted on re-trace and not changed — both are working as designed. **+5 regression tests** (650 → 655).
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **`src/belote/game.py::_resolve_trick_winner` (CRITICAL) — La Rupture no longer drifts between play_card and score_round.** Pre-3.8.1 `_resolve_trick_winner` derived the previous trick's winner via `trick_winner_seat(state.completed_tricks[-1], …)` — the RAW result. Meanwhile `compute_trick_winners` (the final-scoring authority) threads the *resolved* previous winner through the chain. On trick 3+, whenever Rupture flipped trick N-1, the two paths disagreed: `state.last_trick_winner` (and downstream HUD running totals, dix-de-der attribution) reported a winner that did NOT match the final scoring tally. Fixed by reading `state.last_trick_winner` (already stored as the resolved value). Regression test in `tests/belatro/test_boss_modifiers_integration.py::test_rupture_play_card_resolves_consistently_with_scoring`.
|
|
29
|
+
- **`src/belote/belatro/engine/round_driver.py:135-148` (CRITICAL) — boss modifiers are now applied BEFORE `acc.trigger_round_start`.** Pre-3.8.1 the call order was `trigger_round_start` (which snapshots `state.boss_modifiers.no_dix_de_der` into `joker_state["no_dix_de_der"]`) → `boss.apply` (which patches the flag onto the live state). Any joker reading `state.get("no_dix_de_der", …)` (e.g. `trick_timing.py` last-trick scoring) saw the BossModifiers default `False` rather than the live boss flag — so the boss-aware joker code paths silently no-op'd on Le Zéro Final blinds. Fixed by reordering. Regression test in `tests/belatro/test_round_driver.py::test_boss_flags_applied_before_trigger_round_start`.
|
|
30
|
+
- **`src/belote/belatro/engine/round_driver.py:393-401` (CRITICAL, La Rupture follow-on) — `TrickWonEvent.winner` now carries the resolved (Rupture-aware) seat.** Pre-3.8.1 the event was emitted with `winner = trick_winner_seat(last_trick, …)` (raw); under La Rupture every joker keyed on `team_of(event.winner) == 0` would credit the team that did NOT actually receive the trick in `score_round`. Fixed by emitting `winner = state.last_trick_winner`; `trick_winner_seat` import removed from the round driver.
|
|
31
|
+
- **`src/belote/belatro/items/jokers/contract.py::LeRebelle` and `economy.py::LeNotaire` (HIGH) — belote-pair jokers no longer double-fire on `BeloteAnnouncedEvent`.** `round_driver.py` emits `BeloteAnnouncedEvent` twice per round (once when belote flips, once when rebelote flips). Pre-3.8.1 LeRebelle returned `times_mult=3.0` on both, yielding ×9 net Mult instead of ×3. LeNotaire awarded $10 instead of $5. Both now gate on `not event.is_rebelote` so the bonus fires once on the belote announce; the description ("Belote/Rebelote is worth …") matches the intent. Regression tests in `tests/belatro/test_phase2_content.py::test_le_rebelle_fires_once_per_belote_pair` and `…::test_le_notaire_pays_once_per_belote_pair`.
|
|
32
|
+
- **`src/belote/belatro/items/jokers/corrupted.py::LAgentDouble` (HIGH) — partner-sabotage half of the joker now actually triggers.** Pre-3.8.1 the joker tracked `_sabotage_remaining` in joker_state, but the AI sabotage path (`ai.py:283`) keys on `state.boss_modifiers.agent_double_active`, which the joker never set. Result: the +4 Mult half worked but "Partner plays optimally for the opponents for 2 tricks" was a no-op. Fixed by mirroring `LeTraitre`'s wiring: `on_purchase` flags `run.agent_double_joker`; `round_driver` picks it up, flips `agent_double_active=True`, and populates `agent_double_tricks` with 2 random tricks (same precedence rule — boss agent_double takes priority). New field `BelAtroRun.agent_double_joker: bool`. Regression test in `tests/belatro/test_phase2_content.py::test_lagent_double_purchase_flags_run`.
|
|
33
|
+
- **`src/belote/belatro/core/scoring.py:219,231,270` (MEDIUM, typing hardening) — `reward.get(…, 0)` defaults widened to `0.0` for the float-typed contract reward fields.** `honor_bonus` (Moon / Sans Atout), `bonus_mult_per_trick` (Sun / Tout Atout), and `coinche_multiplier` (Libra / Coinche) are declared `float` in `ContractReward` (3.7.1 BA-L1). The int-zero default propagated `int` through type inference at the consumer site, defeating part of the BA-L1 fix. Cosmetic at runtime, real for `mypy --strict` line of defense.
|
|
34
|
+
- **`src/belote/ui/layout.py:39` and `render.py:637` (DOC TYPO) — "press T for full history" → "press H for full history".** The key was renamed from T to H prior to 3.8.0; the layout comment and the last-trick-sidebar comment in render still pointed at the stale binding. T now binds to Cycle Theme; H is the canonical history key (see `input.py:163-165`, `prompts.py:217`). Visible behavior change versus pre-May-2026 builds: if you reach for T expecting history, you'll cycle the theme instead.
|
|
35
|
+
|
|
36
|
+
### Verified clean — audit findings rejected after source verification
|
|
37
|
+
|
|
38
|
+
- **`game.py::_card_beats` under Tout Atout (HIGH claim)** — agent claimed off-suit cards could beat lead-suit under TA (e.g. J♦ beating lead 7♠). Re-traced: `is_trump_card` evaluates `card.suit == trump` where `trump == Suit.TOUT_ATOUT`. Since no actual card carries `suit=TOUT_ATOUT` (the enum's `is_card_suit()` returns `False` for it), the check yields `False` for both candidates and the function correctly falls through to `return card.suit == lead_suit`. Different-suit cards never win under TA. No change.
|
|
39
|
+
- **`scoring.py::_compute_belote_points` 20-when-only-K-played (MEDIUM claim)** — agent flagged that `BELOTE_POINTS=20` is awarded when `belote_tracker[1]` is False. Reading `config.py`: `BELOTE_POINTS=20`, `REBELOTE_POINTS=40` — the design models the rebelote tier as a strict upgrade (40 total when both K and Q play, 20 partial credit when only one plays). Working as configured.
|
|
40
|
+
|
|
41
|
+
### Internal
|
|
42
|
+
|
|
43
|
+
- **Tests**: 650 → 655 (+5). Regression coverage for every CRITICAL/HIGH fix above.
|
|
44
|
+
- **Strict gates**: pytest 655/655 green, mypy `--strict` 0 errors (78 files), ruff 0 violations.
|
|
45
|
+
- **Version markers bumped**: `pyproject.toml`, `src/belote/__init__.py`.
|
|
46
|
+
|
|
47
|
+
## [3.8.0] - 2026-05-13
|
|
48
|
+
|
|
49
|
+
UI-cutoff pass, audit polish, and minor perf wins. The session began with a user-reported bug — the main-menu croissant art clipped at the top on certain terminal heights — and broadened into a full UI-fit overhaul (live "terminal too small" overlay, BelAtro screens rebuilt around vertical centering, shop action buttons relocated below the cards). A second three-Explore-agent audit ran across the classic engine, BelAtro mode, and render/AI hot paths: **no critical findings**, but three minor hardening items (zip-strict, voucher idempotency, all-pass-bidding test gap) and three modest perf wins shipped. **+15 regression tests** (635 → 650). Plan file at `/home/mrrobot/.claude/plans/i-want-to-fix-swirling-pelican.md`.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- **`src/belote/ui/menu.py:116` — classic main-menu croissant no longer clips at the top.** Pre-3.8.0 the guard read `if term_h < 42: return final_cup`, but the full content needs 41 art + 2 footer = 43 rows. At `term_h == 42` the croissant was rendered and the top row scrolled off the alt-screen; at 43 it sat flush against the top with zero margin. New threshold is derived from `len(get_cards_art()) + 1 + len(CUP_TEMPLATE) + 2 + 1` (= 44), so the croissant only shows when there is genuine room for it plus one row of breathing space.
|
|
54
|
+
- **`src/belote/ui/main.py:115-123` — startup "terminal too small" hard-fail replaced with a live overlay.** Pre-3.8.0 a sub-80×32 terminal got `print()` + `sys.exit(1)` before the alt-screen was entered, polluting scrollback and offering no recovery. New `src/belote/ui/fit_guard.py::require_minimum` paints a centered "Resize to 80×32 (currently NN×MM) — Press Q to quit" inside the alt-screen, refreshes on SIGWINCH (the existing handler at `render.py:99` invalidates the size cache), and returns the moment the terminal is large enough. `FitAbortedError` raised on Q/EOF.
|
|
55
|
+
- **BelAtro screens converted to a list-build + vcenter pattern; absolute-row writes removed.** `belatro/ui/{shop,menu,announce,collection,consumables}.py` previously pinned content to hardcoded `move(N, …)` rows (e.g. shop description at row 18, BelAtro art at rows 3-9, boss reveal at rows 10/13/15). New `src/belote/ui/layout.py::vcenter_lines(lines, term_h)` — extracted from `render.py:899-903` — pads top + bottom so every BelAtro screen centers vertically and never clips. `history.py` and `rules.py` (already responsive) only gain a `require_minimum` call at loop entry.
|
|
56
|
+
- **`belatro/ui/shop.py::_render` — reroll / forge buttons moved BELOW the card row.** Pre-3.8.0 the bracket-text labels (`[ Reroll $5 ]`, `[ Forge x3/3 ]`) rendered at `card_start_row + 3` — mid-frame across the card art — and `_card_col`'s 18-cell spacing × 5+ columns overflowed at 80 cols when the forge slot was visible. New layout places the action strip on its own row directly below the cards, centered. No card frame width change, no inventory cap. `_card_col` now centers the card strip and tightens inter-card gap (down to 0) before any card would extend past `term_w - 2`.
|
|
57
|
+
- **`src/belote/belatro/run/shop.py::_apply_item` — voucher idempotency guard (B1 audit finding, MINOR hardening).** `LaTelescope`, `LaDoubleDonne`, `LesCartesDorees`, `LeCouteau` use `+=` against `BelAtroRun` state in their `apply()`. The only call site is `Shop.buy_item`, which fires apply() exactly once per purchase — so the existing code was safe in practice. But a future save/load round-trip that re-invokes apply() on a voucher already in `run.vouchers` would silently double the bonus. New `BelAtroRun._applied_voucher_ids: set[str]` field; the shop checks-and-marks before each apply(). LaVoute's `max()` floor pattern is its own idempotency mechanism and is unchanged. 6 regression tests in `tests/belatro/test_voucher_idempotency.py`.
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- **`src/belote/ui/render.py::patch_trick_card` — HUD rebuild is now opt-in via `force_hud: bool = False` (P1).** Pre-3.8.0 every of the 32 card-play patches per round rebuilt the entire HUD bar at row 1, even though `_build_hud` reads `state.current_round_points` / `state.team_scores`, neither of which advance until `play_card` commits the completed trick (which the caller then re-renders via `display()`). New default skips the rebuild; pass `force_hud=True` when a caller knows HUD-affecting state has changed externally. Saves ~300 µs per round plus a chunk of ANSI bytes; preserves correctness because the next `display()` call refreshes the HUD anyway.
|
|
62
|
+
- **`src/belote/ai.py::AIMemory` — partner_hand rebuild memoised on the same trick-progress key as void inference (P2).** Pre-3.8.0 every `decide_card()` call (32 × per round) cleared and rebuilt `partner_hand` from `state.hand_of(partner(self.seat))`, even though the partner's hand only changes when they play a card. New `last_partner_hand_key: tuple[int, int]` mirrors the existing `last_voids_key` pattern (`(completed_count, current_trick_len)`); skip rebuild on a no-op repeat call. Saves ~200 µs per round; reset properly on new round and mid-round undo (mirrors the existing void-cache reset paths).
|
|
63
|
+
- **`src/belote/ai.py::_hard_bid` — pre-compute suit-bucketed hand in one pass (P4).** Pre-3.8.0 each of the four suit iterations re-filtered `hand` (twice for trump/honour counts, plus inner cross-suit counts) — 12 hand walks per bid evaluation. New `suit_cards: dict[Suit, list[Card]]` is built with a single `for c in hand` pass and reused. Readability win is bigger than the µs perf win on 8-card hands; pattern matches what `_special_bid` already does.
|
|
64
|
+
- **`src/belote/scoring.py:298,301,313,316` — declaration tie-break zips upgraded to `strict=True` (C1).** Walks `ns_carres` / `ns_carre_seats` (and `ns_seqs` / `ns_seq_seats`) in lockstep with the parallel lists built at `scoring.py:274`. Today the invariant holds; `strict=True` defends against a future edit that breaks it silently.
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
|
|
68
|
+
- **`src/belote/ui/fit_guard.py` (new) — `require_minimum(reader, min_cols, min_rows)` + `FitAbortedError`.** Live overlay used by `main.py` (once at startup) and every BelAtro screen loop. Refreshes on SIGWINCH, dismisses automatically once the user resizes past the floor, raises `FitAbortedError` on Q / EOF for clean caller cleanup.
|
|
69
|
+
- **`src/belote/ui/layout.py::vcenter_lines(lines, term_h)`** — pure helper extracted from `render.py:899-903`. Pads top + bottom so a list of lines sits centered in `term_h`; truncates if oversized. Used by every BelAtro screen and by classic `render()`.
|
|
70
|
+
- **`tests/test_bidding_all_pass.py` (new, 5 tests)** — pins all-pass redeal edge cases beyond the basic `test_new_coverage.py::test_all_pass_redeal` smoke. Covers full state reset (bids / bidder_index / bidding_round / trump / taker), multi-redeal dealer rotation, the "round 2 + all pass" path that must redeal (not advance to a phantom round 3), and successful post-redeal bidding.
|
|
71
|
+
- **`tests/belatro/test_voucher_idempotency.py` (new, 6 tests)** — pins the B1 guard contract.
|
|
72
|
+
- **`tests/belatro/test_shop_empty_pools.py` (new, 4 tests)** — B2 audit gap: regression test for empty registry pools (degenerate Profile / full-unlock state). `_empty_pools` helper monkeypatches and bumps the registry generation so cached `get_available_*` views miss.
|
|
73
|
+
|
|
74
|
+
### Verified clean — audit findings rejected after source verification
|
|
75
|
+
|
|
76
|
+
Documented so the next cycle doesn't re-investigate:
|
|
77
|
+
|
|
78
|
+
- **`render.py:807-809` "unconditional `legal_cards` call" (P3 candidate)** — the call IS inside `if state.phase == Phase.PLAYING and state.turn == Seat.SOUTH`; the perf agent misread the guard. No change needed.
|
|
79
|
+
- **Tout Atout void-in-lead-suit discard logic (`game.py:560-578`, initial agent CRITICAL claim)** — verified correct per official rules: off-suit cards may be played when void but never win tricks. Comments and code agree.
|
|
80
|
+
- **Memory-documented 3.6.0 / 3.7.1 fixes** (L'Accumulateur team check, Libra reachability via NS-taker coinche, synergy validation under `python -O`, event-bus handler isolation, `PatchedGameState` underscore narrowing) — all still in place; grep for `boss.id ==` returns zero hits in production paths.
|
|
81
|
+
|
|
82
|
+
### Internal
|
|
83
|
+
|
|
84
|
+
- **Tests**: 635 → 650 (+15). Three new test files (`test_bidding_all_pass.py` ×5, `test_voucher_idempotency.py` ×6, `test_shop_empty_pools.py` ×4).
|
|
85
|
+
- **Strict gates**: pytest 650/650 green, mypy `--strict` 0 errors (78 files), ruff 0 violations.
|
|
86
|
+
- **Version markers bumped**: `pyproject.toml`, `src/belote/__init__.py`.
|
|
87
|
+
|
|
8
88
|
## [3.7.1] - 2026-05-13
|
|
9
89
|
|
|
10
90
|
Bug-hunt, performance, and logic audit pass plus the three items 3.6.0 deferred to 3.7.0. Three Explore agents ran in parallel against the documented false-positive catalogue. The classic-engine sweep returned **no novel findings** — the 3.4.x → 3.6.0 audits have absorbed the available correctness surface. The BelAtro layer produced **2 confirmed bugs** (one HIGH, one MEDIUM) and **1 polish item**. The deferred 3.7.0 items — `score_round` / `play_card` refactor, partner-joker test coverage, player-facing NS-taker surcoinche — all land here. **+36 regression tests** (599 → 635). Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-sequential-map.md`.
|
|
@@ -84,14 +84,19 @@ 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 (655 tests expected)
|
|
88
88
|
PYTHONPATH=src pytest
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
Current baseline (3.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
- **
|
|
91
|
+
Current baseline (3.8.1):
|
|
92
|
+
Current baseline (3.8.2):
|
|
93
|
+
|
|
94
|
+
- **655 tests** passing.
|
|
95
|
+
- 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.
|
|
96
|
+
- Performance: test suite speed increased by mocking `interruptible_sleep`.
|
|
97
|
+
- Regression coverage maintained at 100% for game-logic modules.
|
|
98
|
+
|
|
99
|
+
- 3.8.0 ships UI-fit fixes (croissant cutoff at term_h=42-43, BelAtro hardcoded-row clipping, live "terminal too small" overlay replacing the startup hard-fail, shop reroll/forge moved below cards) plus a fresh three-agent audit. Audit produced **no critical findings**; three minor hardening items (zip-strict in declaration tie-breaks, voucher idempotency guard, all-pass-bidding test gap) and three modest perf wins (skip HUD rebuild in `patch_trick_card`, memoise AI `partner_hand`, single-pass `_hard_bid` suit bucketing) shipped. **+15 regression tests** (635 → 650). Plan file at `/home/mrrobot/.claude/plans/i-want-to-fix-swirling-pelican.md`.
|
|
95
100
|
- 3.7.1 lands the deferred 3.7.0 items plus a fresh audit pass. Three Explore agents ran in parallel against the documented false-positive catalogue. The classic-engine sweep returned no novel findings (3.4.x → 3.6.0 absorbed the surface); the BelAtro layer produced **BA-L2** (L'Accumulateur team→seat bug, HIGH) and **BA-L1** (`ContractReward` TypedDict float annotations, MEDIUM). Deferred items: **D1** — `score_round` and `play_card` extracted behind `_ScoringContext` / `_PlayContext` (zero test edits, behaviour-preserving); **D2** — `tests/belatro/test_partner_jokers.py` adds 26 tests, **100% coverage** for `passive` / `risky` / `shaper` partner-joker modules; **D3** — `prompt_surcoinche` callback on `RoundUICallbacks` plus NS-taker player-surcoinche path in `round_driver.py:268-283`. **+36 regression tests** (599 → 635). Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-sequential-map.md`.
|
|
96
101
|
- 3.6.0 lands a verified bug-hunt and refactor pass over the classic engine and the BelAtro roguelite layer. Three Explore agents produced ~50 candidate findings; verification against current code rejected several as false positives (notably "dix-de-der double counting" — separate counters; "underscore-boss-attr anti-pattern" — already pinned by tests) and confirmed the items shipped here. **+4 regression tests** (595 → 599). Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-functional-naur.md`.
|
|
97
102
|
- **H1** (`belatro/engine/round_driver.py:210-289`) — EW AI can now coinche an NS taker via a new `_ew_should_coinche` heuristic. Pre-3.6.0 there was no path that set `coinche_level > 0` when NS was taker (outside `auto_coinche` / `start_coinched`), making the Libra planet effectively unreachable in natural play.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: belote-cli
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.8.2
|
|
4
4
|
Summary: A 4-player terminal card game
|
|
5
5
|
Project-URL: Homepage, https://github.com/ElysiumDisc/belote
|
|
6
6
|
Project-URL: Repository, https://github.com/ElysiumDisc/belote
|
|
@@ -71,6 +71,8 @@ belatro
|
|
|
71
71
|
| **Le Vétéran** | Start with a random **Planet** card pre-applied to level up a contract |
|
|
72
72
|
| **Le Flambeur** | Starts with **L'Aventurier** Partner Joker (×2 Mult if both win ≥3 tricks) |
|
|
73
73
|
| L'Anarchiste | Start $19 — Corrupted pool visible |
|
|
74
|
+
| **Le Marseillais** | Annonces (Tierce / Quarte / Quinte) score ×2. Belote / Rebelote disabled |
|
|
75
|
+
| **Le Coincheur** | Every round starts pre-coinched. +50 starting Chips, $8 starting cash |
|
|
74
76
|
|
|
75
77
|
### Notable Vouchers
|
|
76
78
|
| Voucher | Effect |
|
|
@@ -252,7 +254,7 @@ belote/
|
|
|
252
254
|
│ ├── input.py # Platform-dispatched key reader and interruptible sleep
|
|
253
255
|
│ ├── stats.py # Global and session statistics tracking
|
|
254
256
|
│ └── rules.py # Game rules content
|
|
255
|
-
├── tests/ # Comprehensive test suite (
|
|
257
|
+
├── tests/ # Comprehensive test suite (655 tests)
|
|
256
258
|
├── scripts/ # Performance benchmarks
|
|
257
259
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
258
260
|
├── LICENSE # MIT License
|
|
@@ -268,14 +270,14 @@ belote/
|
|
|
268
270
|
PYTHONPATH=src pytest
|
|
269
271
|
```
|
|
270
272
|
|
|
271
|
-
Currently **
|
|
273
|
+
Currently **655 tests** passing with 100% coverage on game-logic modules (3.8.2).
|
|
272
274
|
|
|
273
275
|
## Technical Integrity
|
|
274
276
|
|
|
275
277
|
The codebase is strictly validated with the following tools:
|
|
276
278
|
- **mypy**: 0 errors (strict type safety)
|
|
277
279
|
- **ruff**: 0 violations (linting & formatting)
|
|
278
|
-
- **pytest**:
|
|
280
|
+
- **pytest**: 655/655 passed
|
|
279
281
|
- **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
|
|
280
282
|
- **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
|
|
281
283
|
|
|
@@ -28,6 +28,8 @@ belatro
|
|
|
28
28
|
| **Le Vétéran** | Start with a random **Planet** card pre-applied to level up a contract |
|
|
29
29
|
| **Le Flambeur** | Starts with **L'Aventurier** Partner Joker (×2 Mult if both win ≥3 tricks) |
|
|
30
30
|
| L'Anarchiste | Start $19 — Corrupted pool visible |
|
|
31
|
+
| **Le Marseillais** | Annonces (Tierce / Quarte / Quinte) score ×2. Belote / Rebelote disabled |
|
|
32
|
+
| **Le Coincheur** | Every round starts pre-coinched. +50 starting Chips, $8 starting cash |
|
|
31
33
|
|
|
32
34
|
### Notable Vouchers
|
|
33
35
|
| Voucher | Effect |
|
|
@@ -209,7 +211,7 @@ belote/
|
|
|
209
211
|
│ ├── input.py # Platform-dispatched key reader and interruptible sleep
|
|
210
212
|
│ ├── stats.py # Global and session statistics tracking
|
|
211
213
|
│ └── rules.py # Game rules content
|
|
212
|
-
├── tests/ # Comprehensive test suite (
|
|
214
|
+
├── tests/ # Comprehensive test suite (655 tests)
|
|
213
215
|
├── scripts/ # Performance benchmarks
|
|
214
216
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
215
217
|
├── LICENSE # MIT License
|
|
@@ -225,14 +227,14 @@ belote/
|
|
|
225
227
|
PYTHONPATH=src pytest
|
|
226
228
|
```
|
|
227
229
|
|
|
228
|
-
Currently **
|
|
230
|
+
Currently **655 tests** passing with 100% coverage on game-logic modules (3.8.2).
|
|
229
231
|
|
|
230
232
|
## Technical Integrity
|
|
231
233
|
|
|
232
234
|
The codebase is strictly validated with the following tools:
|
|
233
235
|
- **mypy**: 0 errors (strict type safety)
|
|
234
236
|
- **ruff**: 0 violations (linting & formatting)
|
|
235
|
-
- **pytest**:
|
|
237
|
+
- **pytest**: 655/655 passed
|
|
236
238
|
- **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
|
|
237
239
|
- **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
|
|
238
240
|
|
|
@@ -38,6 +38,10 @@ class AIMemory:
|
|
|
38
38
|
# (completed_count, current_trick_len) of the last _update_voids call.
|
|
39
39
|
# Lets us skip re-scanning a stable transient trick on each decision.
|
|
40
40
|
self.last_voids_key: tuple[int, int] | None = None
|
|
41
|
+
# (completed_count, current_trick_len) of the last partner_hand
|
|
42
|
+
# rebuild. The partner's hand only changes when they play a card or
|
|
43
|
+
# a new round starts; same memo pattern as `last_voids_key`.
|
|
44
|
+
self.last_partner_hand_key: tuple[int, int] | None = None
|
|
41
45
|
|
|
42
46
|
|
|
43
47
|
class AIPlayer:
|
|
@@ -76,6 +80,7 @@ class AIPlayer:
|
|
|
76
80
|
self.memory.partner_hand.clear()
|
|
77
81
|
self.memory.processed_tricks_count = 0
|
|
78
82
|
self.memory.last_voids_key = None
|
|
83
|
+
self.memory.last_partner_hand_key = None
|
|
79
84
|
elif (
|
|
80
85
|
self.memory.last_voids_key is not None
|
|
81
86
|
and (completed_count, current_count) < self.memory.last_voids_key
|
|
@@ -90,6 +95,7 @@ class AIPlayer:
|
|
|
90
95
|
self.memory.known_voids[s].clear()
|
|
91
96
|
self.memory.processed_tricks_count = 0
|
|
92
97
|
self.memory.last_voids_key = None
|
|
98
|
+
self.memory.last_partner_hand_key = None
|
|
93
99
|
|
|
94
100
|
# Track all cards in completed tricks
|
|
95
101
|
for trick in state.completed_tricks:
|
|
@@ -98,16 +104,21 @@ class AIPlayer:
|
|
|
98
104
|
for tc in state.current_trick:
|
|
99
105
|
self.memory.played.add(tc.card)
|
|
100
106
|
|
|
101
|
-
# Partner's hand is visible (for NS team, South sees North's plays)
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
# Partner's hand is visible (for NS team, South sees North's plays).
|
|
108
|
+
# It only changes when partner plays a card (i.e. when
|
|
109
|
+
# (completed_count, current_count) advances). Memo on the same key
|
|
110
|
+
# the void cache uses; skip the rebuild on a no-op repeat call.
|
|
111
|
+
partner_key = (completed_count, current_count)
|
|
112
|
+
if self.memory.last_partner_hand_key != partner_key:
|
|
113
|
+
p = partner(self.seat)
|
|
114
|
+
self.memory.partner_hand.clear()
|
|
115
|
+
if (
|
|
116
|
+
state.phase in (Phase.PLAYING, Phase.SCORING)
|
|
117
|
+
and not state.boss_modifiers.hide_partner_hand
|
|
118
|
+
):
|
|
119
|
+
for card in state.hand_of(p):
|
|
120
|
+
self.memory.partner_hand.add(card)
|
|
121
|
+
self.memory.last_partner_hand_key = partner_key
|
|
111
122
|
|
|
112
123
|
def decide_bid(self, state: GameState) -> BidValue:
|
|
113
124
|
"""Decide whether to bid and which contract.
|
|
@@ -467,8 +478,17 @@ class AIPlayer:
|
|
|
467
478
|
suit_scores: dict[Suit, float] = dict.fromkeys(card_suits, 0.0)
|
|
468
479
|
personality = self._rng.uniform(-0.8, 0.8)
|
|
469
480
|
|
|
481
|
+
# Bucket the hand by suit in a single pass. Pre-3.8.0 each suit-loop
|
|
482
|
+
# iteration re-filtered the hand twice (4 suits × 2 walks), and the
|
|
483
|
+
# inner cross-suit lookup re-counted other suits — 12 hand walks
|
|
484
|
+
# total. Single-pass bucketing collapses that to one walk.
|
|
485
|
+
suit_cards: dict[Suit, list[Card]] = {s: [] for s in card_suits}
|
|
486
|
+
for c in hand:
|
|
487
|
+
if c.suit in suit_cards:
|
|
488
|
+
suit_cards[c.suit].append(c)
|
|
489
|
+
|
|
470
490
|
for suit in card_suits:
|
|
471
|
-
trump_cards = [
|
|
491
|
+
trump_cards = suit_cards[suit]
|
|
472
492
|
honor_count = sum(1 for c in trump_cards if c.rank in (Rank.JACK, Rank.NINE, Rank.ACE))
|
|
473
493
|
point_total = sum(card_points_fn(c, suit, self._se) for c in trump_cards)
|
|
474
494
|
|
|
@@ -482,7 +502,7 @@ class AIPlayer:
|
|
|
482
502
|
|
|
483
503
|
for other in card_suits:
|
|
484
504
|
if other != suit:
|
|
485
|
-
other_count =
|
|
505
|
+
other_count = len(suit_cards[other])
|
|
486
506
|
if other_count == 0:
|
|
487
507
|
suit_scores[suit] += 2
|
|
488
508
|
elif other_count == 1:
|
|
@@ -47,7 +47,8 @@ class BelAtroRun:
|
|
|
47
47
|
guarantee_tarot_in_shop: bool = False
|
|
48
48
|
show_partner_bid_tendency: bool = False
|
|
49
49
|
tie_breaks_for_taker: bool = False
|
|
50
|
-
partner_throws_trick: bool = False
|
|
50
|
+
partner_throws_trick: bool = False # Le Traître joker (1 random trick/round)
|
|
51
|
+
agent_double_joker: bool = False # L'Agent Double joker (2 random tricks/round)
|
|
51
52
|
capot_insurance: bool = False # one-shot: halve a chute loss
|
|
52
53
|
|
|
53
54
|
# ── Phase 1+ feature flags ──────────────────────────────
|
|
@@ -96,6 +97,15 @@ class BelAtroRun:
|
|
|
96
97
|
seed: int | None = None
|
|
97
98
|
_rng: Any = None
|
|
98
99
|
|
|
100
|
+
# ── Idempotency guard for voucher.apply() ──────────────
|
|
101
|
+
# Several vouchers (LaTelescope, LaDoubleDonne, LesCartesDorees, LeCouteau)
|
|
102
|
+
# use `+=` against run-level counters in their `apply()`. Today the only
|
|
103
|
+
# call site is `Shop.buy_item`, which fires apply() exactly once per
|
|
104
|
+
# purchase. This set lets the shop short-circuit re-application if a
|
|
105
|
+
# future save/load path ever re-invokes apply() on a voucher already in
|
|
106
|
+
# `vouchers` — preventing silent double-stacking.
|
|
107
|
+
_applied_voucher_ids: set[str] = field(default_factory=set)
|
|
108
|
+
|
|
99
109
|
def consume(self, item: Any, context: object = None) -> None:
|
|
100
110
|
"""Centralised consumable activation.
|
|
101
111
|
|
|
@@ -216,7 +216,7 @@ class ScoreAccumulator:
|
|
|
216
216
|
# The Moon (Sans Atout): honor bonus per honor won
|
|
217
217
|
if event.trump is None:
|
|
218
218
|
moon_reward = self.contract_levels.get("sans_atout", {})
|
|
219
|
-
honor_bonus = moon_reward.get("honor_bonus", 0)
|
|
219
|
+
honor_bonus = moon_reward.get("honor_bonus", 0.0)
|
|
220
220
|
if honor_bonus:
|
|
221
221
|
honors = sum(
|
|
222
222
|
1 for c in event.cards
|
|
@@ -228,7 +228,7 @@ class ScoreAccumulator:
|
|
|
228
228
|
# The Sun (Tout Atout): +X Mult per trick beyond the 4th
|
|
229
229
|
if event.trump == Suit.TOUT_ATOUT and event.trick_number > 4:
|
|
230
230
|
sun_reward = self.contract_levels.get("tout_atout", {})
|
|
231
|
-
sun_mult = sun_reward.get("bonus_mult_per_trick", 0)
|
|
231
|
+
sun_mult = sun_reward.get("bonus_mult_per_trick", 0.0)
|
|
232
232
|
if sun_mult:
|
|
233
233
|
new_mult += sun_mult
|
|
234
234
|
self._log.append(
|
|
@@ -267,7 +267,7 @@ class ScoreAccumulator:
|
|
|
267
267
|
and not event.breakdown.is_failed
|
|
268
268
|
):
|
|
269
269
|
libra_reward = self.contract_levels.get("coinche", {})
|
|
270
|
-
libra_mult: float = libra_reward.get("coinche_multiplier", 0)
|
|
270
|
+
libra_mult: float = libra_reward.get("coinche_multiplier", 0.0)
|
|
271
271
|
if libra_mult:
|
|
272
272
|
libra_bonus: float = libra_mult * event.coinche_level
|
|
273
273
|
new_mult += libra_bonus
|
|
@@ -6,7 +6,7 @@ from dataclasses import replace
|
|
|
6
6
|
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
8
|
from belote.ai import AIPlayer, Difficulty
|
|
9
|
-
from belote.deck import Card,
|
|
9
|
+
from belote.deck import Card, Suit
|
|
10
10
|
from belote.game import (
|
|
11
11
|
SANS_ATOUT_BID,
|
|
12
12
|
BidValue,
|
|
@@ -18,7 +18,6 @@ from belote.game import (
|
|
|
18
18
|
play_card,
|
|
19
19
|
process_bid,
|
|
20
20
|
start_round,
|
|
21
|
-
trick_winner_seat,
|
|
22
21
|
)
|
|
23
22
|
from belote.scoring import get_declaration_points, is_capot, score_round
|
|
24
23
|
|
|
@@ -132,10 +131,21 @@ def drive_round(
|
|
|
132
131
|
new_jstate2 = {**state._joker_state, "agent_double_tricks": sabotage}
|
|
133
132
|
state = replace(state, boss_modifiers=new_bm, _joker_state=new_jstate2)
|
|
134
133
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
# L'Agent Double joker (corrupted): like Le Traître but two sabotage tricks.
|
|
135
|
+
# Same precedence rule — boss agent_double takes priority.
|
|
136
|
+
if (
|
|
137
|
+
state._joker_state.get("agent_double_joker_active")
|
|
138
|
+
and not state.boss_modifiers.agent_double_active
|
|
139
|
+
):
|
|
140
|
+
sabotage2 = frozenset(rng.sample(range(1, 9), 2))
|
|
141
|
+
new_bm2 = replace(state.boss_modifiers, agent_double_active=True)
|
|
142
|
+
new_jstate3 = {**state._joker_state, "agent_double_tricks": sabotage2}
|
|
143
|
+
state = replace(state, boss_modifiers=new_bm2, _joker_state=new_jstate3)
|
|
144
|
+
|
|
145
|
+
# B1: Apply boss modifier flags onto the frozen GameState so play_card sees them.
|
|
146
|
+
# Must run BEFORE acc.trigger_round_start so the accumulator's snapshot of
|
|
147
|
+
# boss-derived flags (e.g. joker_state["no_dix_de_der"]) reflects the live
|
|
148
|
+
# boss state instead of the BossModifiers defaults.
|
|
139
149
|
if boss is not None:
|
|
140
150
|
from ..engine.modifier_patch import PatchedGameState
|
|
141
151
|
|
|
@@ -147,6 +157,9 @@ def drive_round(
|
|
|
147
157
|
# Ensure boss modifiers don't use stale cached logic/values
|
|
148
158
|
clear_legal_cards_cache()
|
|
149
159
|
|
|
160
|
+
if acc is not None:
|
|
161
|
+
state = acc.trigger_round_start(state)
|
|
162
|
+
|
|
150
163
|
# Populate sabotage_tricks for any path that flagged agent_double_active.
|
|
151
164
|
# Sources: L'Agent Double boss (3 random tricks), BetrayalArc (tricks 4-8 via
|
|
152
165
|
# agent_double_late_only flag), traitre joker (already populated above).
|
|
@@ -377,11 +390,14 @@ def drive_round(
|
|
|
377
390
|
if is_last_in_trick(state):
|
|
378
391
|
last_trick = state.completed_tricks[-1]
|
|
379
392
|
|
|
380
|
-
winner
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
393
|
+
# Use the resolved winner cached by play_card (Rupture-aware).
|
|
394
|
+
# 3.8.1: pre-fix this re-derived via trick_winner_seat, which
|
|
395
|
+
# returns the RAW winner; under La Rupture the event.winner
|
|
396
|
+
# would disagree with the team scoring authority.
|
|
397
|
+
winner = state.last_trick_winner
|
|
398
|
+
assert winner is not None, (
|
|
399
|
+
"last_trick_winner unset after a complete trick — "
|
|
400
|
+
"GameState invariant violated."
|
|
385
401
|
)
|
|
386
402
|
# Use state diff to get points; perfectly handles all boss-aware points and Dix de Der
|
|
387
403
|
points = sum(state.current_round_points) - old_pts_total
|
|
@@ -73,7 +73,11 @@ class QuinteRoyale(Joker):
|
|
|
73
73
|
def on_declaration(
|
|
74
74
|
self, event: DeclarationScoredEvent, state: dict[str, Any]
|
|
75
75
|
) -> JokerResult | None:
|
|
76
|
-
if
|
|
76
|
+
if (
|
|
77
|
+
event.seat in (Seat.SOUTH, Seat.NORTH)
|
|
78
|
+
and event.declaration_type == "sequence"
|
|
79
|
+
and event.points >= 100
|
|
80
|
+
):
|
|
77
81
|
# Quinte = 100 pts in classic belote scoring; mark for round-end mult.
|
|
78
82
|
state[f"{self.id}_armed"] = True
|
|
79
83
|
return None
|
|
@@ -93,8 +93,13 @@ class LeRebelle(Joker):
|
|
|
93
93
|
description = "The Belote/Rebelote declaration gives ×3 Mult instead of a flat 20 points."
|
|
94
94
|
cost = 8
|
|
95
95
|
|
|
96
|
-
def on_belote(
|
|
97
|
-
|
|
96
|
+
def on_belote(
|
|
97
|
+
self, event: BeloteAnnouncedEvent, state: dict[str, Any]
|
|
98
|
+
) -> JokerResult | None:
|
|
99
|
+
# Belote/Rebelote points (20) are only awarded if both cards are played.
|
|
100
|
+
# Gate on the second event (rebelote) so we only subtract points that
|
|
101
|
+
# the player actually earned.
|
|
102
|
+
if event.seat == Seat.SOUTH and event.is_rebelote:
|
|
98
103
|
return JokerResult(add_chips=-20, times_mult=3.0)
|
|
99
104
|
return None
|
|
100
105
|
|
|
@@ -69,18 +69,12 @@ class LAgentDouble(Joker):
|
|
|
69
69
|
cost = 9
|
|
70
70
|
is_corrupted = True
|
|
71
71
|
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
def on_purchase(self, run: BelAtroRun) -> None:
|
|
73
|
+
# Flag the run so round_driver flips agent_double_active + populates
|
|
74
|
+
# a 2-trick sabotage set every round. Mirrors Le Traître's wiring.
|
|
75
|
+
run.agent_double_joker = True
|
|
75
76
|
|
|
76
77
|
def on_trick_won(self, event: TrickWonEvent, state: dict[str, Any]) -> JokerResult | None:
|
|
77
|
-
# Count down the sabotage window once per trick regardless of who won —
|
|
78
|
-
# otherwise NS sweeping the round leaves the sabotage flag stuck on for
|
|
79
|
-
# the whole game. The "for 2 tricks" wording in the description is
|
|
80
|
-
# absolute, not "until the opponents have won 2".
|
|
81
|
-
remaining = state.get(f"{self.id}_sabotage_remaining", 0)
|
|
82
|
-
if remaining > 0:
|
|
83
|
-
state[f"{self.id}_sabotage_remaining"] = remaining - 1
|
|
84
78
|
if event.winner == Seat.SOUTH:
|
|
85
79
|
return JokerResult(add_mult=4.0)
|
|
86
80
|
return None
|
|
@@ -46,7 +46,12 @@ class LeNotaire(Joker):
|
|
|
46
46
|
description = "Belote/Rebelote is worth $5 cash instead of 20 flat points."
|
|
47
47
|
cost = 6
|
|
48
48
|
|
|
49
|
-
def on_belote(
|
|
50
|
-
|
|
49
|
+
def on_belote(
|
|
50
|
+
self, event: BeloteAnnouncedEvent, state: dict[str, Any]
|
|
51
|
+
) -> JokerResult | None:
|
|
52
|
+
# Belote/Rebelote points (20) are only awarded if both cards are played.
|
|
53
|
+
# Gate on the second event (rebelote) so we only subtract points that
|
|
54
|
+
# the player actually earned.
|
|
55
|
+
if event.seat == Seat.SOUTH and event.is_rebelote:
|
|
51
56
|
return JokerResult(add_chips=-20, add_money=5)
|
|
52
57
|
return None
|
|
@@ -327,6 +327,10 @@ class BelAtroGame:
|
|
|
327
327
|
# Le Traître joker: partner sabotages one random trick per round.
|
|
328
328
|
# round_driver picks the trick + reuses the agent_double AI path.
|
|
329
329
|
round_flags["traitre_active"] = True
|
|
330
|
+
if self.run.agent_double_joker:
|
|
331
|
+
# L'Agent Double joker: partner sabotages two random tricks per round.
|
|
332
|
+
# round_driver picks the tricks + reuses the agent_double AI path.
|
|
333
|
+
round_flags["agent_double_joker_active"] = True
|
|
330
334
|
if self.run.surcoinche_unlocked:
|
|
331
335
|
round_flags["surcoinche_unlocked"] = True
|
|
332
336
|
|
|
@@ -359,6 +363,11 @@ class BelAtroGame:
|
|
|
359
363
|
if isinstance(pending, int) and pending > 0:
|
|
360
364
|
self.run.tierce_charges += pending
|
|
361
365
|
|
|
366
|
+
# Phase 2.1: persist Tout Atout streak between rounds.
|
|
367
|
+
streak = final_state._joker_state.get("tout_streak_streak", 0)
|
|
368
|
+
if isinstance(streak, int):
|
|
369
|
+
self.run.card_enhancements["tout_streak_streak"] = streak
|
|
370
|
+
|
|
362
371
|
# Phase 2.3: refresh partner_mood for HUD display.
|
|
363
372
|
self.run.partner_mood = trust.mood()
|
|
364
373
|
|
|
@@ -172,6 +172,13 @@ class Shop:
|
|
|
172
172
|
item.on_purchase(self.run)
|
|
173
173
|
elif isinstance(item, Voucher):
|
|
174
174
|
self.run.vouchers.append(item)
|
|
175
|
-
|
|
175
|
+
# Idempotency guard: several vouchers use `+=` in apply(); if a
|
|
176
|
+
# save/load round-trip ever re-invokes apply() on a voucher
|
|
177
|
+
# already in `vouchers`, bonuses would silently double. Track
|
|
178
|
+
# applied ids so re-apply is a no-op. The normal purchase path
|
|
179
|
+
# hits this exactly once.
|
|
180
|
+
if item.id not in self.run._applied_voucher_ids:
|
|
181
|
+
self.run._applied_voucher_ids.add(item.id)
|
|
182
|
+
item.apply(self.run)
|
|
176
183
|
elif len(self.run.consumables) < self.run.consumable_slots:
|
|
177
184
|
self.run.consumables.append(item)
|
|
@@ -40,16 +40,21 @@ class BelAtroAnnounce:
|
|
|
40
40
|
from belote.ui.render import get_term_size
|
|
41
41
|
|
|
42
42
|
term_w, term_h = get_term_size()
|
|
43
|
+
mid = max(4, term_h // 2)
|
|
43
44
|
|
|
44
45
|
print(clear_screen(), end="")
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
print(
|
|
47
|
+
move(mid - 3, 1)
|
|
48
|
+
+ ansi_center(red_fg() + BOLD + "! BOSS BLIND REVEALED !" + RESET, term_w)
|
|
49
|
+
)
|
|
48
50
|
interruptible_sleep(1.0, reader)
|
|
49
|
-
print(move(
|
|
51
|
+
print(move(mid, 1) + ansi_center(gold_fg() + BOLD + boss.name.upper() + RESET, term_w))
|
|
50
52
|
interruptible_sleep(1.0, reader)
|
|
51
|
-
print(move(
|
|
52
|
-
print(
|
|
53
|
+
print(move(mid + 2, 1) + ansi_center(white_fg() + boss.description + RESET, term_w))
|
|
54
|
+
print(
|
|
55
|
+
move(max(mid + 5, term_h - 2), 1)
|
|
56
|
+
+ ansi_center(BOLD + "[ Press any key to continue ]" + RESET, term_w)
|
|
57
|
+
)
|
|
53
58
|
interruptible_sleep(2.0, reader)
|
|
54
59
|
|
|
55
60
|
@staticmethod
|
|
@@ -108,7 +113,7 @@ class BelAtroAnnounce:
|
|
|
108
113
|
if not lines:
|
|
109
114
|
return
|
|
110
115
|
toggle_overlay()
|
|
111
|
-
start_row =
|
|
116
|
+
start_row = max(1, term_h - len(lines) - 4)
|
|
112
117
|
for i, line in enumerate(lines):
|
|
113
118
|
print(move(start_row + i, 1) + ansi_center(gold_fg() + line + RESET, term_w))
|
|
114
119
|
end = time.time() + 1.5
|
|
@@ -41,7 +41,10 @@ def show_collection(reader: KeyReader, profile: Profile) -> None:
|
|
|
41
41
|
cat_idx = 0
|
|
42
42
|
item_idx = 0
|
|
43
43
|
|
|
44
|
+
from belote.ui.fit_guard import require_minimum
|
|
45
|
+
|
|
44
46
|
while True:
|
|
47
|
+
require_minimum(reader)
|
|
45
48
|
term_w, term_h = get_term_size()
|
|
46
49
|
cat_name, items = categories[cat_idx]
|
|
47
50
|
item_idx = min(item_idx, len(items) - 1) if items else 0
|
|
@@ -82,10 +85,17 @@ def show_collection(reader: KeyReader, profile: Profile) -> None:
|
|
|
82
85
|
|
|
83
86
|
# Show details for discovered item on the right
|
|
84
87
|
if real_idx == item_idx:
|
|
85
|
-
info_col = 40
|
|
88
|
+
info_col = max(40, term_w // 2)
|
|
89
|
+
wrap_w = max(20, term_w - info_col - 2)
|
|
90
|
+
divider_w = min(30, wrap_w)
|
|
86
91
|
if is_discovered:
|
|
87
92
|
out.append(move(start_row, info_col) + gold_fg() + BOLD + item_cls.name + RESET)
|
|
88
|
-
out.append(
|
|
93
|
+
out.append(
|
|
94
|
+
move(start_row + 1, info_col)
|
|
95
|
+
+ menu_border_fg()
|
|
96
|
+
+ "─" * divider_w
|
|
97
|
+
+ RESET
|
|
98
|
+
)
|
|
89
99
|
|
|
90
100
|
# Try to show ASCII art if available
|
|
91
101
|
art = getattr(item_cls, "ascii_art", [])
|
|
@@ -100,7 +110,7 @@ def show_collection(reader: KeyReader, profile: Profile) -> None:
|
|
|
100
110
|
line = ""
|
|
101
111
|
r = desc_start
|
|
102
112
|
for w in words:
|
|
103
|
-
if len(line) + len(w) + 1 >
|
|
113
|
+
if len(line) + len(w) + 1 > wrap_w:
|
|
104
114
|
out.append(move(r, info_col) + white_fg() + line + RESET)
|
|
105
115
|
line = w
|
|
106
116
|
r += 1
|