belote-cli 3.3.3__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.
- {belote_cli-3.3.3 → belote_cli-3.4.0}/CHANGELOG.md +65 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/DEVELOPMENT.md +4 -4
- {belote_cli-3.3.3 → belote_cli-3.4.0}/PKG-INFO +18 -4
- {belote_cli-3.3.3 → belote_cli-3.4.0}/README.md +17 -3
- {belote_cli-3.3.3 → belote_cli-3.4.0}/pyproject.toml +1 -1
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/__init__.py +1 -1
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/core/run_state.py +12 -1
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/core/scoring.py +4 -1
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/engine/event_bus.py +6 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/engine/round_driver.py +7 -4
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/base.py +13 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/ui/hud.py +138 -10
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/ui/shop.py +4 -1
- belote_cli-3.4.0/src/belote/belatro/ui/trust_bar.py +69 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/context.py +0 -14
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/gameflow.py +0 -8
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/input.py +8 -6
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/main.py +7 -6
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/ui/__init__.py +1 -4
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/ui/announce.py +0 -32
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/ui/menu.py +0 -5
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/ui/prompts.py +2 -14
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/belatro/test_hud_synergy.py +6 -4
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/belatro/test_phase3_meta.py +18 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/belatro/test_round_driver.py +62 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/test_gameflow.py +0 -1
- belote_cli-3.3.3/src/belote/belatro/ui/trust_bar.py +0 -44
- {belote_cli-3.3.3 → belote_cli-3.4.0}/.claude/settings.local.json +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/.gitignore +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/.python-version +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/LICENSE +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/scripts/benchmark.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/__init__.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/a11y.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/achievements.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/ai.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/ansi.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/__init__.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/core/__init__.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/core/economy.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/engine/__init__.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/engine/modifier_patch.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/ghost_run.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/__init__.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/__init__.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/annonces.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/coinche.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/contract.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/corrupted.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/economy.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/planets.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/registry.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/tarots.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/items/vouchers.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/main.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/partner/__init__.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/partner/partner_state.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/partner/personality.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/partner/trust.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/progression/__init__.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/progression/save.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/progression/unlocks.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/run/__init__.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/run/ante.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/run/ante_themes.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/run/boss.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/run/decks.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/run/shop.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/run_summary.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/ui/__init__.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/ui/announce.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/ui/collection.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/ui/history.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/ui/menu.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/belatro/ui/rules.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/config.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/deck.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/game.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/replay.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/rules.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/scoring.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/stats.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/themes.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/ui/layout.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/src/belote/ui/render.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/__init__.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/belatro/__init__.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/belatro/test_belatro.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/belatro/test_boss_modifiers_integration.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/belatro/test_collection_logic.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/belatro/test_contract_unlocks.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/belatro/test_dead_flag_fixes.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/belatro/test_deck_variants.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/belatro/test_ghost_run.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/belatro/test_history_overlay.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/belatro/test_partner_trust.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/belatro/test_phase0_coverage.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/belatro/test_phase1_plumbing.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/belatro/test_phase2_content.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/belatro/test_progression.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/test_a11y.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/test_achievements.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/test_ai.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/test_ansi_helpers.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/test_belote.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/test_extended.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/test_game_logic.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/test_layout.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/test_new_coverage.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/test_official_rules.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/test_properties.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/test_replay.py +0 -0
- {belote_cli-3.3.3 → belote_cli-3.4.0}/tests/test_undo.py +0 -0
|
@@ -5,6 +5,71 @@ 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
|
+
|
|
48
|
+
## [3.3.4] - 2026-05-10
|
|
49
|
+
|
|
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.
|
|
51
|
+
|
|
52
|
+
### Removed
|
|
53
|
+
|
|
54
|
+
- **`src/belote/ui/announce.py::play_sound`** — terminal-bell helper (writes `\a` bytes for `trick` / `belote` / `declaration` / `chute` / `capot` events). Was called from five sites in `gameflow.py` (post-trick, capot, first-trick declarations, Belote announcement, chute on failed contract); all five call sites are deleted along with the function. BelAtro's `engine/round_driver.py` never imported it, so no BelAtro behaviour changes.
|
|
55
|
+
- **`src/belote/ui/announce.py::is_muted` / `toggle_mute`** — wrappers around `AUDIO.is_muted()` / `AUDIO.toggle_mute()`. Re-exports dropped from `src/belote/ui/__init__.py::__all__`.
|
|
56
|
+
- **`src/belote/context.py::AudioManager` + the `AUDIO` singleton** — process-wide mute state holder. `TerminalContext` and the `TERMINAL` singleton are kept (they back the terminal-size cache used elsewhere).
|
|
57
|
+
- **`src/belote/input.py::Key.MUTE` + the `m` / `b"m"` key bindings** — `M` no longer triggers a special key event in either `_UnixKeyReader` or `_WindowsKeyReader`; it now falls through to `Key.CHAR` like any other letter (Belote has no other meaning for `M`).
|
|
58
|
+
- **Three `case Key.MUTE: toggle_mute()` branches in `src/belote/ui/prompts.py`** (card prompt, bid prompt, rules viewer) — deleted along with the `from .announce import is_muted, toggle_mute` import.
|
|
59
|
+
- **Two `case Key.MUTE: toggle_mute()` branches in `src/belote/ui/menu.py`** (AI config submenu, main menu) — deleted along with the `from .announce import toggle_mute` import.
|
|
60
|
+
- **`[M] Toggle Sound Effects` line from the in-game help screen** (`src/belote/ui/prompts.py::show_help`) — plus the live `(Currently: ON/OFF)` status line that reflected `is_muted()`.
|
|
61
|
+
- **`tests/test_gameflow.py`** — the obsolete `unittest.mock.patch("belote.gameflow.play_sound")` mock inside `test_run_play_8_tricks`'s `ExitStack` is gone; the test still passes (the underlying `display` / `patch_trick_card` / `announce` / `prompt_card` mocks remain).
|
|
62
|
+
|
|
63
|
+
### Internal
|
|
64
|
+
|
|
65
|
+
- **Tests**: still 549 passing.
|
|
66
|
+
- **Strict gates**: pytest 549/549, mypy 0 errors (75 files — `context.py` lost one class but kept the module), ruff 0 violations.
|
|
67
|
+
- **Unused-import sweep**: `green_fg` dropped from `src/belote/ui/prompts.py` imports (only the deleted `sound_status` line used it).
|
|
68
|
+
|
|
69
|
+
### Why drop the bell instead of guarding it on musl
|
|
70
|
+
|
|
71
|
+
`play_sound` only writes BEL (`\a`) bytes to stdout; writing those bytes is just `write(2)` and doesn't itself trigger SIGSYS on any sane libc. Whatever the precise mechanism on Alpine 23 (terminal-driver quirk, blocked downstream ioctl, or musl-specific signal-frame interaction with the existing `signal.signal(SIGINT/SIGTERM)` registration in `main.py:132-133`), the simplest and most robust answer is to stop writing the bell at all. Modern terminal emulators on every tested distro either ignored or visually-flashed the bell — no user-meaningful audio was being produced. The mute toggle exists only to suppress those flashes; with the bell gone, the toggle is dead weight.
|
|
72
|
+
|
|
8
73
|
## [3.3.3] - 2026-05-10
|
|
9
74
|
|
|
10
75
|
Audit-of-audit release — a fresh three-agent codebase pass (classic engine / BelAtro mode / tests + UI) produced ~50 candidate findings. Verification cut that to **3 real fixes** plus **3 net-new invariant test suites** for properties the prior 3.3.x cycles silently relied on. ~14 rejected claims are catalogued at the bottom of this entry so they aren't re-investigated next cycle. 549 tests passing (up from 537), ruff and mypy strict still clean. Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-tingly-barto.md`.
|
|
@@ -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 (
|
|
87
|
+
# Full test suite (551 tests expected)
|
|
88
88
|
PYTHONPATH=src pytest
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
Current baseline (3.
|
|
91
|
+
Current baseline (3.4.0):
|
|
92
92
|
- **mypy**: 0 errors (strict mode, 76 files)
|
|
93
93
|
- **ruff**: 0 violations
|
|
94
|
-
- **pytest**:
|
|
95
|
-
- 3.
|
|
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
|
+
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,22 @@ 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
|
+
|
|
59
|
+
## What's new in 3.3.4
|
|
60
|
+
|
|
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.
|
|
62
|
+
- **Test coverage** — Still 549 tests. Strict gates clean: pytest 549/549, mypy 0 errors, ruff 0 violations.
|
|
63
|
+
|
|
48
64
|
## What's new in 3.3.3
|
|
49
65
|
|
|
50
66
|
- **Determinism** — Boss assignment in BelAtro mode now draws from the run's seeded RNG instead of the module-level `random`. This was the last unseeded RNG site in the round flow (shop and tarots were converted in 3.2.0, AI in 3.3.1, replay analysis in 3.3.2). Same seed now reproduces the same boss on the boss blind.
|
|
@@ -243,7 +259,6 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
|
|
|
243
259
|
|
|
244
260
|
**General:**
|
|
245
261
|
- `?`: Show keyboard shortcut help
|
|
246
|
-
- `M`: Toggle sound effects on/off
|
|
247
262
|
- `I` or `V`: Toggle BelAtro score overlay (per-trick breakdown popup)
|
|
248
263
|
- `Q`: Quit to main menu or exit
|
|
249
264
|
- `H`: View Game History (round-by-round, with contract / taker / tricks / declarations)
|
|
@@ -284,7 +299,6 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
|
|
|
284
299
|
- **Statistics:** unified global tracking of games played, win rates, best rounds, and BelAtro expansion milestones.
|
|
285
300
|
- **Responsive Layout (3 tiers):** Three preset layouts — **compact** (80×32, fits 1366×768), **standard** (96×38), **spacious** (120×48+). The game picks the largest preset that fits your terminal on every render, so resizing mid-game adapts automatically; cards, side columns, and HUD verbosity all scale with the preset. Vertical centering pads tall terminals so the game never clings to the top.
|
|
286
301
|
- **Alternate Screen Buffer:** Both classic Belote and BelAtro run in a dedicated terminal buffer for a clean, non-overlapping interface — your shell scrollback stays untouched after you quit.
|
|
287
|
-
- **Sound Effects:** Enhanced auditory feedback for trick wins, Belote, and Capot, with a built-in mute toggle.
|
|
288
302
|
- **Declarations:** Automatic detection and announcement of sequences (Tierce, Quarte, etc.) and Carrés after the first trick.
|
|
289
303
|
- **Live HUD:** Real-time round scoring displays points won during the current round, with a smooth "rolling" numerical animation for total scores.
|
|
290
304
|
- **High Fidelity:** Implementation of French Belote rules according to the [official rules of the Fédération Française de Belote](https://www.ffbelote.org/regles-officielle-belote/), including a two-round bidding system, "Dix de Der", "Capot" (252 pts), and "Litige" (tie-break). All six contracts are bidable in round 2: the four card suits, **Tout Atout** (every suit acts as trump within its own led-suit group; press `a`), and **Sans Atout** (no trump, lead-suit highest wins; press `s`).
|
|
@@ -316,7 +330,7 @@ belote/
|
|
|
316
330
|
│ ├── scoring.py # Declarations, round scoring, capot
|
|
317
331
|
│ ├── ai.py # Three-tier AI (easy/medium/hard)
|
|
318
332
|
│ ├── config.py # Global configuration and timings
|
|
319
|
-
│ ├── context.py # Global managers (
|
|
333
|
+
│ ├── context.py # Global managers (Terminal)
|
|
320
334
|
│ ├── themes.py # Color theme management
|
|
321
335
|
│ ├── ui/ # Modular UI package
|
|
322
336
|
│ ├── ansi.py # ANSI escape helpers (colors, cursor)
|
|
@@ -2,6 +2,22 @@
|
|
|
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
|
+
|
|
16
|
+
## What's new in 3.3.4
|
|
17
|
+
|
|
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.
|
|
19
|
+
- **Test coverage** — Still 549 tests. Strict gates clean: pytest 549/549, mypy 0 errors, ruff 0 violations.
|
|
20
|
+
|
|
5
21
|
## What's new in 3.3.3
|
|
6
22
|
|
|
7
23
|
- **Determinism** — Boss assignment in BelAtro mode now draws from the run's seeded RNG instead of the module-level `random`. This was the last unseeded RNG site in the round flow (shop and tarots were converted in 3.2.0, AI in 3.3.1, replay analysis in 3.3.2). Same seed now reproduces the same boss on the boss blind.
|
|
@@ -200,7 +216,6 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
|
|
|
200
216
|
|
|
201
217
|
**General:**
|
|
202
218
|
- `?`: Show keyboard shortcut help
|
|
203
|
-
- `M`: Toggle sound effects on/off
|
|
204
219
|
- `I` or `V`: Toggle BelAtro score overlay (per-trick breakdown popup)
|
|
205
220
|
- `Q`: Quit to main menu or exit
|
|
206
221
|
- `H`: View Game History (round-by-round, with contract / taker / tricks / declarations)
|
|
@@ -241,7 +256,6 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
|
|
|
241
256
|
- **Statistics:** unified global tracking of games played, win rates, best rounds, and BelAtro expansion milestones.
|
|
242
257
|
- **Responsive Layout (3 tiers):** Three preset layouts — **compact** (80×32, fits 1366×768), **standard** (96×38), **spacious** (120×48+). The game picks the largest preset that fits your terminal on every render, so resizing mid-game adapts automatically; cards, side columns, and HUD verbosity all scale with the preset. Vertical centering pads tall terminals so the game never clings to the top.
|
|
243
258
|
- **Alternate Screen Buffer:** Both classic Belote and BelAtro run in a dedicated terminal buffer for a clean, non-overlapping interface — your shell scrollback stays untouched after you quit.
|
|
244
|
-
- **Sound Effects:** Enhanced auditory feedback for trick wins, Belote, and Capot, with a built-in mute toggle.
|
|
245
259
|
- **Declarations:** Automatic detection and announcement of sequences (Tierce, Quarte, etc.) and Carrés after the first trick.
|
|
246
260
|
- **Live HUD:** Real-time round scoring displays points won during the current round, with a smooth "rolling" numerical animation for total scores.
|
|
247
261
|
- **High Fidelity:** Implementation of French Belote rules according to the [official rules of the Fédération Française de Belote](https://www.ffbelote.org/regles-officielle-belote/), including a two-round bidding system, "Dix de Der", "Capot" (252 pts), and "Litige" (tie-break). All six contracts are bidable in round 2: the four card suits, **Tout Atout** (every suit acts as trump within its own led-suit group; press `a`), and **Sans Atout** (no trump, lead-suit highest wins; press `s`).
|
|
@@ -273,7 +287,7 @@ belote/
|
|
|
273
287
|
│ ├── scoring.py # Declarations, round scoring, capot
|
|
274
288
|
│ ├── ai.py # Three-tier AI (easy/medium/hard)
|
|
275
289
|
│ ├── config.py # Global configuration and timings
|
|
276
|
-
│ ├── context.py # Global managers (
|
|
290
|
+
│ ├── context.py # Global managers (Terminal)
|
|
277
291
|
│ ├── themes.py # Color theme management
|
|
278
292
|
│ ├── ui/ # Modular UI package
|
|
279
293
|
│ ├── ansi.py # ANSI escape helpers (colors, cursor)
|
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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
|
|
51
|
-
seen.add(
|
|
52
|
-
seen.add(
|
|
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:
|
|
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
|
-
|
|
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
|
+
)
|