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.
Files changed (130) hide show
  1. {belote_cli-3.7.1 → belote_cli-3.8.2}/CHANGELOG.md +80 -0
  2. {belote_cli-3.7.1 → belote_cli-3.8.2}/DEVELOPMENT.md +10 -5
  3. {belote_cli-3.7.1 → belote_cli-3.8.2}/PKG-INFO +6 -4
  4. {belote_cli-3.7.1 → belote_cli-3.8.2}/README.md +5 -3
  5. {belote_cli-3.7.1 → belote_cli-3.8.2}/pyproject.toml +1 -1
  6. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/__init__.py +1 -1
  7. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/ai.py +32 -12
  8. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/core/run_state.py +11 -1
  9. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/core/scoring.py +3 -3
  10. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/engine/round_driver.py +27 -11
  11. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/annonces.py +5 -1
  12. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/contract.py +7 -2
  13. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/corrupted.py +4 -10
  14. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/economy.py +7 -2
  15. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/main.py +9 -0
  16. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/run/shop.py +8 -1
  17. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/announce.py +12 -7
  18. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/collection.py +13 -3
  19. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/consumables.py +22 -13
  20. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/history.py +3 -0
  21. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/menu.py +39 -32
  22. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/rules.py +3 -0
  23. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/shop.py +92 -38
  24. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/game.py +7 -3
  25. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/main.py +10 -12
  26. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/scoring.py +17 -9
  27. belote_cli-3.8.2/src/belote/ui/fit_guard.py +83 -0
  28. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/ui/layout.py +16 -1
  29. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/ui/menu.py +6 -3
  30. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/ui/render.py +16 -9
  31. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_belatro.py +1 -1
  32. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_boss_modifiers_integration.py +77 -0
  33. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_phase2_content.py +52 -0
  34. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_round_driver.py +70 -0
  35. belote_cli-3.8.2/tests/belatro/test_shop_empty_pools.py +89 -0
  36. belote_cli-3.8.2/tests/belatro/test_voucher_idempotency.py +130 -0
  37. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_belote.py +11 -7
  38. belote_cli-3.8.2/tests/test_bidding_all_pass.py +114 -0
  39. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_gameflow.py +1 -0
  40. {belote_cli-3.7.1 → belote_cli-3.8.2}/.claude/settings.local.json +0 -0
  41. {belote_cli-3.7.1 → belote_cli-3.8.2}/.gitignore +0 -0
  42. {belote_cli-3.7.1 → belote_cli-3.8.2}/.python-version +0 -0
  43. {belote_cli-3.7.1 → belote_cli-3.8.2}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
  44. {belote_cli-3.7.1 → belote_cli-3.8.2}/LICENSE +0 -0
  45. {belote_cli-3.7.1 → belote_cli-3.8.2}/scripts/benchmark.py +0 -0
  46. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/__init__.py +0 -0
  47. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/a11y.py +0 -0
  48. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/achievements.py +0 -0
  49. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/ansi.py +0 -0
  50. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/__init__.py +0 -0
  51. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/core/__init__.py +0 -0
  52. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/core/economy.py +0 -0
  53. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/engine/__init__.py +0 -0
  54. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/engine/event_bus.py +0 -0
  55. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/engine/modifier_patch.py +0 -0
  56. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ghost_run.py +0 -0
  57. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/__init__.py +0 -0
  58. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/base.py +0 -0
  59. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/__init__.py +0 -0
  60. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/coinche.py +0 -0
  61. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  62. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
  63. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  64. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  65. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  66. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  67. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/planets.py +0 -0
  68. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/registry.py +0 -0
  69. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/tarots.py +0 -0
  70. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/items/vouchers.py +0 -0
  71. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/partner/__init__.py +0 -0
  72. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/partner/partner_state.py +0 -0
  73. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/partner/personality.py +0 -0
  74. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/partner/trust.py +0 -0
  75. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/progression/__init__.py +0 -0
  76. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/progression/save.py +0 -0
  77. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/progression/unlocks.py +0 -0
  78. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/run/__init__.py +0 -0
  79. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/run/ante.py +0 -0
  80. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/run/ante_themes.py +0 -0
  81. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/run/boss.py +0 -0
  82. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/run/decks.py +0 -0
  83. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/run_summary.py +0 -0
  84. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/__init__.py +0 -0
  85. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/hud.py +0 -0
  86. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/belatro/ui/trust_bar.py +0 -0
  87. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/config.py +0 -0
  88. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/context.py +0 -0
  89. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/deck.py +0 -0
  90. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/gameflow.py +0 -0
  91. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/input.py +0 -0
  92. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/replay.py +0 -0
  93. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/rules.py +0 -0
  94. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/stats.py +0 -0
  95. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/themes.py +0 -0
  96. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/ui/__init__.py +0 -0
  97. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/ui/announce.py +0 -0
  98. {belote_cli-3.7.1 → belote_cli-3.8.2}/src/belote/ui/prompts.py +0 -0
  99. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/__init__.py +0 -0
  100. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/__init__.py +0 -0
  101. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_collection_logic.py +0 -0
  102. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_consumables_ui.py +0 -0
  103. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_contract_unlocks.py +0 -0
  104. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_dead_flag_fixes.py +0 -0
  105. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_deck_variants.py +0 -0
  106. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_event_bus.py +0 -0
  107. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_ghost_run.py +0 -0
  108. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_history_overlay.py +0 -0
  109. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_hud_synergy.py +0 -0
  110. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_partner_jokers.py +0 -0
  111. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_partner_trust.py +0 -0
  112. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_phase0_coverage.py +0 -0
  113. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_phase1_plumbing.py +0 -0
  114. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_phase3_meta.py +0 -0
  115. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_progression.py +0 -0
  116. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/belatro/test_run_summary.py +0 -0
  117. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_a11y.py +0 -0
  118. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_achievements.py +0 -0
  119. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_ai.py +0 -0
  120. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_ansi_helpers.py +0 -0
  121. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_declaration_tiebreak.py +0 -0
  122. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_extended.py +0 -0
  123. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_game_logic.py +0 -0
  124. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_input_eof.py +0 -0
  125. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_layout.py +0 -0
  126. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_new_coverage.py +0 -0
  127. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_official_rules.py +0 -0
  128. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_properties.py +0 -0
  129. {belote_cli-3.7.1 → belote_cli-3.8.2}/tests/test_replay.py +0 -0
  130. {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 (635 tests expected)
87
+ # Full test suite (655 tests expected)
88
88
  PYTHONPATH=src pytest
89
89
  ```
90
90
 
91
- Current baseline (3.7.1):
92
- - **mypy**: 0 errors (strict mode, 77 files)
93
- - **ruff**: 0 violations
94
- - **pytest**: 635 tests, 0 failures
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.7.1
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 (635 tests)
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 **635 tests** passing with 100% coverage on game-logic modules.
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**: 635/635 passed
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 (635 tests)
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 **635 tests** passing with 100% coverage on game-logic modules.
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**: 635/635 passed
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "belote-cli"
7
- version = "3.7.1"
7
+ version = "3.8.2"
8
8
  description = "A 4-player terminal card game"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
- __version__ = "3.7.1"
1
+ __version__ = "3.8.2"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -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
- # In this implementation, AI tracks what it can see
103
- p = partner(self.seat)
104
- self.memory.partner_hand.clear()
105
- if (
106
- state.phase in (Phase.PLAYING, Phase.SCORING)
107
- and not state.boss_modifiers.hide_partner_hand
108
- ):
109
- for card in state.hand_of(p):
110
- self.memory.partner_hand.add(card)
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 = [c for c in hand if c.suit == suit]
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 = sum(1 for c in hand if c.suit == other)
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, Contract, Suit
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
- if acc is not None:
136
- state = acc.trigger_round_start(state)
137
-
138
- # B1: Apply boss modifier flags onto the frozen GameState so play_card sees them
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 = trick_winner_seat(
381
- last_trick,
382
- state.trump,
383
- state.boss_modifiers.seven_eight_trump,
384
- state.contract == Contract.SANS_ATOUT,
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 event.seat in (Seat.SOUTH, Seat.NORTH) and event.points >= 100:
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(self, event: BeloteAnnouncedEvent, state: dict[str, Any]) -> JokerResult | None:
97
- if event.seat == Seat.SOUTH:
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 on_round_start(self, state: dict[str, Any]) -> JokerResult | None:
73
- state[f"{self.id}_sabotage_remaining"] = 2
74
- return None
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(self, event: BeloteAnnouncedEvent, state: dict[str, Any]) -> JokerResult | None:
50
- if event.seat == Seat.SOUTH:
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
- item.apply(self.run)
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
- for i in range(1, 10):
46
- print(move(i, 1) + " ")
47
- print(move(10, 1) + ansi_center(red_fg() + BOLD + "! BOSS BLIND REVEALED !" + RESET, term_w))
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(13, 1) + ansi_center(gold_fg() + BOLD + boss.name.upper() + RESET, term_w))
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(15, 1) + ansi_center(white_fg() + boss.description + RESET, term_w))
52
- print(move(20, 1) + ansi_center(BOLD + "[ Press any key to continue ]" + RESET, term_w))
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 = 24
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(move(start_row + 1, info_col) + menu_border_fg() + "─" * 30 + RESET)
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 > 40:
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