belote-cli 3.3.4__tar.gz → 3.4.0__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 (120) hide show
  1. {belote_cli-3.3.4 → belote_cli-3.4.0}/CHANGELOG.md +40 -0
  2. {belote_cli-3.3.4 → belote_cli-3.4.0}/DEVELOPMENT.md +5 -5
  3. {belote_cli-3.3.4 → belote_cli-3.4.0}/PKG-INFO +12 -1
  4. {belote_cli-3.3.4 → belote_cli-3.4.0}/README.md +11 -0
  5. {belote_cli-3.3.4 → belote_cli-3.4.0}/pyproject.toml +1 -1
  6. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/__init__.py +1 -1
  7. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/core/run_state.py +12 -1
  8. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/core/scoring.py +4 -1
  9. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/engine/event_bus.py +6 -0
  10. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/engine/round_driver.py +7 -4
  11. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/base.py +13 -0
  12. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/ui/hud.py +138 -10
  13. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/ui/shop.py +4 -1
  14. belote_cli-3.4.0/src/belote/belatro/ui/trust_bar.py +69 -0
  15. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/input.py +8 -1
  16. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/main.py +7 -6
  17. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/ui/prompts.py +2 -1
  18. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_hud_synergy.py +6 -4
  19. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_phase3_meta.py +18 -0
  20. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_round_driver.py +62 -0
  21. belote_cli-3.3.4/src/belote/belatro/ui/trust_bar.py +0 -44
  22. {belote_cli-3.3.4 → belote_cli-3.4.0}/.claude/settings.local.json +0 -0
  23. {belote_cli-3.3.4 → belote_cli-3.4.0}/.gitignore +0 -0
  24. {belote_cli-3.3.4 → belote_cli-3.4.0}/.python-version +0 -0
  25. {belote_cli-3.3.4 → belote_cli-3.4.0}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
  26. {belote_cli-3.3.4 → belote_cli-3.4.0}/LICENSE +0 -0
  27. {belote_cli-3.3.4 → belote_cli-3.4.0}/scripts/benchmark.py +0 -0
  28. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/__init__.py +0 -0
  29. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/a11y.py +0 -0
  30. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/achievements.py +0 -0
  31. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/ai.py +0 -0
  32. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/ansi.py +0 -0
  33. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/__init__.py +0 -0
  34. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/core/__init__.py +0 -0
  35. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/core/economy.py +0 -0
  36. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/engine/__init__.py +0 -0
  37. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/engine/modifier_patch.py +0 -0
  38. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/ghost_run.py +0 -0
  39. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/__init__.py +0 -0
  40. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/__init__.py +0 -0
  41. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/annonces.py +0 -0
  42. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/coinche.py +0 -0
  43. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/contract.py +0 -0
  44. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  45. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/economy.py +0 -0
  46. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  47. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
  48. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  49. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  50. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  51. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  52. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/planets.py +0 -0
  53. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/registry.py +0 -0
  54. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/tarots.py +0 -0
  55. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/vouchers.py +0 -0
  56. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/main.py +0 -0
  57. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/partner/__init__.py +0 -0
  58. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/partner/partner_state.py +0 -0
  59. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/partner/personality.py +0 -0
  60. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/partner/trust.py +0 -0
  61. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/progression/__init__.py +0 -0
  62. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/progression/save.py +0 -0
  63. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/progression/unlocks.py +0 -0
  64. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/run/__init__.py +0 -0
  65. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/run/ante.py +0 -0
  66. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/run/ante_themes.py +0 -0
  67. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/run/boss.py +0 -0
  68. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/run/decks.py +0 -0
  69. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/run/shop.py +0 -0
  70. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/run_summary.py +0 -0
  71. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/ui/__init__.py +0 -0
  72. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/ui/announce.py +0 -0
  73. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/ui/collection.py +0 -0
  74. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/ui/history.py +0 -0
  75. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/ui/menu.py +0 -0
  76. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/ui/rules.py +0 -0
  77. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/config.py +0 -0
  78. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/context.py +0 -0
  79. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/deck.py +0 -0
  80. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/game.py +0 -0
  81. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/gameflow.py +0 -0
  82. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/replay.py +0 -0
  83. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/rules.py +0 -0
  84. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/scoring.py +0 -0
  85. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/stats.py +0 -0
  86. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/themes.py +0 -0
  87. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/ui/__init__.py +0 -0
  88. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/ui/announce.py +0 -0
  89. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/ui/layout.py +0 -0
  90. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/ui/menu.py +0 -0
  91. {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/ui/render.py +0 -0
  92. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/__init__.py +0 -0
  93. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/__init__.py +0 -0
  94. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_belatro.py +0 -0
  95. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_boss_modifiers_integration.py +0 -0
  96. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_collection_logic.py +0 -0
  97. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_contract_unlocks.py +0 -0
  98. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_dead_flag_fixes.py +0 -0
  99. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_deck_variants.py +0 -0
  100. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_ghost_run.py +0 -0
  101. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_history_overlay.py +0 -0
  102. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_partner_trust.py +0 -0
  103. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_phase0_coverage.py +0 -0
  104. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_phase1_plumbing.py +0 -0
  105. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_phase2_content.py +0 -0
  106. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_progression.py +0 -0
  107. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_a11y.py +0 -0
  108. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_achievements.py +0 -0
  109. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_ai.py +0 -0
  110. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_ansi_helpers.py +0 -0
  111. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_belote.py +0 -0
  112. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_extended.py +0 -0
  113. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_game_logic.py +0 -0
  114. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_gameflow.py +0 -0
  115. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_layout.py +0 -0
  116. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_new_coverage.py +0 -0
  117. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_official_rules.py +0 -0
  118. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_properties.py +0 -0
  119. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_replay.py +0 -0
  120. {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_undo.py +0 -0
@@ -5,6 +5,46 @@ 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.0] - 2026-05-10
9
+
10
+ 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`.
11
+
12
+ ### Fixed
13
+
14
+ - **`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`).
15
+ - **`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`.
16
+ - **`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.
17
+ - **`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.
18
+ - **`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.
19
+ - **`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.
20
+
21
+ ### Added — UI/HUD polish
22
+
23
+ - **`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.
24
+ - **`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.
25
+ - **`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.
26
+ - **`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.
27
+
28
+ ### Verified clean — agent claims that did NOT survive source verification
29
+
30
+ 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.
31
+
32
+ - **`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.**
33
+ - **`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.**
34
+ - **`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.**
35
+ - **`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.**
36
+ - **`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.**
37
+ - **`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.**
38
+ - **`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.**
39
+
40
+ ### Internal
41
+
42
+ - **Tests**: 549 → 551 (+2 — A1 regression + E1 regression). Ruff and mypy strict still clean across all 76 source files.
43
+ - **Strict gates**: pytest 551/551, mypy 0 errors (76 files), ruff 0 violations.
44
+ - **`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.
45
+ - **`_SYNERGY_PAIRS`** widened to 3-tuples. `detect_synergies()` keeps the historic `list[tuple[str, str]]` return; `detect_synergies_full()` exposes the description.
46
+ - **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.
47
+
8
48
  ## [3.3.4] - 2026-05-10
9
49
 
10
50
  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,15 @@ 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 (551 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.0):
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**: 551 tests, 0 failures
95
+ - 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
96
 
97
97
  Run all gates before committing:
98
98
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: belote-cli
3
- Version: 3.3.4
3
+ Version: 3.4.0
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,17 @@ 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.0
49
+
50
+ - **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.
51
+ - **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.
52
+ - **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.
53
+ - **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.
54
+ - **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.
55
+ - **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.
56
+ - **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.
57
+ - **Test coverage** — 551 tests (up from 549). Strict gates still clean: pytest 551/551, mypy 0 errors (76 files), ruff 0 violations.
58
+
48
59
  ## What's new in 3.3.4
49
60
 
50
61
  - **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.
@@ -2,6 +2,17 @@
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.0
6
+
7
+ - **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.
8
+ - **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.
9
+ - **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.
10
+ - **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.
11
+ - **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.
12
+ - **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.
13
+ - **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.
14
+ - **Test coverage** — 551 tests (up from 549). Strict gates still clean: pytest 551/551, mypy 0 errors (76 files), ruff 0 violations.
15
+
5
16
  ## What's new in 3.3.4
6
17
 
7
18
  - **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.
@@ -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.0"
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.0"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -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
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Sequence
3
4
  from typing import TYPE_CHECKING
4
5
 
5
- from belote.ansi import BOLD, DIM, RESET, gold_fg, move, red_fg, visible_len, white_fg
6
+ from belote.ansi import BOLD, DIM, RESET, gold_fg, green_fg, move, red_fg, visible_len, white_fg
6
7
  from belote.ui.layout import choose_layout
7
8
 
8
9
  if TYPE_CHECKING:
@@ -28,11 +29,21 @@ _MOOD_GLYPH = {
28
29
  # extend this tuple; pairs are order-insensitive (both directions matched).
29
30
  # Every id here MUST resolve in the joker registry — see
30
31
  # `validate_synergy_ids()` below; a startup self-check raises on typos.
31
- _SYNERGY_PAIRS: tuple[tuple[str, str], ...] = (
32
+ # 3.4.0: now a 3-tuple with a human-readable description rendered in the
33
+ # synergy tooltip when both jokers are active.
34
+ _SYNERGY_PAIRS: tuple[tuple[str, str, str], ...] = (
32
35
  # Coinche stacking with the Tout-Atout streak ramp
33
- ("coinche_stack", "tout_streak"),
36
+ (
37
+ "coinche_stack",
38
+ "tout_streak",
39
+ "Coinched Tout-Atout wins ramp the streak multiplier",
40
+ ),
34
41
  # La Sentinelle's trump-Jack lock plus a contract-level Mult booster
35
- ("la_sentinelle", "le_fanatique"),
42
+ (
43
+ "la_sentinelle",
44
+ "le_fanatique",
45
+ "Sentinelle locks Jack; Fanatique amplifies contract-suit Mult",
46
+ ),
36
47
  )
37
48
 
38
49
 
@@ -47,16 +58,21 @@ def validate_synergy_ids() -> list[str]:
47
58
  from ..items.registry import registry
48
59
 
49
60
  seen: set[str] = set()
50
- for a, b in _SYNERGY_PAIRS:
51
- seen.add(a)
52
- seen.add(b)
61
+ for entry in _SYNERGY_PAIRS:
62
+ seen.add(entry[0])
63
+ seen.add(entry[1])
53
64
  return sorted(s for s in seen if s not in registry.jokers)
54
65
 
55
66
 
56
- def detect_synergies(jokers: list[object]) -> list[tuple[str, str]]:
67
+ def detect_synergies(jokers: Sequence[object]) -> list[tuple[str, str]]:
68
+ """Return the (id_a, id_b) pairs that are both present in `jokers`.
69
+
70
+ Backward-compatible with pre-3.4.0 2-tuple callers — the description
71
+ field is dropped here. Use `detect_synergies_full()` to keep it.
72
+ """
57
73
  ids = {getattr(j, "id", "") for j in jokers}
58
- found = []
59
- for a, b in _SYNERGY_PAIRS:
74
+ found: list[tuple[str, str]] = []
75
+ for a, b, _desc in _SYNERGY_PAIRS:
60
76
  if a in ids and b in ids:
61
77
  found.append((a, b))
62
78
  # Generic catch-all: if the player has 3+ jokers but no specific pair
@@ -66,6 +82,16 @@ def detect_synergies(jokers: list[object]) -> list[tuple[str, str]]:
66
82
  return found
67
83
 
68
84
 
85
+ def detect_synergies_full(jokers: Sequence[object]) -> list[tuple[str, str, str]]:
86
+ """Like `detect_synergies` but returns the description too.
87
+
88
+ The generic 3+-joker stack synergy is NOT included — it has no specific
89
+ description and is purely a HUD nudge for variety.
90
+ """
91
+ ids = {getattr(j, "id", "") for j in jokers}
92
+ return [(a, b, desc) for (a, b, desc) in _SYNERGY_PAIRS if a in ids and b in ids]
93
+
94
+
69
95
  class BelAtroHUD:
70
96
  """Renders the roguelite HUD elements during gameplay."""
71
97
 
@@ -80,6 +106,16 @@ class BelAtroHUD:
80
106
  layout = choose_layout(term_w, term_h)
81
107
  run = self.run
82
108
 
109
+ # 3.4.0: joker pip strip on row 1 (above the existing HUD lines), shown
110
+ # in every layout including compact. Cheap — empty inventory still
111
+ # paints the dotted-slot capacity so the player learns the slot count.
112
+ if not state.boss_modifiers.hide_hud:
113
+ render_joker_pip_strip(run, term_w, row=1)
114
+ # Synergy tooltip below the score line; only fires when at least
115
+ # one pair is active. Compact layouts get one line; verbose two.
116
+ tooltip_row = 4 if layout.hud_style == "compact" else 5
117
+ render_synergy_tooltip(list(run.jokers), term_w, row=tooltip_row)
118
+
83
119
  if layout.hud_style == "compact":
84
120
  self._render_compact(acc, state, term_w)
85
121
  return
@@ -165,3 +201,95 @@ class BelAtroHUD:
165
201
  score_str = f"{state._chips}×{state._mult:.1f}={acc.get_total(state)}"
166
202
  score_col = max(2, term_w - len(score_str) - 2)
167
203
  print(move(3, score_col) + red_fg() + BOLD + score_str + RESET)
204
+
205
+
206
+ # ── 3.4.0: joker pip strip + synergy tooltip ────────────────────────────────
207
+
208
+ # Edition glyph & colour for the pip strip. Polychrome cycles colours but we
209
+ # keep a stable accent so the strip doesn't flicker — the visual interest
210
+ # comes from the colour difference between editions, not animation.
211
+ _EDITION_GLYPH: dict[str, str] = {
212
+ "none": " ",
213
+ "foil": "F",
214
+ "holo": "H",
215
+ "poly": "P",
216
+ "neg": "N",
217
+ }
218
+
219
+
220
+ def _edition_color(ed_value: str) -> str:
221
+ """ANSI prefix for an edition. Falls back to white for NONE."""
222
+ if ed_value == "foil":
223
+ return "\x1b[38;5;51m" # bright cyan
224
+ if ed_value == "holo":
225
+ return "\x1b[38;5;201m" # magenta
226
+ if ed_value == "poly":
227
+ return "\x1b[38;5;213m" # pink-violet (stand-in for rainbow)
228
+ if ed_value == "neg":
229
+ return "\x1b[7m" # reverse video
230
+ return str(white_fg())
231
+
232
+
233
+ def render_joker_pip_strip(run: BelAtroRun, term_w: int, row: int = 1) -> None:
234
+ """Render a compact one-row strip of joker slots at `row` (default top).
235
+
236
+ Layout: `J: [Co][To*][..][..][..]` — 4 chars per slot, leading "J: " label,
237
+ `*` marker on slots involved in an active synergy pair. Empty slots are
238
+ rendered with `··` so the player sees their capacity at a glance.
239
+
240
+ No-ops when `term_w < 24` (not enough room for a 5-slot strip).
241
+ """
242
+ if term_w < 24:
243
+ return
244
+ slots = max(1, run.joker_slots)
245
+ jokers = list(run.jokers)
246
+ # Detect which joker ids are in an active synergy so we can mark their pips
247
+ synergetic_ids: set[str] = set()
248
+ for a, b, _desc in detect_synergies_full(jokers):
249
+ synergetic_ids.add(a)
250
+ synergetic_ids.add(b)
251
+
252
+ parts: list[str] = [f"{white_fg()}J:{RESET} "]
253
+ for i in range(slots):
254
+ if i < len(jokers):
255
+ j = jokers[i]
256
+ ed_value = getattr(getattr(j, "edition", None), "value", "none")
257
+ ed_color = _edition_color(ed_value)
258
+ shortcode = (getattr(j, "shortcode", "??") or "??")[:2]
259
+ marker = "*" if getattr(j, "id", "") in synergetic_ids else " "
260
+ # Pip cell: `[Xx*]` with edition-coloured content (4 cells wide
261
+ # excluding the gold brackets).
262
+ parts.append(
263
+ f"{gold_fg()}[{RESET}"
264
+ f"{ed_color}{shortcode}{marker}{RESET}"
265
+ f"{gold_fg()}]{RESET}"
266
+ )
267
+ else:
268
+ parts.append(f"{DIM}[··]{RESET}")
269
+ strip = "".join(parts)
270
+ # Center is overkill; anchor at col 2 so it doesn't fight the score line
271
+ # on the right of row 2.
272
+ print(move(row, 2) + strip)
273
+
274
+
275
+ def render_synergy_tooltip(jokers: Sequence[object], term_w: int, row: int = 5) -> None:
276
+ """Render one-line synergy descriptions at `row` if any pair is active.
277
+
278
+ No-ops when there are no active synergies. Truncates each line to the
279
+ available width so we never wrap.
280
+ """
281
+ pairs = detect_synergies_full(list(jokers))
282
+ if not pairs:
283
+ return
284
+ # Show up to two synergies; further ones are summarised as "+N more".
285
+ max_w = max(20, term_w - 4)
286
+ for i, (_a, _b, desc) in enumerate(pairs[:2]):
287
+ line = f"{green_fg()}♦{RESET} {white_fg()}{desc}{RESET}"
288
+ if visible_len(line) > max_w:
289
+ # crude trim — fall back to plain ASCII to make ansi-stripping
290
+ # unnecessary (we never split mid-escape).
291
+ line = desc[: max_w - 2] + ".."
292
+ print(move(row + i, 2) + line)
293
+ if len(pairs) > 2:
294
+ extra = f"{DIM}+{len(pairs) - 2} more synergies{RESET}"
295
+ print(move(row + 2, 2) + extra)
@@ -68,7 +68,10 @@ class ShopScreen:
68
68
  self.selected = max(0, len(self.shop.inventory) - 1)
69
69
  elif self.selected == num_items:
70
70
  self.shop.reroll()
71
- self.selected = min(self.selected, len(self.shop.inventory))
71
+ # Clamp to a *valid* index: len(inventory)-1, not len.
72
+ # The previous form let `selected == len(inventory)` slip
73
+ # through, OOB on the next render's inventory[self.selected].
74
+ self.selected = min(self.selected, max(0, len(self.shop.inventory) - 1))
72
75
  elif self.selected == forge_idx:
73
76
  self._handle_forge()
74
77
  elif key in (Key.ESC, Key.QUIT):
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from belote.ansi import BOLD, RESET, gold_fg, green_fg, move, red_fg, white_fg
6
+
7
+ if TYPE_CHECKING:
8
+ from ..partner.trust import TrustTrack
9
+
10
+
11
+ # 3.4.0: per-tier glyph + name. Index = TrustTrack.tier (0–4).
12
+ _TIER_GLYPHS: tuple[str, ...] = ("✗", "♡", "♥", "♦", "★")
13
+ _TIER_NAMES: tuple[str, ...] = ("Méfiant", "Sulking", "Neutre", "Loyal", "Mécène")
14
+
15
+
16
+ def _orange_fg() -> str:
17
+ # Standalone helper — ansi.py doesn't expose an orange yet. Bright yellow
18
+ # SGR (38;5;208) renders close to "orange" on 256-colour terminals; falls
19
+ # back to gold on 16-colour. Cheap inline avoids a wider ansi.py change.
20
+ return "\x1b[38;5;208m"
21
+
22
+
23
+ def _bar_color(value: int) -> str:
24
+ """Four-tier colour ramp: 0–2 cramoisi, 3–4 orange, 5–7 gold, 8–10 emeraude."""
25
+ if value <= 2:
26
+ return str(red_fg())
27
+ if value <= 4:
28
+ return _orange_fg()
29
+ if value <= 7:
30
+ return str(gold_fg())
31
+ return str(green_fg())
32
+
33
+
34
+ class TrustBar:
35
+ """Visualizes the 0-10 Trust Track with a tier glyph and 4-colour gradient."""
36
+
37
+ def __init__(self, trust: TrustTrack) -> None:
38
+ self.trust = trust
39
+
40
+ def render(self) -> None:
41
+ """Render trust meter at (row 4, col 2)."""
42
+ val = self.trust.value
43
+ tier = self.trust.tier
44
+ filled = "█" * val
45
+ empty = "░" * (10 - val)
46
+ color = _bar_color(val)
47
+ bar = color + filled + white_fg() + empty + RESET
48
+ # Tier glyph leads the bar; bolded for the top two tiers so Loyal/
49
+ # Mécène stand out at a glance.
50
+ glyph = _TIER_GLYPHS[tier]
51
+ glyph_render = (BOLD + color + glyph + RESET) if tier >= 3 else (color + glyph + RESET)
52
+
53
+ if self.trust.ai_degraded:
54
+ status = red_fg() + " ⚠ Degraded" + RESET
55
+ elif self.trust.auto_capot_available:
56
+ status = gold_fg() + " ★ Auto-Capot" + RESET
57
+ elif self.trust.shares_void_info:
58
+ status = green_fg() + " ✦ Void Info" + RESET
59
+ else:
60
+ status = ""
61
+
62
+ print(
63
+ move(4, 2)
64
+ + white_fg() + "Trust: " + RESET
65
+ + glyph_render + " ["
66
+ + bar
67
+ + white_fg() + "] " + RESET
68
+ + status
69
+ )
@@ -56,7 +56,14 @@ class _UnixKeyReader:
56
56
 
57
57
  def restore(self) -> None:
58
58
  if self._old_termios and not self._restored:
59
- termios.tcsetattr(self._stdin_fd, termios.TCSADRAIN, self._old_termios)
59
+ # A dropped SSH session / broken pipe can make tcsetattr raise.
60
+ # We swallow the error and mark restored anyway so a re-entrant
61
+ # restore() (e.g. from __exit__ after a prior failed restore) is
62
+ # a no-op rather than another raise.
63
+ import contextlib
64
+
65
+ with contextlib.suppress(termios.error, OSError):
66
+ termios.tcsetattr(self._stdin_fd, termios.TCSADRAIN, self._old_termios)
60
67
  self._restored = True
61
68
 
62
69
  def read(self) -> KeyEvent:
@@ -12,7 +12,6 @@ import random
12
12
  import shutil
13
13
  import signal
14
14
  import sys
15
- from dataclasses import replace
16
15
 
17
16
  from . import __version__
18
17
  from .ansi import (
@@ -216,11 +215,13 @@ def main() -> None:
216
215
  break
217
216
  state = res_round
218
217
 
219
- # Check for game over (after round end and score application)
220
- ns, ew = state.team_scores
221
- if ns >= target or ew >= target:
222
- state = replace(state, phase=Phase.GAME_OVER)
223
-
218
+ # `apply_round_score` (scoring.py) already set the correct
219
+ # phase: GAME_OVER when a team is ahead at/over target,
220
+ # DEAL on a tie at target so a tie-breaker round plays.
221
+ # Pre-3.4.0 this branch re-checked targets and forced
222
+ # GAME_OVER unconditionally, breaking the tie-breaker.
223
+ if state.phase == Phase.GAME_OVER:
224
+ ns, ew = state.team_scores
224
225
  unique_diffs = set(diffs_map.values())
225
226
  difficulty_str = (
226
227
  next(iter(unique_diffs)) if len(unique_diffs) == 1 else "mixed"
@@ -109,7 +109,8 @@ def prompt_card(
109
109
  idx = int(char) - 1
110
110
  if 0 <= idx < len(hand) and hand[idx] in legal:
111
111
  return hand[idx], state
112
- return None, state
112
+ # Unreachable: the while(True) above only exits via return.
113
+ raise AssertionError("prompt_card loop fell through without returning")
113
114
 
114
115
 
115
116
  def prompt_bid(state: GameState, reader: KeyReader) -> Suit | str | None:
@@ -42,8 +42,9 @@ def test_detect_synergies_finds_sentinelle_fanatique_pair() -> None:
42
42
 
43
43
  def test_detect_synergies_generic_stack_badge_for_three_unrelated() -> None:
44
44
  """Three jokers with no known pair still raise a generic 'stack' tag."""
45
- # Pick three IDs that aren't part of any pair.
46
- pair_ids = {a for pair in _SYNERGY_PAIRS for a in pair}
45
+ # Pick three IDs that aren't part of any pair. Each pair entry is
46
+ # (id_a, id_b, description) since 3.4.0 pull the first two.
47
+ pair_ids = {x for pair in _SYNERGY_PAIRS for x in pair[:2]}
47
48
  unrelated = [j for j_id, j in registry.jokers.items() if j_id not in pair_ids][:3]
48
49
  if len(unrelated) < 3:
49
50
  return # not enough non-paired jokers — registry too small
@@ -53,7 +54,7 @@ def test_detect_synergies_generic_stack_badge_for_three_unrelated() -> None:
53
54
 
54
55
  def test_detect_synergies_empty_for_unrelated_pair() -> None:
55
56
  """One unpaired + one unpaired = no badge, not even a generic one."""
56
- pair_ids = {a for pair in _SYNERGY_PAIRS for a in pair}
57
+ pair_ids = {x for pair in _SYNERGY_PAIRS for x in pair[:2]}
57
58
  unrelated = [j for j_id, j in registry.jokers.items() if j_id not in pair_ids][:2]
58
59
  if len(unrelated) < 2:
59
60
  return
@@ -66,7 +67,8 @@ def test_detect_synergies_does_not_fire_for_solo_half() -> None:
66
67
  is owned. Trip-wire for any future change to detect_synergies that
67
68
  accidentally matches single jokers against pair entries.
68
69
  """
69
- for left_id, right_id in _SYNERGY_PAIRS:
70
+ for entry in _SYNERGY_PAIRS:
71
+ left_id, right_id = entry[0], entry[1]
70
72
  # Confirm the right half is registered (the validate test above
71
73
  # already pins this, but be defensive).
72
74
  if right_id not in registry.jokers or left_id not in registry.jokers:
@@ -107,6 +107,24 @@ def test_enter_endless_clears_run_won_and_flips_flag() -> None:
107
107
  assert run.run_won is False
108
108
 
109
109
 
110
+ def test_enter_endless_advances_into_first_scaled_cycle() -> None:
111
+ """3.4.0 fix: pre-fix, entering endless from (ante=8, blind=2, offset=0)
112
+ left state at (ante=8, blind=2, offset=0, endless=True), so the next round
113
+ REPLAYED Ante 8 Boss Blind at base target before ×2.2 kicked in. The fix
114
+ bumps offset to 1 and resets blind_index so the very first endless round is
115
+ Ante 8 Small Blind × 2.2 — honouring the prompt's "Ante 9+ scales" promise.
116
+ """
117
+ run = BelAtroRun(ante_number=8, blind_index=2, run_won=True)
118
+ run.enter_endless()
119
+ assert run.endless is True
120
+ assert run.run_over is False
121
+ assert run.blind_index == 0 # restart of blind cycle
122
+ assert run.endless_ante_offset == 1 # first scaled cycle
123
+ # The very next blind is the scaled Small Blind, not a Boss replay.
124
+ assert run.current_blind.target == endless_ante(8, 0, 1).target
125
+ assert run.current_blind.name == "Small Blind"
126
+
127
+
110
128
  # ── Joker fusion ────────────────────────────────────────────────────────────
111
129
 
112
130
 
@@ -227,3 +227,65 @@ def test_traitre_joker_sabotage_preserved_when_no_boss() -> None:
227
227
  tricks = state._joker_state.get("agent_double_tricks")
228
228
  assert isinstance(tricks, frozenset)
229
229
  assert len(tricks) == 1 # traitre's "single random trick" pattern
230
+
231
+
232
+ # ── A1 regression (3.4.0): BidMadeEvent must not double-fire on_bid jokers ──
233
+
234
+
235
+ def test_bid_made_event_does_not_double_fire_on_bid_under_auto_coinche() -> None:
236
+ """Regression for the coinche-path double-fire bug.
237
+
238
+ Before the fix, the auto_coinche boss path emitted BidMadeEvent twice for
239
+ the winning bid: once during the bidding loop (coinche_level=0), then
240
+ again post-coinche-resolution (coinche_level=1). Both emits fired
241
+ `on_bid` jokers, so any joker that read coinche-related state on the bid
242
+ event would be silently invoked twice (or with stale info on the first
243
+ fire). The fix flags re-emits with `re_emit=True` so the accumulator
244
+ skips on_bid firing while still updating joker_state["contract"].
245
+ """
246
+ from belote.belatro.run.boss import LAvocat
247
+
248
+ # A joker that records every on_bid fire — we'll assert no fire happens
249
+ # with coinche_level > 0, since re-emits should not invoke on_bid.
250
+ class _BidSniffer(Joker):
251
+ id = "bid_sniffer"
252
+ name = "BidSniffer"
253
+ description = "test"
254
+
255
+ def __init__(self) -> None:
256
+ self.fires: list[int] = [] # captured coinche_level per fire
257
+
258
+ def on_bid(self, event, state): # type: ignore[no-untyped-def]
259
+ self.fires.append(event.coinche_level)
260
+
261
+ sniffer = _BidSniffer()
262
+ acc = ScoreAccumulator()
263
+ acc.attach_jokers([sniffer])
264
+ bus = EventBus()
265
+ partner = PartnerState()
266
+
267
+ # UI that passes on bids and plays the first legal card on every turn.
268
+ class _LegalPlayUI(MockUICallbacks):
269
+ def prompt_card(self, state: GameState): # type: ignore[no-untyped-def]
270
+ from belote.game import legal_cards
271
+
272
+ legal = legal_cards(state, Seat.SOUTH)
273
+ return legal[0], state
274
+
275
+ drive_round(
276
+ bus=bus,
277
+ partner=partner,
278
+ boss=LAvocat(),
279
+ ui_callbacks=_LegalPlayUI(),
280
+ acc=acc,
281
+ seed=7,
282
+ )
283
+
284
+ # The fix must ensure no on_bid invocation carries coinche_level > 0 —
285
+ # those only come from re-emits, which are now suppressed. Failures here
286
+ # mean a re-emit slipped through without re_emit=True (regression).
287
+ coinched_fires = [lvl for lvl in sniffer.fires if lvl > 0]
288
+ assert coinched_fires == [], (
289
+ f"on_bid fired with coinche_level>0 ({coinched_fires}) — re-emits "
290
+ "should not invoke on_bid jokers."
291
+ )
@@ -1,44 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import TYPE_CHECKING
4
-
5
- from belote.ansi import RESET, gold_fg, green_fg, move, red_fg, white_fg
6
-
7
- if TYPE_CHECKING:
8
- from ..partner.trust import TrustTrack
9
-
10
-
11
- class TrustBar:
12
- """Visualizes the 0-10 Trust Track."""
13
-
14
- def __init__(self, trust: TrustTrack) -> None:
15
- self.trust = trust
16
-
17
- def render(self) -> None:
18
- """Render trust meter."""
19
- val = self.trust.value
20
- filled = "█" * val
21
- empty = "░" * (10 - val)
22
- # Three-tier color: ≤3 red (danger), 4–6 gold (neutral), ≥7 green
23
- # (healthy). The default trust=5 used to render red under the old
24
- # `> 5` threshold, which falsely signalled distrust at game start.
25
- if val <= 3:
26
- color = red_fg()
27
- elif val >= 7:
28
- color = green_fg()
29
- else:
30
- color = gold_fg()
31
- bar = color + filled + white_fg() + empty + RESET
32
-
33
- if self.trust.ai_degraded:
34
- status = red_fg() + " ⚠ Degraded" + RESET
35
- elif self.trust.auto_capot_available:
36
- status = gold_fg() + " ★ Auto-Capot" + RESET
37
- elif self.trust.shares_void_info:
38
- status = green_fg() + " ✦ Void Info" + RESET
39
- else:
40
- status = ""
41
-
42
- print(
43
- move(4, 2) + white_fg() + "Trust: [" + RESET + bar + white_fg() + "] " + RESET + status
44
- )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes