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.
- {belote_cli-3.3.4 → belote_cli-3.4.0}/CHANGELOG.md +40 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/DEVELOPMENT.md +5 -5
- {belote_cli-3.3.4 → belote_cli-3.4.0}/PKG-INFO +12 -1
- {belote_cli-3.3.4 → belote_cli-3.4.0}/README.md +11 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/pyproject.toml +1 -1
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/__init__.py +1 -1
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/core/run_state.py +12 -1
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/core/scoring.py +4 -1
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/engine/event_bus.py +6 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/engine/round_driver.py +7 -4
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/base.py +13 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/ui/hud.py +138 -10
- {belote_cli-3.3.4 → 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.4 → belote_cli-3.4.0}/src/belote/input.py +8 -1
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/main.py +7 -6
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/ui/prompts.py +2 -1
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_hud_synergy.py +6 -4
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_phase3_meta.py +18 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_round_driver.py +62 -0
- belote_cli-3.3.4/src/belote/belatro/ui/trust_bar.py +0 -44
- {belote_cli-3.3.4 → belote_cli-3.4.0}/.claude/settings.local.json +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/.gitignore +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/.python-version +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/LICENSE +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/scripts/benchmark.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/__init__.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/a11y.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/achievements.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/ai.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/ansi.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/__init__.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/core/__init__.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/core/economy.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/engine/__init__.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/engine/modifier_patch.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/ghost_run.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/__init__.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/__init__.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/annonces.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/coinche.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/contract.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/corrupted.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/economy.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/planets.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/registry.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/tarots.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/items/vouchers.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/main.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/partner/__init__.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/partner/partner_state.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/partner/personality.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/partner/trust.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/progression/__init__.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/progression/save.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/progression/unlocks.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/run/__init__.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/run/ante.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/run/ante_themes.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/run/boss.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/run/decks.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/run/shop.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/run_summary.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/ui/__init__.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/ui/announce.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/ui/collection.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/ui/history.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/ui/menu.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/belatro/ui/rules.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/config.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/context.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/deck.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/game.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/gameflow.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/replay.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/rules.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/scoring.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/stats.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/themes.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/ui/__init__.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/ui/announce.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/ui/layout.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/ui/menu.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/src/belote/ui/render.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/__init__.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/__init__.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_belatro.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_boss_modifiers_integration.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_collection_logic.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_contract_unlocks.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_dead_flag_fixes.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_deck_variants.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_ghost_run.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_history_overlay.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_partner_trust.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_phase0_coverage.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_phase1_plumbing.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_phase2_content.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/belatro/test_progression.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_a11y.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_achievements.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_ai.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_ansi_helpers.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_belote.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_extended.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_game_logic.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_gameflow.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_layout.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_new_coverage.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_official_rules.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_properties.py +0 -0
- {belote_cli-3.3.4 → belote_cli-3.4.0}/tests/test_replay.py +0 -0
- {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 (
|
|
87
|
+
# Full test suite (551 tests expected)
|
|
88
88
|
PYTHONPATH=src pytest
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
Current baseline (3.
|
|
92
|
-
- **mypy**: 0 errors (strict mode,
|
|
91
|
+
Current baseline (3.4.0):
|
|
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,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.
|
|
@@ -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
|
+
)
|
|
@@ -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
|
-
|
|
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
|
-
#
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|