belote-cli 3.8.0__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.8.0 → belote_cli-3.8.2}/CHANGELOG.md +39 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/DEVELOPMENT.md +9 -5
- {belote_cli-3.8.0 → belote_cli-3.8.2}/PKG-INFO +4 -4
- {belote_cli-3.8.0 → belote_cli-3.8.2}/README.md +3 -3
- {belote_cli-3.8.0 → belote_cli-3.8.2}/pyproject.toml +1 -1
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/__init__.py +1 -1
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/core/run_state.py +2 -1
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/core/scoring.py +3 -3
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/engine/round_driver.py +27 -11
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/annonces.py +5 -1
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/contract.py +7 -2
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/corrupted.py +4 -10
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/economy.py +7 -2
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/main.py +9 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/game.py +7 -3
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/scoring.py +10 -2
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ui/layout.py +1 -1
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ui/render.py +1 -1
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_belatro.py +1 -1
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_boss_modifiers_integration.py +77 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_phase2_content.py +52 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_round_driver.py +70 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_belote.py +11 -7
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_gameflow.py +1 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/.claude/settings.local.json +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/.gitignore +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/.python-version +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/LICENSE +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/scripts/benchmark.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/__init__.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/a11y.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/achievements.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ai.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ansi.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/__init__.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/core/__init__.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/core/economy.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/engine/__init__.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/engine/event_bus.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/engine/modifier_patch.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ghost_run.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/__init__.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/base.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/__init__.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/coinche.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/planets.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/registry.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/tarots.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/vouchers.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/partner/__init__.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/partner/partner_state.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/partner/personality.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/partner/trust.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/progression/__init__.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/progression/save.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/progression/unlocks.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/run/__init__.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/run/ante.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/run/ante_themes.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/run/boss.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/run/decks.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/run/shop.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/run_summary.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/__init__.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/announce.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/collection.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/consumables.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/history.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/hud.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/menu.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/rules.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/shop.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/trust_bar.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/config.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/context.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/deck.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/gameflow.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/input.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/main.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/replay.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/rules.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/stats.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/themes.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ui/__init__.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ui/announce.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ui/fit_guard.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ui/menu.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ui/prompts.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/__init__.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/__init__.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_collection_logic.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_consumables_ui.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_contract_unlocks.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_dead_flag_fixes.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_deck_variants.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_event_bus.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_ghost_run.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_history_overlay.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_hud_synergy.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_partner_jokers.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_partner_trust.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_phase0_coverage.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_phase1_plumbing.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_phase3_meta.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_progression.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_run_summary.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_shop_empty_pools.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_voucher_idempotency.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_a11y.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_achievements.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_ai.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_ansi_helpers.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_bidding_all_pass.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_declaration_tiebreak.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_extended.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_game_logic.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_input_eof.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_layout.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_new_coverage.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_official_rules.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_properties.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_replay.py +0 -0
- {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_undo.py +0 -0
|
@@ -5,6 +5,45 @@ 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
|
+
|
|
8
47
|
## [3.8.0] - 2026-05-13
|
|
9
48
|
|
|
10
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`.
|
|
@@ -84,14 +84,18 @@ 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.8.
|
|
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
|
+
|
|
95
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`.
|
|
96
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`.
|
|
97
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`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: belote-cli
|
|
3
|
-
Version: 3.8.
|
|
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
|
|
@@ -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 (655 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 **655 tests** passing with 100% coverage on game-logic modules (3.8.2).
|
|
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**: 655/655 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 (655 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 **655 tests** passing with 100% coverage on game-logic modules (3.8.2).
|
|
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**: 655/655 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
|
|
|
@@ -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 ──────────────────────────────
|
|
@@ -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
|
|
|
@@ -915,9 +915,13 @@ def _resolve_trick_winner(
|
|
|
915
915
|
winner = trick_winner_seat(new_trick, ctx.trump, ctx.se_trump, ctx.is_sa)
|
|
916
916
|
|
|
917
917
|
if state.boss_modifiers.no_consecutive_team_wins and state.completed_tricks:
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
918
|
+
# Use the cached resolved winner (state.last_trick_winner), not the
|
|
919
|
+
# raw trick_winner_seat result from completed_tricks[-1]. play_card
|
|
920
|
+
# stores the *resolved* winner, so reading from state keeps the
|
|
921
|
+
# Rupture chain consistent with compute_trick_winners (used in final
|
|
922
|
+
# scoring). Pre-3.8.1 the two paths drifted on trick 3+ whenever
|
|
923
|
+
# Rupture flipped trick N-1.
|
|
924
|
+
last_winner = state.last_trick_winner
|
|
921
925
|
if last_winner and winner and team_of(winner) == team_of(last_winner):
|
|
922
926
|
other_team_cards = [
|
|
923
927
|
tc for tc in new_trick if team_of(tc.seat) != team_of(last_winner)
|
|
@@ -59,7 +59,9 @@ def get_declaration_points(decls: list[Sequence | Carre]) -> int:
|
|
|
59
59
|
pts = 0
|
|
60
60
|
for d in decls:
|
|
61
61
|
if isinstance(d, Sequence):
|
|
62
|
-
|
|
62
|
+
# 5+ cards is always a Quinte (100 pts)
|
|
63
|
+
length = min(d.length, 5)
|
|
64
|
+
pts += _SEQUENCE_POINTS.get(length, 0)
|
|
63
65
|
elif isinstance(d, Carre):
|
|
64
66
|
pts += _CARRE_POINTS.get(_VALUE_TO_RANK[d.rank], 0)
|
|
65
67
|
return pts
|
|
@@ -226,7 +228,7 @@ def _carre_points(carre: Carre) -> int:
|
|
|
226
228
|
|
|
227
229
|
|
|
228
230
|
def _sequence_points(seq: Sequence) -> int:
|
|
229
|
-
return _SEQUENCE_POINTS.get(seq.length, 0)
|
|
231
|
+
return _SEQUENCE_POINTS.get(min(seq.length, 5), 0)
|
|
230
232
|
|
|
231
233
|
|
|
232
234
|
def resolve_declarations(
|
|
@@ -733,6 +735,12 @@ def _score_capot_outcome(
|
|
|
733
735
|
else:
|
|
734
736
|
capot_base = GLOBAL_CONFIG.CAPOT_BASE
|
|
735
737
|
|
|
738
|
+
# Le Zéro Final: if last-trick bonus is suppressed, the Capot reward (which
|
|
739
|
+
# includes the +10) must drop by 10 too. Matches the chute-pool logic
|
|
740
|
+
# in _score_normal_outcome.
|
|
741
|
+
if state.boss_modifiers.no_dix_de_der:
|
|
742
|
+
capot_base -= GLOBAL_CONFIG.LAST_TRICK_BONUS
|
|
743
|
+
|
|
736
744
|
is_failed = False
|
|
737
745
|
if capot_winner_team == ctx.taker_team:
|
|
738
746
|
taker_total = (
|
|
@@ -36,7 +36,7 @@ class LayoutPreset:
|
|
|
36
36
|
hud_style: str
|
|
37
37
|
|
|
38
38
|
# Whether the W/E "Last Trick" sidebar shows in side columns at this size.
|
|
39
|
-
# At compact widths we hide it — the user can press
|
|
39
|
+
# At compact widths we hide it — the user can press H for full history.
|
|
40
40
|
show_last_trick_sidebar: bool
|
|
41
41
|
|
|
42
42
|
|
|
@@ -634,7 +634,7 @@ def _render_middle_section(
|
|
|
634
634
|
right_rows[mid - 2] = e_cards
|
|
635
635
|
right_rows[mid - 1] = e_count
|
|
636
636
|
|
|
637
|
-
# Last Trick Panel — hidden at compact widths (
|
|
637
|
+
# Last Trick Panel — hidden at compact widths (press H for full history).
|
|
638
638
|
if state.completed_tricks and layout.show_last_trick_sidebar:
|
|
639
639
|
last = state.completed_tricks[-1]
|
|
640
640
|
right_rows[mid + 1] = f"{UNDERLINE}Last Trick:{RESET}"
|
|
@@ -1267,7 +1267,7 @@ class TestLeNotaire:
|
|
|
1267
1267
|
self.state: dict[str, Any] = {}
|
|
1268
1268
|
|
|
1269
1269
|
def test_south_belote_gives_money_removes_chips(self) -> None:
|
|
1270
|
-
evt = make_belote_event(seat=Seat.SOUTH, is_rebelote=
|
|
1270
|
+
evt = make_belote_event(seat=Seat.SOUTH, is_rebelote=True)
|
|
1271
1271
|
result = self.joker.on_belote(evt, self.state)
|
|
1272
1272
|
assert result is not None
|
|
1273
1273
|
assert result.add_money == 5
|
|
@@ -304,3 +304,80 @@ def test_patched_state_rejects_only_underscore_boss_attrs() -> None:
|
|
|
304
304
|
assert proxy._chips == 100
|
|
305
305
|
proxy._mult = 2.5 # via __setattr__
|
|
306
306
|
assert proxy._mult == 2.5
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ── 3.8.1: La Rupture — play_card and score_round must agree on tricks 3+ ──
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_rupture_play_card_resolves_consistently_with_scoring() -> None:
|
|
313
|
+
"""3.8.1 fix: play_card's _resolve_trick_winner used the RAW previous winner
|
|
314
|
+
from completed_tricks[-1] via trick_winner_seat. score_round's
|
|
315
|
+
compute_trick_winners threads the RESOLVED previous winner. On trick 3+,
|
|
316
|
+
when Rupture flipped trick N-1, the two paths disagreed and state.
|
|
317
|
+
last_trick_winner could be inconsistent with the final scoring tally.
|
|
318
|
+
|
|
319
|
+
Lock the fix: state.last_trick_winner (stored from _resolve_trick_winner)
|
|
320
|
+
must equal compute_trick_winners(...)[-1] after each play_card call.
|
|
321
|
+
"""
|
|
322
|
+
from belote.game import compute_trick_winners, play_card, team_of
|
|
323
|
+
|
|
324
|
+
# Trick 1 (raw winner NS) and trick 2 (raw winner NS).
|
|
325
|
+
# Rupture flips trick 2 to EW, so the resolved chain after 2 tricks is NS → EW.
|
|
326
|
+
def ns_sweep(lead_rank: Rank) -> tuple[TrickCard, ...]:
|
|
327
|
+
return (
|
|
328
|
+
TrickCard(Seat.SOUTH, Card(Suit.SPADES, lead_rank)),
|
|
329
|
+
TrickCard(Seat.WEST, Card(Suit.SPADES, Rank.SEVEN)),
|
|
330
|
+
TrickCard(Seat.NORTH, Card(Suit.SPADES, Rank.EIGHT)),
|
|
331
|
+
TrickCard(Seat.EAST, Card(Suit.SPADES, Rank.NINE)),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
completed = (ns_sweep(Rank.ACE), ns_sweep(Rank.TEN))
|
|
335
|
+
|
|
336
|
+
# Build a state with hands forcing a third NS-sweep trick.
|
|
337
|
+
# SOUTH leads ♠K, WEST/NORTH/EAST follow with low ♠.
|
|
338
|
+
south_hand = (Card(Suit.SPADES, Rank.KING),)
|
|
339
|
+
west_hand = (Card(Suit.SPADES, Rank.JACK),)
|
|
340
|
+
north_hand = (Card(Suit.SPADES, Rank.QUEEN),)
|
|
341
|
+
east_hand = (Card(Suit.SPADES, Rank.NINE),)
|
|
342
|
+
|
|
343
|
+
pre_winners = compute_trick_winners(
|
|
344
|
+
GameState(
|
|
345
|
+
hands=((), (), (), ()),
|
|
346
|
+
boss_modifiers=BossModifiers(no_consecutive_team_wins=True),
|
|
347
|
+
completed_tricks=completed,
|
|
348
|
+
),
|
|
349
|
+
Suit.HEARTS,
|
|
350
|
+
False,
|
|
351
|
+
)
|
|
352
|
+
# Sanity: trick 1 = NS (no prev), trick 2 = flipped to EW (prev was NS).
|
|
353
|
+
assert team_of(pre_winners[0]) == 0
|
|
354
|
+
assert team_of(pre_winners[1]) == 1
|
|
355
|
+
|
|
356
|
+
state = GameState(
|
|
357
|
+
hands=(south_hand, east_hand, north_hand, west_hand),
|
|
358
|
+
trump=Suit.HEARTS,
|
|
359
|
+
taker=Seat.SOUTH,
|
|
360
|
+
phase=Phase.PLAYING,
|
|
361
|
+
leader=Seat.SOUTH,
|
|
362
|
+
turn=Seat.SOUTH,
|
|
363
|
+
boss_modifiers=BossModifiers(no_consecutive_team_wins=True),
|
|
364
|
+
completed_tricks=completed,
|
|
365
|
+
last_trick_winner=pre_winners[-1], # EW, the resolved winner
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Simulate trick 3: SOUTH leads ♠K, the rest follow with lower ♠.
|
|
369
|
+
# Order is S → E → N → W.
|
|
370
|
+
state = play_card(state, Card(Suit.SPADES, Rank.KING)) # SOUTH
|
|
371
|
+
state = play_card(state, Card(Suit.SPADES, Rank.NINE)) # EAST
|
|
372
|
+
state = play_card(state, Card(Suit.SPADES, Rank.QUEEN)) # NORTH
|
|
373
|
+
state = play_card(state, Card(Suit.SPADES, Rank.JACK)) # WEST
|
|
374
|
+
|
|
375
|
+
winners = compute_trick_winners(state, state.trump, False)
|
|
376
|
+
assert len(winners) == 3
|
|
377
|
+
# Post-fix: play_card consults state.last_trick_winner (=EW). Trick 3 raw=NS,
|
|
378
|
+
# opposite team of prev=EW → no flip → NS wins. Must equal scoring path.
|
|
379
|
+
assert state.last_trick_winner == winners[-1], (
|
|
380
|
+
f"Rupture drift: state.last_trick_winner={state.last_trick_winner!r} "
|
|
381
|
+
f"but compute_trick_winners gives {winners[-1]!r}"
|
|
382
|
+
)
|
|
383
|
+
assert team_of(winners[-1]) == 0 # NS keeps trick 3 — alternation NS/EW/NS
|
|
@@ -384,3 +384,55 @@ def test_phase2_vouchers_and_tarots_are_registered() -> None:
|
|
|
384
384
|
assert "tierce_forge" in registry.vouchers
|
|
385
385
|
assert "la_maison_dieu" in registry.tarots
|
|
386
386
|
assert "le_diable" in registry.tarots
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# ── 3.8.1: belote-pair joker double-fire fix ───────────────────────────────
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_le_rebelle_fires_once_per_belote_pair() -> None:
|
|
393
|
+
"""3.8.1 fix: BeloteAnnouncedEvent fires twice per round (belote, then
|
|
394
|
+
rebelote). LeRebelle's times_mult=3.0 must apply once, not ×9 net."""
|
|
395
|
+
from belote.belatro.items.jokers.contract import LeRebelle
|
|
396
|
+
|
|
397
|
+
acc = ScoreAccumulator(target_score=100)
|
|
398
|
+
acc.attach_jokers([LeRebelle()])
|
|
399
|
+
state = GameState(hands=((), (), (), ()), _chips=100, _mult=1.0)
|
|
400
|
+
|
|
401
|
+
# First event: belote (is_rebelote=False) — fires.
|
|
402
|
+
e1 = BeloteAnnouncedEvent(seat=Seat.SOUTH, is_rebelote=False)
|
|
403
|
+
state = acc.update_state(state, e1)
|
|
404
|
+
# Second event: rebelote (is_rebelote=True) — gated, must not fire.
|
|
405
|
+
e2 = BeloteAnnouncedEvent(seat=Seat.SOUTH, is_rebelote=True)
|
|
406
|
+
state = acc.update_state(state, e2)
|
|
407
|
+
|
|
408
|
+
# ×3 Mult applied exactly once.
|
|
409
|
+
assert state._mult == 3.0
|
|
410
|
+
# Chip subtraction applied exactly once (-20).
|
|
411
|
+
assert state._chips == 80
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def test_le_notaire_pays_once_per_belote_pair() -> None:
|
|
415
|
+
"""3.8.1 fix: LeNotaire's $5 cash must apply once, not $10 net."""
|
|
416
|
+
from belote.belatro.items.jokers.economy import LeNotaire
|
|
417
|
+
|
|
418
|
+
acc = ScoreAccumulator(target_score=100)
|
|
419
|
+
acc.attach_jokers([LeNotaire()])
|
|
420
|
+
state = GameState(hands=((), (), (), ()), _chips=100, _mult=1.0)
|
|
421
|
+
|
|
422
|
+
state = acc.update_state(state, BeloteAnnouncedEvent(seat=Seat.SOUTH, is_rebelote=False))
|
|
423
|
+
state = acc.update_state(state, BeloteAnnouncedEvent(seat=Seat.SOUTH, is_rebelote=True))
|
|
424
|
+
|
|
425
|
+
assert state._bonus_money == 5
|
|
426
|
+
assert state._chips == 80
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def test_lagent_double_purchase_flags_run() -> None:
|
|
430
|
+
"""3.8.1 fix: LAgentDouble.on_purchase must flag the run so round_driver
|
|
431
|
+
populates the sabotage tricks. Pre-3.8.1 the joker only awarded +4 Mult
|
|
432
|
+
and never triggered the partner-sabotage half of its description."""
|
|
433
|
+
from belote.belatro.items.jokers.corrupted import LAgentDouble
|
|
434
|
+
|
|
435
|
+
run = BelAtroRun()
|
|
436
|
+
assert run.agent_double_joker is False
|
|
437
|
+
LAgentDouble().on_purchase(run)
|
|
438
|
+
assert run.agent_double_joker is True
|
|
@@ -530,3 +530,73 @@ def test_d3_default_callback_returns_false() -> None:
|
|
|
530
530
|
state = GameState(hands=((), (), (), ()))
|
|
531
531
|
assert cb.prompt_surcoinche(state, Seat.EAST) is False
|
|
532
532
|
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ── 3.8.1: boss flags must be applied before acc.trigger_round_start ──
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def test_boss_flags_applied_before_trigger_round_start() -> None:
|
|
539
|
+
"""3.8.1 fix: round_driver previously called acc.trigger_round_start
|
|
540
|
+
BEFORE boss.apply, so any joker_state field derived from
|
|
541
|
+
state.boss_modifiers.X (e.g. joker_state["no_dix_de_der"]) captured the
|
|
542
|
+
default value instead of the boss-patched one. The order must be
|
|
543
|
+
boss.apply → trigger_round_start.
|
|
544
|
+
|
|
545
|
+
Lock the fix: joker_state["no_dix_de_der"] must reflect the active boss
|
|
546
|
+
flag after drive_round completes the round.
|
|
547
|
+
"""
|
|
548
|
+
from belote.belatro.core.scoring import ScoreAccumulator
|
|
549
|
+
from belote.belatro.engine.event_bus import EventBus
|
|
550
|
+
from belote.belatro.engine.round_driver import RoundUICallbacks, drive_round
|
|
551
|
+
from belote.belatro.partner.partner_state import PartnerState
|
|
552
|
+
from belote.belatro.run.boss import BossModifier
|
|
553
|
+
|
|
554
|
+
class LeZeroFinal(BossModifier):
|
|
555
|
+
id = "le_zero_final_test"
|
|
556
|
+
name = "Le Zéro Final (test)"
|
|
557
|
+
description = "Disables Dix de Der"
|
|
558
|
+
|
|
559
|
+
def apply(self, state: object) -> None: # PatchedGameState
|
|
560
|
+
state.patch("no_dix_de_der", True) # type: ignore[attr-defined]
|
|
561
|
+
|
|
562
|
+
class _NoopUI(RoundUICallbacks):
|
|
563
|
+
def prompt_bid(self, state): # type: ignore[no-untyped-def, override]
|
|
564
|
+
return None # Pass — round will exhaust bids and short-circuit.
|
|
565
|
+
def prompt_card(self, state): # type: ignore[no-untyped-def, override]
|
|
566
|
+
return state.hands[state.turn.value][0], state
|
|
567
|
+
def on_card_played(self, state, seat, card) -> None: # type: ignore[no-untyped-def, override]
|
|
568
|
+
pass
|
|
569
|
+
def on_trick_end(self, state, winner, points) -> None: # type: ignore[no-untyped-def, override]
|
|
570
|
+
pass
|
|
571
|
+
def on_round_end(self, breakdown) -> None: # type: ignore[no-untyped-def, override]
|
|
572
|
+
pass
|
|
573
|
+
|
|
574
|
+
bus = EventBus()
|
|
575
|
+
partner = PartnerState()
|
|
576
|
+
captured: dict[str, object] = {}
|
|
577
|
+
|
|
578
|
+
class _SpyAcc(ScoreAccumulator):
|
|
579
|
+
def trigger_round_start(self, state): # type: ignore[no-untyped-def, override]
|
|
580
|
+
captured["no_dix_de_der"] = state.boss_modifiers.no_dix_de_der
|
|
581
|
+
return super().trigger_round_start(state)
|
|
582
|
+
|
|
583
|
+
acc = _SpyAcc(target_score=80)
|
|
584
|
+
|
|
585
|
+
import contextlib
|
|
586
|
+
# Drive may error out without a fleshed-out UI; we only care that
|
|
587
|
+
# trigger_round_start was called once and observed the patched boss flag.
|
|
588
|
+
with contextlib.suppress(Exception):
|
|
589
|
+
drive_round(
|
|
590
|
+
bus=bus,
|
|
591
|
+
partner=partner,
|
|
592
|
+
ui_callbacks=_NoopUI(),
|
|
593
|
+
acc=acc,
|
|
594
|
+
boss=LeZeroFinal(),
|
|
595
|
+
target_score=80,
|
|
596
|
+
seed=42,
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
assert captured.get("no_dix_de_der") is True, (
|
|
600
|
+
"Boss flag was not applied before trigger_round_start — the joker "
|
|
601
|
+
"state snapshot would see stale BossModifiers defaults."
|
|
602
|
+
)
|
|
@@ -620,7 +620,8 @@ class TestCapot:
|
|
|
620
620
|
breakdown = score_round(state)
|
|
621
621
|
assert breakdown.is_capot is True
|
|
622
622
|
# South has K♠+Q♠ (trump honors) so belote is detected → CAPOT_BASE + BELOTE_POINTS
|
|
623
|
-
|
|
623
|
+
# South and North each hold sequences (detected from tricks) → +200 decls
|
|
624
|
+
assert breakdown.taker_total == CAPOT_BASE + BELOTE_POINTS + 200
|
|
624
625
|
assert breakdown.taker_belote == BELOTE_POINTS
|
|
625
626
|
|
|
626
627
|
# ---------------------------------------------------------------------------
|
|
@@ -694,16 +695,18 @@ class TestCapotPerContract:
|
|
|
694
695
|
state = _make_capot_state(contract="sans_atout", trump=None)
|
|
695
696
|
breakdown = score_round(state)
|
|
696
697
|
assert breakdown.is_capot is True
|
|
697
|
-
|
|
698
|
-
|
|
698
|
+
# Base 220 + 200 from NS sequences (South's 8 hearts + North's 8 diamonds)
|
|
699
|
+
assert breakdown.taker_total == GLOBAL_CONFIG.CAPOT_BASE_SANS_ATOUT + 200, (
|
|
700
|
+
f"SA Capot must use base 220 (+200 decls), got {breakdown.taker_total}"
|
|
699
701
|
)
|
|
700
702
|
|
|
701
703
|
def test_capot_base_tout_atout(self) -> None:
|
|
702
704
|
state = _make_capot_state(contract="tout_atout", trump=Suit.TOUT_ATOUT)
|
|
703
705
|
breakdown = score_round(state)
|
|
704
706
|
assert breakdown.is_capot is True
|
|
705
|
-
|
|
706
|
-
|
|
707
|
+
# Base 348 + 200 from NS sequences
|
|
708
|
+
assert breakdown.taker_total == GLOBAL_CONFIG.CAPOT_BASE_TOUT_ATOUT + 200, (
|
|
709
|
+
f"TA Capot must use base 348 (+200 decls), got {breakdown.taker_total}"
|
|
707
710
|
)
|
|
708
711
|
|
|
709
712
|
def test_capot_base_normal_unchanged(self) -> None:
|
|
@@ -711,8 +714,9 @@ class TestCapotPerContract:
|
|
|
711
714
|
breakdown = score_round(state)
|
|
712
715
|
assert breakdown.is_capot is True
|
|
713
716
|
# Hearts trump means South's K+Q hearts trigger Belote (BELOTE_POINTS=20).
|
|
714
|
-
#
|
|
715
|
-
assert breakdown.taker_total == GLOBAL_CONFIG.CAPOT_BASE + breakdown.taker_belote
|
|
717
|
+
# Plus 200 from NS sequences.
|
|
718
|
+
assert breakdown.taker_total == GLOBAL_CONFIG.CAPOT_BASE + breakdown.taker_belote + 200
|
|
719
|
+
|
|
716
720
|
|
|
717
721
|
|
|
718
722
|
# ---------------------------------------------------------------------------
|
|
@@ -52,6 +52,7 @@ def test_run_play_8_tricks() -> None:
|
|
|
52
52
|
unittest.mock.patch("belote.gameflow.display"),
|
|
53
53
|
unittest.mock.patch("belote.gameflow.patch_trick_card"),
|
|
54
54
|
unittest.mock.patch("belote.gameflow.announce"),
|
|
55
|
+
unittest.mock.patch("belote.gameflow.interruptible_sleep", return_value=False),
|
|
55
56
|
unittest.mock.patch("belote.gameflow.prompt_card") as mock_prompt,
|
|
56
57
|
):
|
|
57
58
|
# Build a state at start of play
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|