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.
Files changed (130) hide show
  1. {belote_cli-3.8.0 → belote_cli-3.8.2}/CHANGELOG.md +39 -0
  2. {belote_cli-3.8.0 → belote_cli-3.8.2}/DEVELOPMENT.md +9 -5
  3. {belote_cli-3.8.0 → belote_cli-3.8.2}/PKG-INFO +4 -4
  4. {belote_cli-3.8.0 → belote_cli-3.8.2}/README.md +3 -3
  5. {belote_cli-3.8.0 → belote_cli-3.8.2}/pyproject.toml +1 -1
  6. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/__init__.py +1 -1
  7. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/core/run_state.py +2 -1
  8. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/core/scoring.py +3 -3
  9. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/engine/round_driver.py +27 -11
  10. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/annonces.py +5 -1
  11. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/contract.py +7 -2
  12. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/corrupted.py +4 -10
  13. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/economy.py +7 -2
  14. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/main.py +9 -0
  15. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/game.py +7 -3
  16. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/scoring.py +10 -2
  17. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ui/layout.py +1 -1
  18. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ui/render.py +1 -1
  19. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_belatro.py +1 -1
  20. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_boss_modifiers_integration.py +77 -0
  21. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_phase2_content.py +52 -0
  22. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_round_driver.py +70 -0
  23. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_belote.py +11 -7
  24. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_gameflow.py +1 -0
  25. {belote_cli-3.8.0 → belote_cli-3.8.2}/.claude/settings.local.json +0 -0
  26. {belote_cli-3.8.0 → belote_cli-3.8.2}/.gitignore +0 -0
  27. {belote_cli-3.8.0 → belote_cli-3.8.2}/.python-version +0 -0
  28. {belote_cli-3.8.0 → belote_cli-3.8.2}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
  29. {belote_cli-3.8.0 → belote_cli-3.8.2}/LICENSE +0 -0
  30. {belote_cli-3.8.0 → belote_cli-3.8.2}/scripts/benchmark.py +0 -0
  31. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/__init__.py +0 -0
  32. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/a11y.py +0 -0
  33. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/achievements.py +0 -0
  34. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ai.py +0 -0
  35. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ansi.py +0 -0
  36. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/__init__.py +0 -0
  37. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/core/__init__.py +0 -0
  38. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/core/economy.py +0 -0
  39. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/engine/__init__.py +0 -0
  40. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/engine/event_bus.py +0 -0
  41. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/engine/modifier_patch.py +0 -0
  42. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ghost_run.py +0 -0
  43. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/__init__.py +0 -0
  44. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/base.py +0 -0
  45. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/__init__.py +0 -0
  46. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/coinche.py +0 -0
  47. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  48. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
  49. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  50. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  51. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  52. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  53. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/planets.py +0 -0
  54. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/registry.py +0 -0
  55. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/tarots.py +0 -0
  56. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/items/vouchers.py +0 -0
  57. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/partner/__init__.py +0 -0
  58. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/partner/partner_state.py +0 -0
  59. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/partner/personality.py +0 -0
  60. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/partner/trust.py +0 -0
  61. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/progression/__init__.py +0 -0
  62. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/progression/save.py +0 -0
  63. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/progression/unlocks.py +0 -0
  64. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/run/__init__.py +0 -0
  65. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/run/ante.py +0 -0
  66. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/run/ante_themes.py +0 -0
  67. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/run/boss.py +0 -0
  68. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/run/decks.py +0 -0
  69. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/run/shop.py +0 -0
  70. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/run_summary.py +0 -0
  71. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/__init__.py +0 -0
  72. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/announce.py +0 -0
  73. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/collection.py +0 -0
  74. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/consumables.py +0 -0
  75. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/history.py +0 -0
  76. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/hud.py +0 -0
  77. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/menu.py +0 -0
  78. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/rules.py +0 -0
  79. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/shop.py +0 -0
  80. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/belatro/ui/trust_bar.py +0 -0
  81. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/config.py +0 -0
  82. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/context.py +0 -0
  83. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/deck.py +0 -0
  84. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/gameflow.py +0 -0
  85. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/input.py +0 -0
  86. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/main.py +0 -0
  87. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/replay.py +0 -0
  88. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/rules.py +0 -0
  89. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/stats.py +0 -0
  90. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/themes.py +0 -0
  91. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ui/__init__.py +0 -0
  92. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ui/announce.py +0 -0
  93. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ui/fit_guard.py +0 -0
  94. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ui/menu.py +0 -0
  95. {belote_cli-3.8.0 → belote_cli-3.8.2}/src/belote/ui/prompts.py +0 -0
  96. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/__init__.py +0 -0
  97. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/__init__.py +0 -0
  98. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_collection_logic.py +0 -0
  99. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_consumables_ui.py +0 -0
  100. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_contract_unlocks.py +0 -0
  101. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_dead_flag_fixes.py +0 -0
  102. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_deck_variants.py +0 -0
  103. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_event_bus.py +0 -0
  104. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_ghost_run.py +0 -0
  105. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_history_overlay.py +0 -0
  106. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_hud_synergy.py +0 -0
  107. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_partner_jokers.py +0 -0
  108. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_partner_trust.py +0 -0
  109. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_phase0_coverage.py +0 -0
  110. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_phase1_plumbing.py +0 -0
  111. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_phase3_meta.py +0 -0
  112. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_progression.py +0 -0
  113. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_run_summary.py +0 -0
  114. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_shop_empty_pools.py +0 -0
  115. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/belatro/test_voucher_idempotency.py +0 -0
  116. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_a11y.py +0 -0
  117. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_achievements.py +0 -0
  118. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_ai.py +0 -0
  119. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_ansi_helpers.py +0 -0
  120. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_bidding_all_pass.py +0 -0
  121. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_declaration_tiebreak.py +0 -0
  122. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_extended.py +0 -0
  123. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_game_logic.py +0 -0
  124. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_input_eof.py +0 -0
  125. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_layout.py +0 -0
  126. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_new_coverage.py +0 -0
  127. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_official_rules.py +0 -0
  128. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_properties.py +0 -0
  129. {belote_cli-3.8.0 → belote_cli-3.8.2}/tests/test_replay.py +0 -0
  130. {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 (650 tests expected)
87
+ # Full test suite (655 tests expected)
88
88
  PYTHONPATH=src pytest
89
89
  ```
90
90
 
91
- Current baseline (3.8.0):
92
- - **mypy**: 0 errors (strict mode, 78 files)
93
- - **ruff**: 0 violations
94
- - **pytest**: 650 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
+
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.0
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 (650 tests)
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 **650 tests** passing with 100% coverage on game-logic modules.
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**: 650/650 passed
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 (650 tests)
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 **650 tests** passing with 100% coverage on game-logic modules.
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**: 650/650 passed
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "belote-cli"
7
- version = "3.8.0"
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.8.0"
1
+ __version__ = "3.8.2"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -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, 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
 
@@ -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
- last_winner = trick_winner_seat(
919
- state.completed_tricks[-1], ctx.trump, ctx.se_trump, ctx.is_sa
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
- pts += _SEQUENCE_POINTS.get(d.length, 0)
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 T for full history.
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 (toggle with T/H key for full history).
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=False)
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
- assert breakdown.taker_total == CAPOT_BASE + BELOTE_POINTS
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
- assert breakdown.taker_total == GLOBAL_CONFIG.CAPOT_BASE_SANS_ATOUT, (
698
- f"SA Capot must use base 220, got {breakdown.taker_total}"
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
- assert breakdown.taker_total == GLOBAL_CONFIG.CAPOT_BASE_TOUT_ATOUT, (
706
- f"TA Capot must use base 348, got {breakdown.taker_total}"
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
- # Compare against base+belote rather than bare base.
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