belote-cli 3.3.4__tar.gz → 3.4.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 (121) hide show
  1. {belote_cli-3.3.4 → belote_cli-3.4.2}/CHANGELOG.md +122 -0
  2. {belote_cli-3.3.4 → belote_cli-3.4.2}/DEVELOPMENT.md +7 -5
  3. {belote_cli-3.3.4 → belote_cli-3.4.2}/PKG-INFO +29 -4
  4. {belote_cli-3.3.4 → belote_cli-3.4.2}/README.md +28 -3
  5. {belote_cli-3.3.4 → belote_cli-3.4.2}/pyproject.toml +1 -1
  6. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/__init__.py +1 -1
  7. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/ai.py +20 -4
  8. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/core/run_state.py +12 -1
  9. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/core/scoring.py +4 -1
  10. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/engine/event_bus.py +6 -0
  11. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/engine/round_driver.py +7 -4
  12. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/base.py +13 -0
  13. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/jokers/contract.py +6 -6
  14. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/jokers/trick_timing.py +5 -5
  15. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/main.py +3 -1
  16. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/partner/partner_state.py +12 -1
  17. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/progression/save.py +10 -1
  18. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/run/ante_themes.py +12 -7
  19. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/ui/hud.py +138 -10
  20. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/ui/shop.py +4 -1
  21. belote_cli-3.4.2/src/belote/belatro/ui/trust_bar.py +69 -0
  22. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/game.py +0 -5
  23. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/gameflow.py +10 -5
  24. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/input.py +8 -1
  25. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/main.py +8 -7
  26. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/ui/prompts.py +2 -1
  27. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/belatro/test_belatro.py +47 -7
  28. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/belatro/test_boss_modifiers_integration.py +60 -0
  29. belote_cli-3.4.2/tests/belatro/test_collection_logic.py +77 -0
  30. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/belatro/test_dead_flag_fixes.py +28 -0
  31. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/belatro/test_hud_synergy.py +6 -4
  32. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/belatro/test_partner_trust.py +69 -0
  33. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/belatro/test_phase0_coverage.py +100 -0
  34. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/belatro/test_phase3_meta.py +31 -3
  35. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/belatro/test_round_driver.py +62 -0
  36. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/test_ai.py +83 -0
  37. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/test_new_coverage.py +49 -0
  38. belote_cli-3.3.4/src/belote/belatro/ui/trust_bar.py +0 -44
  39. belote_cli-3.3.4/tests/belatro/test_collection_logic.py +0 -36
  40. {belote_cli-3.3.4 → belote_cli-3.4.2}/.claude/settings.local.json +0 -0
  41. {belote_cli-3.3.4 → belote_cli-3.4.2}/.gitignore +0 -0
  42. {belote_cli-3.3.4 → belote_cli-3.4.2}/.python-version +0 -0
  43. {belote_cli-3.3.4 → belote_cli-3.4.2}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
  44. {belote_cli-3.3.4 → belote_cli-3.4.2}/LICENSE +0 -0
  45. {belote_cli-3.3.4 → belote_cli-3.4.2}/scripts/benchmark.py +0 -0
  46. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/__init__.py +0 -0
  47. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/a11y.py +0 -0
  48. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/achievements.py +0 -0
  49. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/ansi.py +0 -0
  50. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/__init__.py +0 -0
  51. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/core/__init__.py +0 -0
  52. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/core/economy.py +0 -0
  53. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/engine/__init__.py +0 -0
  54. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/engine/modifier_patch.py +0 -0
  55. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/ghost_run.py +0 -0
  56. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/__init__.py +0 -0
  57. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/jokers/__init__.py +0 -0
  58. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/jokers/annonces.py +0 -0
  59. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/jokers/coinche.py +0 -0
  60. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  61. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/jokers/economy.py +0 -0
  62. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  63. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  64. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  65. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  66. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  67. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/planets.py +0 -0
  68. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/registry.py +0 -0
  69. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/tarots.py +0 -0
  70. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/items/vouchers.py +0 -0
  71. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/partner/__init__.py +0 -0
  72. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/partner/personality.py +0 -0
  73. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/partner/trust.py +0 -0
  74. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/progression/__init__.py +0 -0
  75. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/progression/unlocks.py +0 -0
  76. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/run/__init__.py +0 -0
  77. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/run/ante.py +0 -0
  78. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/run/boss.py +0 -0
  79. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/run/decks.py +0 -0
  80. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/run/shop.py +0 -0
  81. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/run_summary.py +0 -0
  82. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/ui/__init__.py +0 -0
  83. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/ui/announce.py +0 -0
  84. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/ui/collection.py +0 -0
  85. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/ui/history.py +0 -0
  86. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/ui/menu.py +0 -0
  87. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/belatro/ui/rules.py +0 -0
  88. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/config.py +0 -0
  89. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/context.py +0 -0
  90. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/deck.py +0 -0
  91. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/replay.py +0 -0
  92. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/rules.py +0 -0
  93. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/scoring.py +0 -0
  94. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/stats.py +0 -0
  95. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/themes.py +0 -0
  96. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/ui/__init__.py +0 -0
  97. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/ui/announce.py +0 -0
  98. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/ui/layout.py +0 -0
  99. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/ui/menu.py +0 -0
  100. {belote_cli-3.3.4 → belote_cli-3.4.2}/src/belote/ui/render.py +0 -0
  101. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/__init__.py +0 -0
  102. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/belatro/__init__.py +0 -0
  103. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/belatro/test_contract_unlocks.py +0 -0
  104. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/belatro/test_deck_variants.py +0 -0
  105. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/belatro/test_ghost_run.py +0 -0
  106. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/belatro/test_history_overlay.py +0 -0
  107. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/belatro/test_phase1_plumbing.py +0 -0
  108. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/belatro/test_phase2_content.py +0 -0
  109. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/belatro/test_progression.py +0 -0
  110. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/test_a11y.py +0 -0
  111. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/test_achievements.py +0 -0
  112. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/test_ansi_helpers.py +0 -0
  113. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/test_belote.py +0 -0
  114. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/test_extended.py +0 -0
  115. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/test_game_logic.py +0 -0
  116. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/test_gameflow.py +0 -0
  117. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/test_layout.py +0 -0
  118. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/test_official_rules.py +0 -0
  119. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/test_properties.py +0 -0
  120. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/test_replay.py +0 -0
  121. {belote_cli-3.3.4 → belote_cli-3.4.2}/tests/test_undo.py +0 -0
@@ -5,6 +5,128 @@ 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.4.2] - 2026-05-11
9
+
10
+ Implements the deferred bug roadmap from 3.4.1's verification pass. **9 fixes land here** — 3 Critical (C1/C3/C4), 4 High (H1/H4/H5/H7), 1 architectural cleanup (H10), 1 dead-code deletion (M4). Adds 17 regression tests (551 → 568). The 3.4.1 entry catalogued these against the source; this entry implements them. Plan file at `/home/mrrobot/.claude/plans/wtf-these-were-verified-shiny-flute.md`.
11
+
12
+ ### Fixed
13
+
14
+ - **`src/belote/ai.py:104-108` (C1) — AI no longer sees partner's hand under `hide_partner_hand`.** `AIMemory.update_memory()` now gates the `partner_hand` population on `not state.boss_modifiers.hide_partner_hand`. Under Le Fantôme Partenaire the human was blinded but the AI continued to play with perfect partner information — the boss's visibility cost was paid by one team only. The fix removes the perfect-information cheat; the AI still infers partner cards via `known_voids` and `played` like any real player. Regression test in `tests/belatro/test_dead_flag_fixes.py::test_ai_memory_respects_hide_partner_hand`.
15
+ - **`src/belote/gameflow.py:200-203` (C3) — Dix de Der announcement now uses the Rupture-aware winner.** Pre-3.4.2 the 8th-trick "Dix de Der (Team X)" line called raw `trick_winner_seat()` on `display_state.current_trick`, which ignores `boss_modifiers.no_consecutive_team_wins`. Under La Rupture the announcement could name a team that was *not* credited the +10 in scoring. Fix swaps to `compute_trick_winners(state, trump, is_sa, tricks=projected)[-1]` (same helper `scoring.py` uses), feeding `projected = completed_tricks + [display_state.current_trick]` since the 8th trick isn't yet pushed to `completed_tricks` when the announcement fires. Removes the now-unused `trick_winner_seat` import. Regression test in `tests/belatro/test_boss_modifiers_integration.py::test_dix_de_der_announcement_honors_rupture`.
16
+ - **`src/belote/ai.py:540-549` (C4) — `opp_trumps` no longer over-counts; TA total fixed.** The pre-3.4.2 formula `8 - sum(played trumps)` conflated "remaining trump anywhere" with "opponent trump", treating South's own hand and partner's visible cards as still in opponents' hands. The fix subtracts `my_trumps`, `played_trumps`, and `partner_trumps` from the total. Under Tout Atout every card is a trump and the total is `32`, not `8` — pre-3.4.2 the formula degraded to `8 - 0 = 8` always under TA because no card's `.suit` equals `Suit.TOUT_ATOUT`. Both regimes are now handled. Has the side effect of also fixing `my_trumps` under TA (used by `_score_leading_strategy` downstream). Regression tests in `tests/test_ai.py::test_opp_trumps_excludes_own_and_partner_hand` and `::test_opp_trumps_under_tout_atout_uses_32_total`.
17
+ - **`src/belote/belatro/items/jokers/contract.py` + `trick_timing.py` (H1) — 8 jokers now gate on team, not seat.** `LIdeologue`, `LeFanatique`, `LeDiplomate`, `LePatriote`, `LIllusionniste`, `LePremierSang` (both checks), `LeSergent`, `LExecuteur` were checking `event.winner == Seat.SOUTH` instead of `team_of(event.winner) == 0`. They silently no-opped when partner (North) took the relevant trick. Now consistent with the 3.2.0 fix that landed `LaSentinelle` and `LeDernierMot` on the same pattern. Five existing tests that asserted the broken behavior (`test_north_*_returns_none`) were rewritten to verify the new correct behavior — partner wins now fire the joker; opposing-team wins (EAST) are added as the new negative case. `LeSergent`'s "streak reset" semantics also shifted: an opposing-team trick now breaks the streak, not a partner-won one. Regression backstop in `tests/belatro/test_phase0_coverage.py::test_h1_team_aware_jokers_fire_on_north_partner_win`. **`LeRebelle` is intentionally not included** — the 3.4.1 catalogue flagged it as a probable audit hallucination; it is an `on_belote` joker, not on `on_trick_won`, and the seat-vs-team distinction there is a separate spec call deferred to a future cycle.
18
+ - **`src/belote/belatro/run/ante_themes.py` + `belatro/main.py:413-414` (H4) — TournoiAnte pays a real 50% of round payout.** `AnteTheme.on_blind_won` gains a `blind_payout: int` parameter (forwarded to base class and both subclasses). The call site at `belatro/main.py:412` snapshots `run.economy.money` immediately before `process_round_end` and computes `blind_payout = money_after - money_before` after all bonus paths (L'Avocat, `_bonus_money`, Le Puriste, L'Aristocrate) have run. TournoiAnte now does `add_money(max(1, blind_payout // 2))` — actually 50% of payout, with a $1 floor so blind payouts of 0 still pay something. Comment rewritten to match. Updated tests in `tests/belatro/test_phase3_meta.py` (CafeAnte test threads the new arg; TournoiAnte tests verify exact `payout // 2` math + the floor).
19
+ - **`src/belote/belatro/progression/save.py:97-101` (H5) — `load_profile` no longer loses starter unlocks.** Pre-3.4.2 the happy path read `data.get("unlocked_ids", [])`, defaulting to an empty list when the key was absent — wiping the Profile dataclass's `["le_classique", "le_courageux", "l_econome"]` starter unlocks for any save missing the key (legacy saves, manual edits, partial writes). The fix falls back to `Profile().unlocked_ids` when the key is missing; an explicitly empty `unlocked_ids: []` is honored unchanged (a player who has reset their unlocks stays reset). Two regression tests in `tests/belatro/test_collection_logic.py` lock both behaviors.
20
+ - **`src/belote/main.py:230-231` (H7) — stats line agrees with menu summary on ties.** Changed `won=(ns >= target and ns >= ew)` to `won=(ns >= target and ns > ew)` so the `update_stats_game` `won` flag aligns with `ui/menu.py:344`'s `winner = "NS" if ns > ew else "EW"`. On an exact tie at target, pre-3.4.2 the stats recorded a NS win while the visible summary said EW. Both regression tests in `tests/test_new_coverage.py` — one source-grep anti-pattern lock against `>=`, one semantic check on the formula.
21
+
22
+ ### Internal
23
+
24
+ - **`src/belote/belatro/partner/partner_state.py:38-49` (H10, architectural)** — `equip_joker` signature widened to `equip_joker(self, joker: Joker, run: BelAtroRun | None = None) -> bool`. When `run` is provided, `joker.on_purchase(run)` fires after the slot append. No current partner joker defines `on_purchase`, so no behaviour changes today — this is forward-looking infrastructure. The catalogue called this "latent"; equipping through this path will now invoke purchase-time effects consistently with the main joker slot equip path. Note: `equip_joker` has zero callers in the current codebase (the shop path equips through a different surface), so this fix is doubly forward-looking. Three regression tests in `tests/belatro/test_partner_trust.py::TestEquipJokerOnPurchase`.
25
+ - **`src/belote/game.py::advance_turn` (M4) — dead code deleted.** Zero callers across `src/` and `tests/`. The function was a one-line `replace(state, turn=state.turn.next_seat())` helper that no live code used.
26
+ - **Tests**: 551 → 568 (+17). Five existing `test_north_*_returns_none` tests in `test_belatro.py` were flipped to assert the new team-aware behavior (these tests had encoded the H1 bug as a contract — they are not test regressions but contract updates).
27
+ - **Strict gates**: pytest 568/568, mypy 0 errors (76 source files), ruff 0 violations.
28
+ - **Version markers bumped**: `pyproject.toml`, `src/belote/__init__.py`.
29
+ - **Docs bumped**: `CHANGELOG.md` (this entry), `README.md` "What's new in 3.4.2", `DEVELOPMENT.md` baseline.
30
+ - **Still deferred**: **H2** (`LEgoiste` partner-trick nullification) was flagged as needing a spec decision in 3.4.1 and was not in any tier the user picked for 3.4.2 — the comment says "Partner's points are nullified" which reads as intent, so a fix would invert documented behavior. Stays on the deferred list pending a spec call. `LeRebelle` `on_belote` seat/team gating (noted above) is similarly deferred.
31
+
32
+ ## [3.4.1] - 2026-05-11
33
+
34
+ Audit-verification-only release — **no code changes**. A fresh external LLM audit ("Comprehensive Audit Report — Belote CLI v3.4.0") produced 26 prioritized findings (4 Critical, 10 High, 12 Medium) plus a test-coverage section and a performance section. Direct verification against the source confirmed **7 real bugs** (3 Critical, 4 High), **1 architectural latent issue** (no current consumer but fragile for future work), and **1 disputed claim** that needs a spec call before any fix. **8 claims were false positives** under verification (intent inversion, defense-in-depth confused for bugs, or stale call-graph reading). Mediums spot-checked: 1 real (dead code), 4 false. The point of cutting a release for verification-only work is to (a) lock the false-positive catalogue against re-investigation next cycle and (b) record the confirmed-bug roadmap before any fixes land. Plan/verification file at `/home/mrrobot/.claude/plans/check-on-this-audit-polished-kite.md`. Test count, mypy, ruff results unchanged from 3.4.0 (no source code touched).
35
+
36
+ ### Confirmed bugs — deferred to 3.4.2+
37
+
38
+ These were verified against current code and are real. None are fixed in 3.4.1; they are catalogued here so the next session has a vetted target list.
39
+
40
+ **Critical**
41
+ - **`src/belote/ai.py:104-108` (C1) — AI sees partner's full hand regardless of `hide_partner_hand`.** `AIMemory.update_memory()` unconditionally populates `self.memory.partner_hand` from `state.hand_of(partner)`. The boss flag `hide_partner_hand` (declared at `game.py:177`, set by Le Fantôme Partenaire at `belatro/run/boss.py:171`) is only read by display code at `belatro/main.py:291`, never by the AI memory path. Net effect: under Le Fantôme Partenaire the human is blinded to partner's hand but the AI continues to play with perfect partner information.
42
+ - **`src/belote/gameflow.py:196-198` (C3) — Dix de Der announcement uses non-Rupture-aware winner.** The announcement calls `trick_winner_seat()` directly; the La Rupture-aware helper `compute_trick_winners()` (defined at `game.py:756`, used by `scoring.py`) is the one that honours `boss_modifiers.no_consecutive_team_wins`. Under La Rupture the announced "Dix de Der goes to TEAM X" line can name a team that is not actually credited the +10 in scoring.
43
+ - **`src/belote/ai.py:533` (C4) — `opp_trumps` formula conflates "remaining trump" with "opponent trump".** Current line is `opp_trumps = 8 - sum(1 for c in self.memory.played if c.suit == trump)`. This counts trump still anywhere in unrevealed hands — including South's own hand (already computed as `my_trumps` on line 532) and partner's hand (visible at `self.memory.partner_hand`). The variable is then compared against `my_trumps` in `_score_leading_strategy`, so the over-count biases Hard AI's trump-coverage decisions when the AI itself is holding trump.
44
+
45
+ **High**
46
+ - **`src/belote/belatro/items/jokers/contract.py` + `trick_timing.py` (H1, partial) — 8 jokers still gate on `event.winner == Seat.SOUTH`.** 3.2.0 fixed La Sentinelle and Le Dernier Mot to use `team_of(event.winner) == 0`. The same anti-pattern survives in: `LIdeologue` (contract.py:21), `LeFanatique` (contract.py:45), `LeDiplomate` (contract.py:62), `LePatriote` (contract.py:81), `LIllusionniste` (contract.py:128), `LePremierSang` (trick_timing.py:26/30), `LeSergent` (trick_timing.py:46), `LExecuteur` (trick_timing.py:82). All silently no-op when North takes the relevant trick. (The audit also named `LeRebelle` but I could not find it among the South-only checks — likely a hallucinated joker name; verify before touching.)
47
+ - **`src/belote/belatro/run/ante_themes.py:73-76` (H4) — TournoiAnte bonus is not the advertised 50%.** Effect uses `run.economy.add_money(max(1, run.economy.bonus_per_round // 2 + 2))`. The comment claims "+50% bonus on top of whatever payout the round produced", but the formula is a flat function of `bonus_per_round` (the per-round flat-bonus economy field), not 50% of actual round payout. Either compute true 50% of the round delta or rewrite the comment.
48
+ - **`src/belote/belatro/progression/save.py:94` (H5) — `load_profile` loses default unlocks.** Line reads `unlocked_ids=data.get("unlocked_ids", [])`. The `Profile` dataclass default is `["le_classique", "le_courageux", "l_econome"]`. A saved profile missing the key (older saves, manual edits, partial writes) reloads with no unlocks. The exception branch correctly returns `Profile()` with defaults; only the happy path corrupts.
49
+ - **`src/belote/main.py:230-231` (H7) — Win operator mismatch on ties.** Main loop uses `won=(ns >= target and ns >= ew)`; menu summary at `ui/menu.py:344` uses `winner = "NS" if ns > ew else "EW"`. On an exact tie at target, main records a NS win while the visible summary attributes the round to EW.
50
+
51
+ ### Architectural / latent
52
+
53
+ - **`src/belote/belatro/partner/partner_state.py:34-38` (H10) — `equip_joker` skips `on_purchase`.** The method simply appends to `self.jokers`; no `on_purchase()` hook is invoked. No current partner joker defines `on_purchase()`, so this is latent today — but any future partner joker with a purchase-time effect (a la `LeTraitre` in `corrupted.py`) will silently fail to fire when equipped through this path. Document or wire the hook before adding such a joker.
54
+ - **`src/belote/game.py:1007` (M4) — `advance_turn()` is dead code.** Defined, never called. Safe to delete.
55
+
56
+ ### Needs spec decision before fixing
57
+
58
+ - **`src/belote/belatro/items/jokers/corrupted.py:56-62` (H2) — `LEgoiste` nullifies the entire partner trick.** On `event.winner == Seat.NORTH` the joker returns `JokerResult(add_chips=-event.card_points)`, where `event.card_points` is the FULL trick's card points (South's contribution included). The audit reads this as a bug (only partner's own contribution should be subtracted); the code comment reads "Partner's points are nullified", implying *intentional* full-trick nullification. Resolve which is canonical before changing the formula — and either way add a test that pins the intended behaviour.
59
+
60
+ ### Verified clean — agent claims that did NOT survive source verification
61
+
62
+ Catalogued so they aren't re-investigated next cycle.
63
+
64
+ - **(C2) "No round-2 bid validation in `place_bid`"** — `place_bid` itself does not validate, but the human-input path filters the up-card suit out of the options menu at `ui/prompts.py:132-135`, and the AI path uses `exclude=forbidden` at `ai.py:137`. Defense-in-depth gap (a programmatic caller could pass a bad bid), not a live bug.
65
+ - **(H3) "`LeFou` fallback is dead code"** — `tarots.py:119` checks `if last_id and last_id != self.id`. `last_consumable_id` defaults to `None` and `getattr(item, "id", None)` can also set it to `None`, so the fallback fires on the first consumable of a run and on self-copy attempts. Live code.
66
+ - **(H6) "Signal handlers skip `finally` and lose stats"** — `main.py:127-129` handler does call `sys.exit(0)`, which bypasses the `finally`. But `flush_stats()` is also invoked at `main.py:160` (quit path) and `main.py:235` (game-over). The data-loss window is narrow (SIGINT before any flush in the same session). Cosmetic at best.
67
+ - **(H8) "`zip(..., strict=False)` causes silent data loss in scoring"** — `winners` at `scoring.py:461,498` is produced by `compute_trick_winners(state, ...)`, which is exactly one entry per completed trick by construction. The length invariant holds; `strict=False` is defensive noise, not a live bug.
68
+ - **(H9) "`_CARD_TO_ID[c]` will KeyError on BelAtro jokers"** — `_CARD_TO_ID` is built from `make_deck()` (32 standard cards). BelAtro jokers are `Joker` objects living in `state._joker_state`, never inserted into `hand`. No reachable code path produces the KeyError.
69
+ - **(M2) "Frozen `GameState` contains mutable `_rng`"** — `_rng` is declared with `field(default_factory=random.Random, compare=False, repr=False)` — the same documented pattern used for `_joker_state` (see comment at `game.py:214-217`). Intentional; the contract is "always rebuild via `dataclasses.replace`".
70
+ - **(M8) "`card in self.memory.partner_hand` is always False"** — `partner_hand` is populated at `ai.py:107-108` during PLAYING/SCORING phase. The check at line 660 is live and prevents double-scoring visible partner cards.
71
+ - **(M12) "`mult == float(int(mult))` float precision bug"** — Intentional optimisation in `belatro/core/scoring.py:254`: when the multiplier is exactly integral, take the lossless integer-multiplication path; else accept float multiplication. Correct logic.
72
+
73
+ ### Audit calibration notes (for the next pass)
74
+
75
+ The audit's overall scaffolding (file:line citations, severity tiers, action plan) was well-presented but had a recurring failure mode: **flagging "suspicious-looking" patterns without verifying behaviour in context**. Concrete recurring misses worth feeding back to the auditing model:
76
+
77
+ 1. **Defense-in-depth confused with bugs** (C2, H8, H9). When the audited line lacks an obvious check, the audit should trace one hop up the call graph before declaring "no validation". In all three cases the invariant is enforced at the caller.
78
+ 2. **Intent inversion** (H2, M2, M12). When code is paired with a comment that describes the exact behaviour the audit flags as wrong, that's evidence of intent, not a bug. Reading the adjacent comment before flagging would have caught these.
79
+ 3. **Self-cancelling dead-code claims** (H3). The audit claimed "`last_consumable_id` is always set before `use()` is called". True — but it can be set to `None`, and the consumer's guard is `if last_id and ...`. Surface-level static reasoning without tracing the guard.
80
+ 4. **Headline metrics with no numbers**. The "Current Health" table lists Tests / Lint / Types / Version rows with no values. The version row is checkable; the others were left blank, which makes the table cosmetic.
81
+ 5. **Joker name drift** (H1). `LeRebelle` appeared in the South-only list but is not among the actual offenders. Probable hallucination.
82
+
83
+ ### Internal
84
+
85
+ - **No source code touched.** Test count, mypy strictness, ruff cleanliness all unchanged from 3.4.0: pytest 551/551, mypy 0 errors (76 files), ruff 0 violations.
86
+ - **Version markers bumped**: `pyproject.toml`, `src/belote/__init__.py`.
87
+ - **Docs bumped**: `README.md` "What's new in 3.4.1" section, `DEVELOPMENT.md` baseline.
88
+ - **Roadmap for 3.4.2**: the 7 confirmed bugs above (C1, C3, C4, H1×8, H4, H5, H7) plus the H10/M4 cleanups. Tier 1 (C1/C3/C4) closes the AI-fairness gap; Tier 2 (H1/H4/H5/H7) closes the remaining verified issues.
89
+
90
+ ## [3.4.0] - 2026-05-10
91
+
92
+ Audit + endless-mode reliability + HUD polish release. A fresh three-agent codebase pass (classic engine / BelAtro layer / UI + I/O) produced ~80 candidate findings. Direct verification against the source rejected ~95% as false positives or by-design patterns. The five surviving issues plus two **new** bugs uncovered during follow-up verification of endless mode and classic game flow are fixed here. Two HUD features land alongside (joker pip strip with edition glow, synergy tooltip, polished trust bar). 551 tests passing (up from 549), ruff and mypy strict still clean. Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-fizzy-summit.md`.
93
+
94
+ ### Fixed
95
+
96
+ - **`src/belote/belatro/engine/round_driver.py` (A1, HIGH)** — `BidMadeEvent` was emitted twice for the winning bid on every coinche path (player coinche → AI surcoinche, AI partner coinche, boss `auto_coinche` for EW *and* NS takers, and the `start_coinched` deck mod). Both emits ran `on_bid` joker handlers — once with `coinche_level=0`, then again with the resolved level — so any `on_bid` joker that accumulates per event was silently invoked twice for the same bid (Le Passeur and the contract-injection path were both vulnerable, future on_bid jokers more so). The fix adds a `re_emit: bool = False` field to `BidMadeEvent`; the post-coinche refreshes pass `re_emit=True`, and `ScoreAccumulator.update_state` skips `_fire_jokers("on_bid", ...)` for re-emits while still updating `joker_state["contract"]` so the HUD and contract-aware logic stay in sync. Regression test in `tests/belatro/test_round_driver.py::test_bid_made_event_does_not_double_fire_on_bid_under_auto_coinche` (registers a counting `on_bid` joker under L'Avocat and asserts no fire carries `coinche_level > 0`).
97
+ - **`src/belote/belatro/core/run_state.py::enter_endless` (E1, HIGH)** — Pre-3.4.0, accepting the "Continue into Endless Mode? (Ante 9+ scales ×2.2)" prompt left the run at `(ante=8, blind_index=2, endless_ante_offset=0, endless=True)`. The next `_play_blind` therefore *replayed* the Ante 8 Boss Blind at the SAME base target before the ×2.2 scaling kicked in on the second cycle — the prompt's promise of "Ante 9+ scales" was violated for one full round. The fix bumps `endless_ante_offset` to `max(offset, 1)` and resets `blind_index = 0` inside `enter_endless`, so the first endless round is Ante 8 Small Blind × 2.2 as advertised. Regression test in `tests/belatro/test_phase3_meta.py::test_enter_endless_advances_into_first_scaled_cycle`.
98
+ - **`src/belote/main.py` classic game-over branch (E2, HIGH)** — `apply_round_score` (scoring.py:952-953) intentionally keeps `phase=Phase.DEAL` when both teams reach `target` AND the round ended in a tie — Belote's tie-breaker rule. The classic main loop then re-checked `ns >= target or ew >= target` and unconditionally forced `phase=Phase.GAME_OVER`, overriding the scoring layer's intent: tie-breakers never played, the game just ended on the first round any team crossed target even if the score was exactly even. Fixed by replacing the redundant re-check with `if state.phase == Phase.GAME_OVER:` — the scoring layer is the single source of truth, and the unused `dataclasses.replace` import is removed.
99
+ - **`src/belote/input.py::_UnixKeyReader.restore` (A2, MED)** — `termios.tcsetattr` ran without exception handling. On a dropped SSH session, broken pipe, or a permission glitch it raised and left the host shell in raw/no-echo mode (the parent terminal would no longer echo keystrokes after the game crashed out). The call is now wrapped in `contextlib.suppress(termios.error, OSError)` and `_restored` is set regardless, so a follow-up restore call from `__exit__` after a prior raise is a no-op.
100
+ - **`src/belote/belatro/ui/shop.py` selection clamp (A3, MED)** — After reroll the index clamp was `min(self.selected, len(self.shop.inventory))`, which allows `selected == len(inventory)` — out-of-bounds for the very next render's `inventory[self.selected]`. The buy-path guard at the same site already used the correct `max(0, len(...) - 1)` form. Fixed to match.
101
+ - **`src/belote/ui/prompts.py::prompt_card` dead code (A5, LOW)** — The trailing `return None, state` after the `while True:` loop was unreachable (every match arm either continues or returns inside the loop). Replaced with an explicit `raise AssertionError("…")` so a future change that lets the loop fall through fails loud rather than silently returning a sentinel.
102
+
103
+ ### Added — UI/HUD polish
104
+
105
+ - **`src/belote/belatro/ui/hud.py::render_joker_pip_strip` (B.3)** — Row-1 strip of 5 joker slots, each rendered as a 4-cell pip `[Xx ]` (or `[Xx*]` when the joker is in an active synergy pair). Empty slots paint as dotted `[··]` so the player sees their capacity at a glance. Edition support: `F` Foil → bright cyan, `H` Holo → magenta, `P` Polychrome → pink-violet, `N` Negative → reverse-video. The shortcode is `Joker.shortcode` — a new class property that returns the joker's manual `_shortcode_override` if set, else the first two letters of `name` upper-cased. New jokers inherit a sensible default with no extra plumbing. Hidden under Le Brouillard's `hide_hud` like the rest of the BelAtro HUD.
106
+ - **`src/belote/belatro/ui/hud.py::render_synergy_tooltip` (B.4)** — When at least one synergy pair is active, prints a green-pip line below the score line describing the synergy (e.g. *"♦ Coinched Tout-Atout wins ramp the streak multiplier"*). Up to two synergies render on consecutive rows; further matches collapse to a `+N more synergies` line. `_SYNERGY_PAIRS` widened from `tuple[id_a, id_b]` to `tuple[id_a, id_b, description]`; existing `detect_synergies()` callers stay compatible via a 2-tuple shim, and the new `detect_synergies_full()` returns the description too. `validate_synergy_ids()` was updated to walk the new 3-tuple format.
107
+ - **`src/belote/belatro/ui/trust_bar.py` polish (B.5)** — Four-tier colour ramp (cramoisi ≤2 / orange 3–4 / gold 5–7 / emeraude 8–10) replacing the previous three-tier red/gold/green. Leading tier glyph rendered from `_TIER_GLYPHS` (`✗ ♡ ♥ ♦ ★`) — Loyal/Mécène (tier ≥3) glyphs are bolded so the top tiers stand out. All four-tier transitions reuse `TrustTrack.tier`'s existing bucketing — no trust-math change.
108
+ - **`src/belote/belatro/items/base.py::Joker.shortcode`** — New class property used by the pip strip. Subclasses can set `_shortcode_override = "Cs"` for a custom 2-char tag; otherwise the property derives one from `name`/`id`. No subclass changes required for the existing roster — defaults are good enough.
109
+
110
+ ### Verified clean — agent claims that did NOT survive source verification
111
+
112
+ These were flagged by the audit agents but verification against the current code showed they are either correct behaviour, by-design patterns, or already-handled invariants. Catalogued so they aren't re-investigated next cycle.
113
+
114
+ - **`game.py:562` "Tout Atout legal_cards downgrade" claim** — The `risers or tuple(my_suit_cards)` fallback is correct Belote: if you cannot rise within the lead suit, you may play any card *of that suit*. `my_suit_cards` is your hand filtered by lead suit, not played cards. **Not a bug.**
115
+ - **`game.py:947-955` "L'Anarchie unseeded `_rng`"** — The default `_rng = field(default_factory=random.Random)` IS unseeded, BUT `start_round()` at `game.py:302` always sets `_rng=rng` from the driver's seeded RNG before any round logic runs. By the time L'Anarchie consumes it at line 955 the seeded instance is in place. **Clean.**
116
+ - **`ai.py:73-92` "AI memory `last_voids_key` reset coverage"** — Both reset branches (new-round at line 73-78 and regression-detected at line 88-92) reset `last_voids_key` alongside the other three fields. **Clean per documented invariant.**
117
+ - **`run/shop.py:166-168` "Negative-edition double-fits a full inventory"** — The `joker_slots += 1; jokers.append()` sequence is the documented Negative design (see `_can_accept` docstring at line 145-147). Net effect: slot pool grows with the joker. **Not a bug.**
118
+ - **`round_driver.py:95-99` "Le Traître sabotage flag duplication"** — The guard `not state.boss_modifiers.agent_double_active` at line 95 and the population check `not state._joker_state.get("agent_double_tricks")` at line 120 prevent the double-population the agent feared. **Clean.**
119
+ - **`run_state.py:66` "`contract_levels` not reset per run"** — `BelAtroRun.contract_levels` is `field(default_factory=dict)`; each new run instance starts fresh. Within a run it intentionally accumulates so planet rewards persist. **By design.**
120
+ - **`registry.py:128-135` "`register_all_items` idempotency hole"** — The double-guard `_registered and registry.jokers` is *deliberate* per the docstring at line 130-133, to support test-suite registry resets. **Working as intended.**
121
+
122
+ ### Internal
123
+
124
+ - **Tests**: 549 → 551 (+2 — A1 regression + E1 regression). Ruff and mypy strict still clean across all 76 source files.
125
+ - **Strict gates**: pytest 551/551, mypy 0 errors (76 files), ruff 0 violations.
126
+ - **`BidMadeEvent`** gained a `re_emit: bool = False` field. Existing call sites unchanged; only the three post-coinche refresh sites in `round_driver.py` opt into `re_emit=True`. Backward-compatible.
127
+ - **`_SYNERGY_PAIRS`** widened to 3-tuples. `detect_synergies()` keeps the historic `list[tuple[str, str]]` return; `detect_synergies_full()` exposes the description.
128
+ - **Deferred to a future release**: the larger render-pipeline features from the plan — score gutter (B.2) and trick-lane compass animation (B.1) — were scoped out because they touch `ui/render.py`'s line-assembly and vertical-centering logic, where a regression risks the classic and BelAtro display flows. They remain on the roadmap but want a dedicated session.
129
+
8
130
  ## [3.3.4] - 2026-05-10
9
131
 
10
132
  Portability release — removes all terminal-bell / sound code, which was triggering SIGSYS ("Bad system call") on Alpine 23 (musl libc) the moment the first trick completed in classic Belote mode. BelAtro mode was unaffected on the same Alpine box (it never imported `play_sound`), and Kubuntu / Lubuntu 24.10 / 25.10 (glibc) were unaffected in either mode. Rather than guard the BEL writes behind a libc-detection flag, the entire sound subsystem is removed: classic Belote and BelAtro now share the same "no bells" baseline. 549 tests still passing, ruff and mypy strict still clean.
@@ -84,15 +84,17 @@ PYTHONPATH=src mypy --strict src/
84
84
  # Linting (0 violations expected)
85
85
  ruff check src/ tests/
86
86
 
87
- # Full test suite (549 tests expected)
87
+ # Full test suite (568 tests expected)
88
88
  PYTHONPATH=src pytest
89
89
  ```
90
90
 
91
- Current baseline (3.3.4):
92
- - **mypy**: 0 errors (strict mode, 75 files)
91
+ Current baseline (3.4.2):
92
+ - **mypy**: 0 errors (strict mode, 76 files)
93
93
  - **ruff**: 0 violations
94
- - **pytest**: 549 tests, 0 failures
95
- - 3.3.4 covered: removed all terminal-bell / sound code (`play_sound`, `AudioManager`, `[M]` mute key) to fix a SIGSYS crash on Alpine 23 / musl after the first classic-mode trick. BelAtro and glibc distros were unaffected; classic Belote and BelAtro now share the same "no bells" baseline.
94
+ - **pytest**: 568 tests, 0 failures
95
+ - 3.4.2 closes the 3.4.1 catalogue. All 7 confirmed bugs (C1 AI cheat under `hide_partner_hand`, C3 Dix de Der under La Rupture, C4 `opp_trumps` formula + TA total, H1 8 jokers seat→team, H4 TournoiAnte true 50%, H5 `load_profile` default unlocks, H7 classic-mode tie operator) plus H10 (`equip_joker` wires `on_purchase`) and M4 (delete dead `advance_turn`) ship in 3.4.2. +17 regression tests (551 568). H2 (`LEgoiste` partner-trick nullification) remains deferred — needs a spec call between code-comment intent and the audit's reading.
96
+ - 3.4.1 was **documentation-only** — an external LLM audit was verified against the source. 7 confirmed bugs were catalogued in `CHANGELOG.md` as deferred to 3.4.2+; 8 audit claims were rejected as false positives and are listed in the "Verified clean" section to block re-investigation. No source code changed in 3.4.1.
97
+ - 3.4.0 covered: A1 `BidMadeEvent` double-fire on coinche paths (HIGH), E1 endless mode replaying Ante 8 Boss instead of advancing to the first scaled cycle (HIGH), E2 classic-mode tie-breaker overridden by main loop (HIGH), A2 termios raw-mode leak on SSH drop (MED), A3 shop selection index off-by-one after reroll (MED), A5 prompts.py dead return (LOW). Plus HUD additions: joker pip strip with edition glow (B.3), synergy tooltip (B.4), four-tier trust bar with tier glyph (B.5). Score gutter (B.2) and trick-lane compass (B.1) intentionally deferred — they touch `ui/render.py`'s vertical-centering logic and want a dedicated session.
96
98
 
97
99
  Run all gates before committing:
98
100
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: belote-cli
3
- Version: 3.3.4
3
+ Version: 3.4.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
@@ -45,6 +45,31 @@ Description-Content-Type: text/markdown
45
45
 
46
46
  Complete implementation of the French card game Belote for the terminal, with a full-screen green felt table and full card graphics at compass positions (N/W/E/S).
47
47
 
48
+ ## What's new in 3.4.2
49
+
50
+ - **The 3.4.1 catalogue is closed.** All 7 confirmed bugs plus the H10 architectural cleanup and the M4 dead-code deletion ship here. **C1 — AI cheating under Le Fantôme Partenaire:** AI memory now respects `hide_partner_hand`; the boss flag's visibility cost is paid by both sides. **C3 — Dix de Der announcement under La Rupture:** the 8th-trick "Team X" line now uses the Rupture-aware `compute_trick_winners` helper, so the named team matches what scoring credits. **C4 — `opp_trumps` formula:** subtracts South's own trumps, played trumps, and partner-visible trumps; under Tout Atout the total switches to 32 (every card is a trump). **H1 — 8 BelAtro jokers:** `LIdeologue`, `LeFanatique`, `LeDiplomate`, `LePatriote`, `LIllusionniste`, `LePremierSang`, `LeSergent`, `LExecuteur` now gate on `team_of(event.winner) == 0` instead of `event.winner == Seat.SOUTH`, matching the 3.2.0 fix that landed `LaSentinelle` and `LeDernierMot`. **H4 — TournoiAnte:** `on_blind_won` now receives `blind_payout` and pays a true 50% (was a flat function of `bonus_per_round`). **H5 — `load_profile`:** saves missing the `unlocked_ids` key fall back to the Profile dataclass default starter unlocks. **H7 — classic win attribution on ties:** `update_stats_game` operator aligned to `ns > ew` so the stats line agrees with `menu.py`'s visible winner on an exact tie at target.
51
+ - **Architectural / cleanup.** `equip_joker` accepts an optional `run` and fires `on_purchase` when provided (forward-looking — no current partner joker has a purchase hook). Dead `advance_turn()` deleted from `game.py`.
52
+ - **Still deferred** (need spec calls, not implementation work): H2 `LEgoiste` partner-trick nullification (comment vs. audit reading); `LeRebelle` `on_belote` seat/team gating (separate code path from H1).
53
+ - **Tests + gates** — 568 tests (up from 551, +17 regressions). Five existing `test_north_*_returns_none` tests in `test_belatro.py` were flipped to assert the new team-aware behaviour (they had encoded the H1 bug as a contract). Strict gates still clean: pytest 568/568, mypy 0 errors (76 files), ruff 0 violations.
54
+
55
+ ## What's new in 3.4.1
56
+
57
+ - **Audit verification pass — no code changes.** An external LLM audit ("Comprehensive Audit Report — Belote CLI v3.4.0") flagged 4 Critical, 10 High, and 12 Medium issues. Direct verification against the source confirmed **7 real bugs** (3 Critical, 4 High), **1 architectural latent issue**, and **1 disputed claim** needing a spec decision; **8 claims were false positives**. The roadmap and the false-positive catalogue are recorded in `CHANGELOG.md` so the next session has a vetted target list and the rejected claims aren't re-investigated. *Note (3.4.2): the catalogue is now closed — all 7 confirmed bugs plus H10 and M4 are fixed in 3.4.2. The 3.4.1 entry is preserved as a historical record of the verification-only release.*
58
+ - **Confirmed bugs deferred to 3.4.2+**: AI ignoring `hide_partner_hand` (C1), Dix de Der announcement not La Rupture-aware (C3), AI `opp_trumps` over-count (C4), 8 BelAtro jokers still gated on South instead of NS team (H1), TournoiAnte bonus not actually 50% (H4), `load_profile` losing default unlocks (H5), classic-mode tie-break operator mismatch with the menu summary (H7). Plus latent: `equip_joker` skipping `on_purchase` (H10), and a dead `advance_turn()` (M4).
59
+ - **Audit calibration findings** — recurring failure modes documented for the next pass: defense-in-depth confused with bugs (C2/H8/H9), intent inversion against adjacent comments (H2/M2/M12), self-cancelling dead-code claims (H3), and joker-name hallucination (H1's `LeRebelle`). Useful as feedback to the auditing model.
60
+ - **No behavioural changes.** Test count, mypy strict, ruff all unchanged from 3.4.0: pytest 551/551, mypy 0 errors (76 files), ruff 0 violations.
61
+
62
+ ## What's new in 3.4.0
63
+
64
+ - **BelAtro joker correctness** — `BidMadeEvent` no longer double-fires `on_bid` joker handlers on coinche paths. Pre-3.4.0 the player-coinche, AI-partner-coinche, `auto_coinche` boss, and `start_coinched` deck-mod paths all re-emitted the same bid event a second time with the resolved `coinche_level`, so on_bid jokers (Le Passeur today, anything new tomorrow) were silently invoked twice for the same bid. Fixed via a `re_emit: bool` field on the event; refreshes update `joker_state["contract"]` but skip joker firing.
65
+ - **Endless mode honours its prompt** — Accepting "Continue into Endless Mode? (Ante 9+ scales ×2.2)" used to leave the player at Ante 8 Boss Blind for one more *un-scaled* round before the ×2.2 kicked in. `enter_endless()` now advances into the first scaled cycle (offset=1, blind_index=0) immediately, so the very next round is the scaled Small Blind as advertised.
66
+ - **Classic mode tie-breaker actually plays** — When both teams ended a round tied at exactly the target score, the classic loop unconditionally forced GAME_OVER, overriding `apply_round_score`'s deliberate `phase=DEAL` for tie-breaker rounds. The redundant re-check is gone; the scoring layer is the single source of truth for game-over phase.
67
+ - **Terminal raw-mode no longer leaks on SSH drop** — `_UnixKeyReader.restore()` now wraps `termios.tcsetattr` in `contextlib.suppress(termios.error, OSError)` and marks the reader restored regardless. A dropped SSH session previously left the parent shell in no-echo mode.
68
+ - **Shop reroll OOB fix** — Shop selection index after reroll is now correctly clamped to `len(inventory) - 1` instead of `len(inventory)`, preventing an out-of-range access on the next render.
69
+ - **HUD: joker pip strip + synergy tooltip + polished trust bar** — Top-row 5-slot strip shows your jokers as compact pips with edition tint (Foil cyan / Holo magenta / Polychrome pink-violet / Negative reverse-video); slots in an active synergy pair gain a `*` marker, and a one-line tooltip below the score line describes the synergy. The trust bar gains a four-tier colour ramp (cramoisi/orange/gold/emeraude) and a leading tier glyph (`✗ ♡ ♥ ♦ ★`) — Loyal/Mécène glyphs are bolded so the top tiers pop.
70
+ - **Audit reconciliation** — A fresh three-agent audit pass surfaced ~80 candidate findings; verification rejected ~95% as false positives or by-design patterns. The seven survivors are the fixes above; the rejected claims are catalogued in `CHANGELOG.md` so they aren't re-investigated.
71
+ - **Test coverage** — 551 tests (up from 549). Strict gates still clean: pytest 551/551, mypy 0 errors (76 files), ruff 0 violations.
72
+
48
73
  ## What's new in 3.3.4
49
74
 
50
75
  - **Portability fix** — Removed all terminal-bell / sound code, which was triggering SIGSYS ("Bad system call") on Alpine 23 (musl libc) the moment the first trick completed in classic Belote mode. BelAtro mode and every glibc-based distro (Kubuntu / Lubuntu 24.10 / 25.10) were unaffected, but rather than guard the BEL writes behind a libc check, the entire sound subsystem is gone — `play_sound`, `AudioManager` / `AUDIO`, `is_muted` / `toggle_mute`, the `[M]` mute key, and the help-screen mute line. Classic Belote and BelAtro now share the same "no bells" baseline.
@@ -326,7 +351,7 @@ belote/
326
351
  │ ├── input.py # Platform-dispatched key reader and interruptible sleep
327
352
  │ ├── stats.py # Global and session statistics tracking
328
353
  │ └── rules.py # Game rules content
329
- ├── tests/ # Comprehensive test suite (528 tests)
354
+ ├── tests/ # Comprehensive test suite (568 tests)
330
355
  ├── scripts/ # Performance benchmarks
331
356
  ├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
332
357
  ├── LICENSE # MIT License
@@ -342,14 +367,14 @@ belote/
342
367
  PYTHONPATH=src pytest
343
368
  ```
344
369
 
345
- Currently **528 tests** passing with 100% coverage on game-logic modules.
370
+ Currently **568 tests** passing with 100% coverage on game-logic modules.
346
371
 
347
372
  ## Technical Integrity
348
373
 
349
374
  The codebase is strictly validated with the following tools:
350
375
  - **mypy**: 0 errors (strict type safety)
351
376
  - **ruff**: 0 violations (linting & formatting)
352
- - **pytest**: 528/528 passed
377
+ - **pytest**: 568/568 passed
353
378
  - **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
354
379
  - **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
355
380
 
@@ -2,6 +2,31 @@
2
2
 
3
3
  Complete implementation of the French card game Belote for the terminal, with a full-screen green felt table and full card graphics at compass positions (N/W/E/S).
4
4
 
5
+ ## What's new in 3.4.2
6
+
7
+ - **The 3.4.1 catalogue is closed.** All 7 confirmed bugs plus the H10 architectural cleanup and the M4 dead-code deletion ship here. **C1 — AI cheating under Le Fantôme Partenaire:** AI memory now respects `hide_partner_hand`; the boss flag's visibility cost is paid by both sides. **C3 — Dix de Der announcement under La Rupture:** the 8th-trick "Team X" line now uses the Rupture-aware `compute_trick_winners` helper, so the named team matches what scoring credits. **C4 — `opp_trumps` formula:** subtracts South's own trumps, played trumps, and partner-visible trumps; under Tout Atout the total switches to 32 (every card is a trump). **H1 — 8 BelAtro jokers:** `LIdeologue`, `LeFanatique`, `LeDiplomate`, `LePatriote`, `LIllusionniste`, `LePremierSang`, `LeSergent`, `LExecuteur` now gate on `team_of(event.winner) == 0` instead of `event.winner == Seat.SOUTH`, matching the 3.2.0 fix that landed `LaSentinelle` and `LeDernierMot`. **H4 — TournoiAnte:** `on_blind_won` now receives `blind_payout` and pays a true 50% (was a flat function of `bonus_per_round`). **H5 — `load_profile`:** saves missing the `unlocked_ids` key fall back to the Profile dataclass default starter unlocks. **H7 — classic win attribution on ties:** `update_stats_game` operator aligned to `ns > ew` so the stats line agrees with `menu.py`'s visible winner on an exact tie at target.
8
+ - **Architectural / cleanup.** `equip_joker` accepts an optional `run` and fires `on_purchase` when provided (forward-looking — no current partner joker has a purchase hook). Dead `advance_turn()` deleted from `game.py`.
9
+ - **Still deferred** (need spec calls, not implementation work): H2 `LEgoiste` partner-trick nullification (comment vs. audit reading); `LeRebelle` `on_belote` seat/team gating (separate code path from H1).
10
+ - **Tests + gates** — 568 tests (up from 551, +17 regressions). Five existing `test_north_*_returns_none` tests in `test_belatro.py` were flipped to assert the new team-aware behaviour (they had encoded the H1 bug as a contract). Strict gates still clean: pytest 568/568, mypy 0 errors (76 files), ruff 0 violations.
11
+
12
+ ## What's new in 3.4.1
13
+
14
+ - **Audit verification pass — no code changes.** An external LLM audit ("Comprehensive Audit Report — Belote CLI v3.4.0") flagged 4 Critical, 10 High, and 12 Medium issues. Direct verification against the source confirmed **7 real bugs** (3 Critical, 4 High), **1 architectural latent issue**, and **1 disputed claim** needing a spec decision; **8 claims were false positives**. The roadmap and the false-positive catalogue are recorded in `CHANGELOG.md` so the next session has a vetted target list and the rejected claims aren't re-investigated. *Note (3.4.2): the catalogue is now closed — all 7 confirmed bugs plus H10 and M4 are fixed in 3.4.2. The 3.4.1 entry is preserved as a historical record of the verification-only release.*
15
+ - **Confirmed bugs deferred to 3.4.2+**: AI ignoring `hide_partner_hand` (C1), Dix de Der announcement not La Rupture-aware (C3), AI `opp_trumps` over-count (C4), 8 BelAtro jokers still gated on South instead of NS team (H1), TournoiAnte bonus not actually 50% (H4), `load_profile` losing default unlocks (H5), classic-mode tie-break operator mismatch with the menu summary (H7). Plus latent: `equip_joker` skipping `on_purchase` (H10), and a dead `advance_turn()` (M4).
16
+ - **Audit calibration findings** — recurring failure modes documented for the next pass: defense-in-depth confused with bugs (C2/H8/H9), intent inversion against adjacent comments (H2/M2/M12), self-cancelling dead-code claims (H3), and joker-name hallucination (H1's `LeRebelle`). Useful as feedback to the auditing model.
17
+ - **No behavioural changes.** Test count, mypy strict, ruff all unchanged from 3.4.0: pytest 551/551, mypy 0 errors (76 files), ruff 0 violations.
18
+
19
+ ## What's new in 3.4.0
20
+
21
+ - **BelAtro joker correctness** — `BidMadeEvent` no longer double-fires `on_bid` joker handlers on coinche paths. Pre-3.4.0 the player-coinche, AI-partner-coinche, `auto_coinche` boss, and `start_coinched` deck-mod paths all re-emitted the same bid event a second time with the resolved `coinche_level`, so on_bid jokers (Le Passeur today, anything new tomorrow) were silently invoked twice for the same bid. Fixed via a `re_emit: bool` field on the event; refreshes update `joker_state["contract"]` but skip joker firing.
22
+ - **Endless mode honours its prompt** — Accepting "Continue into Endless Mode? (Ante 9+ scales ×2.2)" used to leave the player at Ante 8 Boss Blind for one more *un-scaled* round before the ×2.2 kicked in. `enter_endless()` now advances into the first scaled cycle (offset=1, blind_index=0) immediately, so the very next round is the scaled Small Blind as advertised.
23
+ - **Classic mode tie-breaker actually plays** — When both teams ended a round tied at exactly the target score, the classic loop unconditionally forced GAME_OVER, overriding `apply_round_score`'s deliberate `phase=DEAL` for tie-breaker rounds. The redundant re-check is gone; the scoring layer is the single source of truth for game-over phase.
24
+ - **Terminal raw-mode no longer leaks on SSH drop** — `_UnixKeyReader.restore()` now wraps `termios.tcsetattr` in `contextlib.suppress(termios.error, OSError)` and marks the reader restored regardless. A dropped SSH session previously left the parent shell in no-echo mode.
25
+ - **Shop reroll OOB fix** — Shop selection index after reroll is now correctly clamped to `len(inventory) - 1` instead of `len(inventory)`, preventing an out-of-range access on the next render.
26
+ - **HUD: joker pip strip + synergy tooltip + polished trust bar** — Top-row 5-slot strip shows your jokers as compact pips with edition tint (Foil cyan / Holo magenta / Polychrome pink-violet / Negative reverse-video); slots in an active synergy pair gain a `*` marker, and a one-line tooltip below the score line describes the synergy. The trust bar gains a four-tier colour ramp (cramoisi/orange/gold/emeraude) and a leading tier glyph (`✗ ♡ ♥ ♦ ★`) — Loyal/Mécène glyphs are bolded so the top tiers pop.
27
+ - **Audit reconciliation** — A fresh three-agent audit pass surfaced ~80 candidate findings; verification rejected ~95% as false positives or by-design patterns. The seven survivors are the fixes above; the rejected claims are catalogued in `CHANGELOG.md` so they aren't re-investigated.
28
+ - **Test coverage** — 551 tests (up from 549). Strict gates still clean: pytest 551/551, mypy 0 errors (76 files), ruff 0 violations.
29
+
5
30
  ## What's new in 3.3.4
6
31
 
7
32
  - **Portability fix** — Removed all terminal-bell / sound code, which was triggering SIGSYS ("Bad system call") on Alpine 23 (musl libc) the moment the first trick completed in classic Belote mode. BelAtro mode and every glibc-based distro (Kubuntu / Lubuntu 24.10 / 25.10) were unaffected, but rather than guard the BEL writes behind a libc check, the entire sound subsystem is gone — `play_sound`, `AudioManager` / `AUDIO`, `is_muted` / `toggle_mute`, the `[M]` mute key, and the help-screen mute line. Classic Belote and BelAtro now share the same "no bells" baseline.
@@ -283,7 +308,7 @@ belote/
283
308
  │ ├── input.py # Platform-dispatched key reader and interruptible sleep
284
309
  │ ├── stats.py # Global and session statistics tracking
285
310
  │ └── rules.py # Game rules content
286
- ├── tests/ # Comprehensive test suite (528 tests)
311
+ ├── tests/ # Comprehensive test suite (568 tests)
287
312
  ├── scripts/ # Performance benchmarks
288
313
  ├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
289
314
  ├── LICENSE # MIT License
@@ -299,14 +324,14 @@ belote/
299
324
  PYTHONPATH=src pytest
300
325
  ```
301
326
 
302
- Currently **528 tests** passing with 100% coverage on game-logic modules.
327
+ Currently **568 tests** passing with 100% coverage on game-logic modules.
303
328
 
304
329
  ## Technical Integrity
305
330
 
306
331
  The codebase is strictly validated with the following tools:
307
332
  - **mypy**: 0 errors (strict type safety)
308
333
  - **ruff**: 0 violations (linting & formatting)
309
- - **pytest**: 528/528 passed
334
+ - **pytest**: 568/568 passed
310
335
  - **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
311
336
  - **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
312
337
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "belote-cli"
7
- version = "3.3.4"
7
+ version = "3.4.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.3.4"
1
+ __version__ = "3.4.2"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -102,8 +102,10 @@ class AIPlayer:
102
102
  # In this implementation, AI tracks what it can see
103
103
  p = partner(self.seat)
104
104
  self.memory.partner_hand.clear()
105
- if state.phase in (Phase.PLAYING, Phase.SCORING):
106
- # Partner's remaining cards
105
+ if (
106
+ state.phase in (Phase.PLAYING, Phase.SCORING)
107
+ and not state.boss_modifiers.hide_partner_hand
108
+ ):
107
109
  for card in state.hand_of(p):
108
110
  self.memory.partner_hand.add(card)
109
111
 
@@ -529,8 +531,22 @@ class AIPlayer:
529
531
 
530
532
  my_hand = state.hand_of(self.seat)
531
533
  hand_suit_counts: dict[Suit, int] = Counter(c.suit for c in my_hand)
532
- my_trumps = hand_suit_counts.get(trump, 0)
533
- opp_trumps = 8 - sum(1 for c in self.memory.played if c.suit == trump)
534
+ # Under Tout Atout every card is a trump; under a normal contract
535
+ # trump cards are those matching the trump suit. `opp_trumps` must
536
+ # subtract everything that is no longer in opponents' hands: my own
537
+ # trumps, trumps already played, and any of partner's visible
538
+ # trumps (empty under `hide_partner_hand`).
539
+ if trump is Suit.TOUT_ATOUT:
540
+ total_trumps = 32
541
+ my_trumps = len(my_hand)
542
+ played_trumps = len(self.memory.played)
543
+ partner_trumps = len(self.memory.partner_hand)
544
+ else:
545
+ total_trumps = 8
546
+ my_trumps = hand_suit_counts.get(trump, 0)
547
+ played_trumps = sum(1 for c in self.memory.played if c.suit == trump)
548
+ partner_trumps = sum(1 for c in self.memory.partner_hand if c.suit == trump)
549
+ opp_trumps = max(0, total_trumps - my_trumps - played_trumps - partner_trumps)
534
550
 
535
551
  # Score each legal card by expected outcome
536
552
  best_card = legal[0]
@@ -215,7 +215,18 @@ class BelAtroRun:
215
215
  self.run_over = True
216
216
 
217
217
  def enter_endless(self) -> None:
218
- """Toggle endless mode after beating ante 8."""
218
+ """Toggle endless mode after beating ante 8.
219
+
220
+ Pre-3.4.0 the loop continued at (ante=8, blind=2, offset=0), which made
221
+ the *first* endless round replay the Ante 8 Boss Blind at the same
222
+ target before the ×2.2 scaling kicked in on the next cycle. We now
223
+ advance into a fresh endless cycle here so the prompt's "Ante 9+ scales
224
+ ×2.2" is honoured immediately.
225
+ """
219
226
  self.endless = True
220
227
  self.run_won = False # endless overrides run-won state
221
228
  self.run_over = False # ...and re-opens the run so the main loop continues
229
+ # Skip the redundant Ante 8 Boss replay: bump offset and restart the
230
+ # blind cycle. max(...) preserves any externally-set offset (tests).
231
+ self.endless_ante_offset = max(self.endless_ante_offset, 1)
232
+ self.blind_index = 0
@@ -230,7 +230,10 @@ class ScoreAccumulator:
230
230
  elif isinstance(event, BidMadeEvent):
231
231
  # Inject contract type into joker state so jokers can read it
232
232
  joker_state["contract"] = event.contract
233
- _fire_jokers("on_bid", event)
233
+ # Re-emits (post-coinche refresh) update derived state but must not
234
+ # re-fire on_bid jokers — those already fired for the original bid.
235
+ if not event.re_emit:
236
+ _fire_jokers("on_bid", event)
234
237
 
235
238
  # Update GameState with new values
236
239
  return replace(
@@ -51,6 +51,12 @@ class BidMadeEvent:
51
51
  trump: Suit | None # None = pass
52
52
  contract: str # "normal" | "tout_atout" | "sans_atout" | "coinche" | "surcoinche"
53
53
  coinche_level: int = 0 # 0=none, 1=coinche, 2=surcoinche
54
+ # When True, this event is a post-coinche refresh of an already-emitted bid.
55
+ # Consumers should update derived state (HUD, joker_state["contract"]) but
56
+ # MUST NOT re-fire `on_bid` jokers — those already fired for the original
57
+ # bid during the bidding loop. Without this flag, jokers like Le Passeur
58
+ # would double-count or future on_bid-based scoring would silently overpay.
59
+ re_emit: bool = False
54
60
 
55
61
 
56
62
  # ── Bus ────────────────────────────────────────────────────────────────────
@@ -238,7 +238,9 @@ def drive_round(
238
238
  # L'Avocat boss forces at least coinche=1 (existing auto_coinche flag).
239
239
  if state.boss_modifiers.auto_coinche:
240
240
  coinche_level = max(coinche_level, 1)
241
- # Re-emit the final BidMadeEvent so jokers/HUD see the coinche level.
241
+ # Refresh joker_state with the resolved coinche level via a re-emit.
242
+ # `re_emit=True` updates derived state (HUD, joker_state["contract"])
243
+ # without re-firing on_bid jokers — those already fired in the loop.
242
244
  if coinche_level > 0:
243
245
  state = _emit(
244
246
  BidMadeEvent(
@@ -246,15 +248,14 @@ def drive_round(
246
248
  trump=state.trump,
247
249
  contract=state.contract or "normal",
248
250
  coinche_level=coinche_level,
251
+ re_emit=True,
249
252
  ),
250
253
  state,
251
254
  )
252
255
  elif state.boss_modifiers.auto_coinche and state.phase == Phase.PLAYING:
253
256
  # Boss forces coinche even if taker is on NS team.
254
257
  coinche_level = 1
255
- # Re-emit BidMadeEvent so jokers/HUD subscribed to on_bid see the
256
- # coinche level. The EW-taker branch above does this; this NS branch
257
- # used to skip it, silently dropping the event for on_bid subscribers.
258
+ # Re-emit refresh see comment above; on_bid is suppressed via re_emit.
258
259
  if state.taker is not None:
259
260
  state = _emit(
260
261
  BidMadeEvent(
@@ -262,6 +263,7 @@ def drive_round(
262
263
  trump=state.trump,
263
264
  contract=state.contract or "normal",
264
265
  coinche_level=coinche_level,
266
+ re_emit=True,
265
267
  ),
266
268
  state,
267
269
  )
@@ -280,6 +282,7 @@ def drive_round(
280
282
  trump=state.trump,
281
283
  contract=state.contract or "normal",
282
284
  coinche_level=coinche_level,
285
+ re_emit=True,
283
286
  ),
284
287
  state,
285
288
  )
@@ -61,6 +61,19 @@ class Joker(ABC):
61
61
  # NONE for backward compatibility with existing tests that instantiate
62
62
  # jokers directly.
63
63
  edition: Edition = Edition.NONE
64
+ # 3.4.0: short 2-char label used by the joker pip strip in the HUD. Sub-
65
+ # classes may override; the default takes the first two ASCII letters of
66
+ # `name` for instances that don't set their own. Resolved lazily so the
67
+ # default doesn't snapshot during class definition before name is set.
68
+ _shortcode_override: str = ""
69
+
70
+ @property
71
+ def shortcode(self) -> str:
72
+ if self._shortcode_override:
73
+ return self._shortcode_override[:2]
74
+ # Strip non-letters (avoid leading "L'" or "Le " producing empty codes)
75
+ letters = "".join(c for c in (self.name or self.id or "??") if c.isalpha())
76
+ return (letters[:2] or "??").upper()
64
77
 
65
78
  def on_trick_won(self, event: TrickWonEvent, state: dict[str, Any]) -> JokerResult | None:
66
79
  return None
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from typing import Any
4
4
 
5
5
  from belote.deck import Rank, Suit, card_points
6
- from belote.game import Seat
6
+ from belote.game import Seat, team_of
7
7
 
8
8
  from ...engine.event_bus import BeloteAnnouncedEvent, RoundEndEvent, TrickWonEvent
9
9
  from ..base import Joker, JokerResult
@@ -18,7 +18,7 @@ class LIdeologue(Joker):
18
18
 
19
19
  def on_trick_won(self, event: TrickWonEvent, state: dict[str, Any]) -> JokerResult | None:
20
20
  # Sans Atout has event.trump as None
21
- if event.winner == Seat.SOUTH and event.trump is None:
21
+ if team_of(event.winner) == 0 and event.trump is None:
22
22
  jacks = sum(1 for c in event.cards if c.rank == Rank.JACK)
23
23
  if jacks > 0:
24
24
  # In Sans Atout, Jack is worth 2. We want it to be 20.
@@ -42,7 +42,7 @@ class LeFanatique(Joker):
42
42
  if state.get("contract") != "tout_atout":
43
43
  return None
44
44
 
45
- if event.winner == Seat.SOUTH:
45
+ if team_of(event.winner) == 0:
46
46
  wins = state.get(f"{self.id}_wins", 0) + 1
47
47
  state[f"{self.id}_wins"] = wins
48
48
  if wins > 4:
@@ -59,7 +59,7 @@ class LeDiplomate(Joker):
59
59
  cost = 7
60
60
 
61
61
  def on_trick_won(self, event: TrickWonEvent, state: dict[str, Any]) -> JokerResult | None:
62
- if event.winner == Seat.SOUTH:
62
+ if team_of(event.winner) == 0:
63
63
  suits: dict[Suit, set[Rank]] = {}
64
64
  for c in event.cards:
65
65
  if c.rank in (Rank.KING, Rank.QUEEN):
@@ -78,7 +78,7 @@ class LePatriote(Joker):
78
78
  cost = 6
79
79
 
80
80
  def on_trick_won(self, event: TrickWonEvent, state: dict[str, Any]) -> JokerResult | None:
81
- if event.winner == Seat.SOUTH and event.trump:
81
+ if team_of(event.winner) == 0 and event.trump:
82
82
  trump_pts = sum(
83
83
  card_points(c, event.trump) for c in event.cards if c.suit == event.trump
84
84
  )
@@ -125,7 +125,7 @@ class LIllusionniste(Joker):
125
125
  cost = 9
126
126
 
127
127
  def on_trick_won(self, event: TrickWonEvent, state: dict[str, Any]) -> JokerResult | None:
128
- if event.winner == Seat.SOUTH and event.trump:
128
+ if team_of(event.winner) == 0 and event.trump:
129
129
  extra_pts = sum(
130
130
  18 for c in event.cards if c.rank == Rank.JACK and c.suit != event.trump
131
131
  )
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Any
4
4
 
5
- from belote.game import Seat, team_of
5
+ from belote.game import team_of
6
6
 
7
7
  from ...engine.event_bus import TrickWonEvent
8
8
  from ..base import Joker, JokerResult
@@ -23,11 +23,11 @@ class LePremierSang(Joker):
23
23
  # subsequent NS-won trick for the rest of the round.
24
24
  active = state.get(f"{self.id}_active", False)
25
25
  if event.trick_number == 1:
26
- if event.winner == Seat.SOUTH:
26
+ if team_of(event.winner) == 0:
27
27
  state[f"{self.id}_active"] = True
28
28
  return JokerResult(add_mult=2.0)
29
29
  return None
30
- if active and event.winner == Seat.SOUTH:
30
+ if active and team_of(event.winner) == 0:
31
31
  return JokerResult(add_mult=2.0)
32
32
  return None
33
33
 
@@ -43,7 +43,7 @@ class LeSergent(Joker):
43
43
  return None
44
44
 
45
45
  def on_trick_won(self, event: TrickWonEvent, state: dict[str, Any]) -> JokerResult | None:
46
- if event.winner == Seat.SOUTH:
46
+ if team_of(event.winner) == 0:
47
47
  streak = state.get(f"{self.id}_streak", 0) + 1
48
48
  state[f"{self.id}_streak"] = streak
49
49
  return JokerResult(add_mult=0.5)
@@ -79,7 +79,7 @@ class LExecuteur(Joker):
79
79
  is_unlockable = True
80
80
 
81
81
  def on_trick_won(self, event: TrickWonEvent, state: dict[str, Any]) -> JokerResult | None:
82
- if event.is_last and event.winner == Seat.SOUTH:
82
+ if event.is_last and team_of(event.winner) == 0:
83
83
  return JokerResult(add_chips=40, times_mult=1.5)
84
84
  return None
85
85
 
@@ -375,6 +375,7 @@ class BelAtroGame:
375
375
  if not lock_trust:
376
376
  trust.blind_failed()
377
377
  else:
378
+ money_before = self.run.economy.money
378
379
  payout = self.run.economy.process_round_end(total - self.run.target_score)
379
380
  if auto_coinche_active:
380
381
  self.run.economy.add_money(payout * 2) # L'Avocat: triple total payout
@@ -409,7 +410,8 @@ class BelAtroGame:
409
410
  # awards bonus money, Café gives +1 trust on big-blind wins).
410
411
  theme = self.run.get_ante_theme()
411
412
  if theme is not None:
412
- theme.on_blind_won(self.run, self.run.blind_index)
413
+ blind_payout = self.run.economy.money - money_before
414
+ theme.on_blind_won(self.run, self.run.blind_index, blind_payout)
413
415
 
414
416
  # Partner-specific trust events (skipped under Le Divorce)
415
417
  if not lock_trust: