belote-cli 4.7.2__tar.gz → 4.7.3__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-4.7.2 → belote_cli-4.7.3}/CHANGELOG.md +87 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/DEVELOPMENT.md +1 -1
- {belote_cli-4.7.2 → belote_cli-4.7.3}/PKG-INFO +4 -4
- {belote_cli-4.7.2 → belote_cli-4.7.3}/README.md +3 -3
- {belote_cli-4.7.2 → belote_cli-4.7.3}/pyproject.toml +1 -1
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/__init__.py +1 -1
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ai.py +5 -3
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/core/run_state.py +10 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/core/scoring.py +11 -1
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/jokers/corrupted.py +7 -1
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/hud.py +84 -26
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ui/announce.py +12 -5
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ui/render.py +8 -2
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_decks_4_5.py +34 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_hud_toggle.py +49 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_joker_contracts.py +23 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_render_diff.py +31 -0
- belote_cli-4.7.2/.antigravitycli/69dd05ce-2c1c-4419-8755-e4dd0d4495e8.json +0 -1
- {belote_cli-4.7.2 → belote_cli-4.7.3}/.claude/settings.local.json +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/.gitignore +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/.python-version +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/LICENSE +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/scripts/benchmark.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/__init__.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/a11y.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/achievements.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ansi.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/__init__.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/core/__init__.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/core/economy.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/core/round_ledger.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/engine/__init__.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/engine/event_bus.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/engine/modifier_patch.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/engine/round_driver.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ghost_run.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/__init__.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/base.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/jokers/__init__.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/jokers/annonces.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/jokers/coinche.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/jokers/contract.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/jokers/economy.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/planets.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/registry.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/tarots.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/vouchers.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/main.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/partner/__init__.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/partner/partner_state.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/partner/personality.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/partner/trust.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/progression/__init__.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/progression/save.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/progression/unlocks.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/run/__init__.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/run/ante.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/run/ante_themes.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/run/boss.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/run/decks.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/run/shop.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/run_summary.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/__init__.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/announce.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/collection.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/consumables.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/history.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/inventory.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/menu.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/rules.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/shop.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/trust_bar.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/config.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/context.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/deck.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/game.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/gameflow.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/input.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/main.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/replay.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/rules.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/scoring.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/stats.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/themes.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ui/__init__.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ui/fit_guard.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ui/layout.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ui/menu.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ui/prompts.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/__init__.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/__init__.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_belatro.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_boss_contracts.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_boss_modifiers_integration.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_collection_logic.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_consumables_ui.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_contract_unlocks.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_dead_flag_fixes.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_deck_variants.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_endless.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_event_bus.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_ghost_run.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_heist.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_history_overlay.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_hud_synergy.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_inventory_overlay.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_jokers_4_5.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_partner_jokers.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_partner_trust.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_phase0_coverage.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_phase1_plumbing.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_phase2_content.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_phase3_meta.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_progression.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_round_driver.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_run_summary.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_shop_empty_pools.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_slot_machine_tally.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_voucher_idempotency.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/perf_baselines.json +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_a11y.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_achievements.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_ai.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_alt_screen_scroll.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_announce_stats.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_ansi_helpers.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_belote.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_benchmark_smoke.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_bidding_all_pass.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_declaration_tiebreak.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_extended.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_game_logic.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_gameflow.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_hand_auto_sort.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_history_overlay_cache.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_input_eof.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_input_wasd.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_invariants.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_layout.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_new_coverage.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_no_color.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_official_rules.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_perf_regression.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_properties.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_render_felt_polish.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_replay.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_undo.py +0 -0
- {belote_cli-4.7.2 → belote_cli-4.7.3}/uv.lock +0 -0
|
@@ -5,6 +5,93 @@ 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
|
+
## [4.7.3] - 2026-05-21
|
|
9
|
+
|
|
10
|
+
Patch release: targeted bug-hunt + performance + code-logic audit. Three
|
|
11
|
+
parallel exploration passes (classic engine, BelAtro layer, UI/render)
|
|
12
|
+
surfaced ~30 candidate findings; verifying each against the live code
|
|
13
|
+
rejected the false positives (deck.py "deluge scores 0", LePasseur "missing
|
|
14
|
+
re_emit guard", show_rules "missing invalidate on scroll", show_history
|
|
15
|
+
"missing term_h in cache key", legal_cards "missing trick_rank hoist") and
|
|
16
|
+
shipped only the verified-true delta. All baselines green: `ruff` 0
|
|
17
|
+
violations, `mypy --strict` 0 errors, `pytest` 1007/1007.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **(Bug) `announce()` did not invalidate the render-diff baseline.** The
|
|
22
|
+
function paints a transient banner with absolute cursor positioning,
|
|
23
|
+
bypassing `display()`. Without a post-paint `invalidate_diff()` the next
|
|
24
|
+
`display()` diffed against `_last_emitted_lines` (which has no record of
|
|
25
|
+
the banner) and could leave the banner visible as a ghost on the bottom
|
|
26
|
+
row. Same architectural rule as `show_help` / `show_history` /
|
|
27
|
+
`show_rules` / `show_card_detail` / `show_round_summary` /
|
|
28
|
+
`animate_score_update`. `announce()` was the last unfixed site of the
|
|
29
|
+
4.0.0 / 4.6.4 finally-pattern sweep. Pinned by
|
|
30
|
+
`test_announce_invalidates_diff_baseline` in `tests/test_render_diff.py`.
|
|
31
|
+
- **(Bug) L'Infiltré × La Déluge interaction.** The `ghost_lead` deck rule
|
|
32
|
+
paid `+2 Mult / +$1` when NS won a trick by playing trump on a "non-trump
|
|
33
|
+
lead". The is-trump-lead check at `belatro/core/scoring.py:414-417`
|
|
34
|
+
considered only `lead_suit == event.trump` and the TOUT_ATOUT case —
|
|
35
|
+
it did not honour `seven_eight_trump` (La Déluge), so a 7-led or 8-led
|
|
36
|
+
trick was incorrectly treated as a non-trump lead and the bonus could
|
|
37
|
+
fire even though the lead was effectively trump. Pinned by
|
|
38
|
+
`test_ghost_lead_silent_when_lead_is_seven_under_deluge` in
|
|
39
|
+
`tests/belatro/test_decks_4_5.py`.
|
|
40
|
+
- **(Defensive) `LeDemon.on_purchase` is now idempotent.** Re-running the
|
|
41
|
+
hook on an already-owned joker (a future save/load round-trip or replay-
|
|
42
|
+
resume tool) would have compounded the trust subtraction. New
|
|
43
|
+
`_applied_purchase_ids: set[str]` field on `BelAtroRun` (mirrors
|
|
44
|
+
`_applied_voucher_ids` from 3.9.3) short-circuits the second call. Pinned
|
|
45
|
+
by `test_le_demon_on_purchase_is_idempotent` in
|
|
46
|
+
`tests/belatro/test_joker_contracts.py`.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- **AI comment about La Déluge corrected** (`src/belote/ai.py:293-296`).
|
|
51
|
+
The pre-4.7.3 comment claimed "promotes 7s/8s of trump above the Jack" —
|
|
52
|
+
but `deck.py::trick_rank` puts the 7 at rank 8 and the 8 at rank 9
|
|
53
|
+
(the two LOWEST trumps, scoring 0). The boss description in
|
|
54
|
+
`boss.py:95` ("become trump") matches the code, not the comment. The
|
|
55
|
+
comment now states the actual behaviour so future maintainers don't
|
|
56
|
+
chase a phantom bug.
|
|
57
|
+
- **`render_joker_pip_strip` / `render_synergy_tooltip` split into
|
|
58
|
+
builders + writers** (`belatro/ui/hud.py`). Pre-4.7.3 each helper did
|
|
59
|
+
its own `sys.stdout.write + flush` outside `BelAtroHUD._render`'s
|
|
60
|
+
batched parts list, costing 2–3 syscalls per HUD refresh instead of
|
|
61
|
+
one. New `build_joker_pip_strip` / `build_synergy_tooltip` return
|
|
62
|
+
strings; the legacy `render_*` wrappers (used by direct callers and
|
|
63
|
+
tests) still exist for backward compatibility. `BelAtroHUD.render`
|
|
64
|
+
and `_render_compact` now embed both builders into a single batched
|
|
65
|
+
write. Pinned by `test_belatro_hud_render_writes_once` in
|
|
66
|
+
`tests/belatro/test_hud_toggle.py`.
|
|
67
|
+
|
|
68
|
+
### Performance
|
|
69
|
+
|
|
70
|
+
- **`_get_card_face` reads `_cached_theme_name` instead of
|
|
71
|
+
`theme_manager.current_name`** (`belote/ui/render.py:314`). The module
|
|
72
|
+
already caches the theme name (line 84) and refreshes it via the theme
|
|
73
|
+
callback (line 95); `_get_card_face` is called ~52 times per game
|
|
74
|
+
frame, so eliminating the per-call property lookup shaves a few
|
|
75
|
+
microseconds off the render budget. The cache is kept in sync via the
|
|
76
|
+
existing theme-change callback. `clear_card_cache` ensures the card
|
|
77
|
+
face cache is reset on every theme change, so reading the cached name
|
|
78
|
+
is safe.
|
|
79
|
+
|
|
80
|
+
### Internal
|
|
81
|
+
|
|
82
|
+
- New `BelAtroRun._applied_purchase_ids: set[str]` field for joker
|
|
83
|
+
on_purchase idempotency (analogous to `_applied_voucher_ids`). Empty
|
|
84
|
+
by default; populated lazily by corrupted jokers with non-idempotent
|
|
85
|
+
on_purchase actions.
|
|
86
|
+
- `build_joker_pip_strip(...) -> str` / `build_synergy_tooltip(...) -> str`
|
|
87
|
+
public builder API in `belatro/ui/hud.py`; `render_joker_pip_strip` /
|
|
88
|
+
`render_synergy_tooltip` retained as thin wrappers around the builders.
|
|
89
|
+
|
|
90
|
+
### Test count baseline
|
|
91
|
+
|
|
92
|
+
- 1007 (4.7.2 had 1003; +4 in 4.7.3 across `test_render_diff.py`,
|
|
93
|
+
`test_decks_4_5.py`, `test_joker_contracts.py`, `test_hud_toggle.py`).
|
|
94
|
+
|
|
8
95
|
## [4.7.2] - 2026-05-20
|
|
9
96
|
|
|
10
97
|
Patch release: external-model audit verification pass. A prior audit (pasted
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: belote-cli
|
|
3
|
-
Version: 4.7.
|
|
3
|
+
Version: 4.7.3
|
|
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
|
|
@@ -263,7 +263,7 @@ belote/
|
|
|
263
263
|
│ ├── input.py # Platform-dispatched key reader and interruptible sleep
|
|
264
264
|
│ ├── stats.py # Global and session statistics tracking
|
|
265
265
|
│ └── rules.py # Game rules content
|
|
266
|
-
├── tests/ # Comprehensive test suite (
|
|
266
|
+
├── tests/ # Comprehensive test suite (1007 tests)
|
|
267
267
|
├── scripts/ # Performance benchmarks
|
|
268
268
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
269
269
|
├── LICENSE # MIT License
|
|
@@ -278,14 +278,14 @@ belote/
|
|
|
278
278
|
PYTHONPATH=src pytest
|
|
279
279
|
```
|
|
280
280
|
|
|
281
|
-
Currently **
|
|
281
|
+
Currently **1007 tests** passing with 100% coverage on game-logic modules (4.7.3).
|
|
282
282
|
|
|
283
283
|
## Technical Integrity
|
|
284
284
|
|
|
285
285
|
The codebase is strictly validated with the following tools:
|
|
286
286
|
- **mypy**: 0 errors (strict type safety)
|
|
287
287
|
- **ruff**: 0 violations (linting & formatting)
|
|
288
|
-
- **pytest**:
|
|
288
|
+
- **pytest**: 1007/1007 passed
|
|
289
289
|
- **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
|
|
290
290
|
- **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
|
|
291
291
|
|
|
@@ -220,7 +220,7 @@ belote/
|
|
|
220
220
|
│ ├── input.py # Platform-dispatched key reader and interruptible sleep
|
|
221
221
|
│ ├── stats.py # Global and session statistics tracking
|
|
222
222
|
│ └── rules.py # Game rules content
|
|
223
|
-
├── tests/ # Comprehensive test suite (
|
|
223
|
+
├── tests/ # Comprehensive test suite (1007 tests)
|
|
224
224
|
├── scripts/ # Performance benchmarks
|
|
225
225
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
226
226
|
├── LICENSE # MIT License
|
|
@@ -235,14 +235,14 @@ belote/
|
|
|
235
235
|
PYTHONPATH=src pytest
|
|
236
236
|
```
|
|
237
237
|
|
|
238
|
-
Currently **
|
|
238
|
+
Currently **1007 tests** passing with 100% coverage on game-logic modules (4.7.3).
|
|
239
239
|
|
|
240
240
|
## Technical Integrity
|
|
241
241
|
|
|
242
242
|
The codebase is strictly validated with the following tools:
|
|
243
243
|
- **mypy**: 0 errors (strict type safety)
|
|
244
244
|
- **ruff**: 0 violations (linting & formatting)
|
|
245
|
-
- **pytest**:
|
|
245
|
+
- **pytest**: 1007/1007 passed
|
|
246
246
|
- **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
|
|
247
247
|
- **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
|
|
248
248
|
|
|
@@ -290,9 +290,11 @@ class AIPlayer:
|
|
|
290
290
|
"""Decide which card to play."""
|
|
291
291
|
hand = state.hand_of(self.seat)
|
|
292
292
|
legal = legal_cards(state, self.seat)
|
|
293
|
-
# La Déluge boss
|
|
294
|
-
#
|
|
295
|
-
#
|
|
293
|
+
# La Déluge boss makes 7s and 8s of any suit rank as trump (the two
|
|
294
|
+
# LOWEST trumps — 7 at rank 8, 8 at rank 9, both scoring 0 points;
|
|
295
|
+
# see deck.py::trick_rank and ::card_points). Every ranking / point
|
|
296
|
+
# read in this method must thread `_se` through or the AI under-
|
|
297
|
+
# values trump cards that should beat them.
|
|
296
298
|
self._se = state.boss_modifiers.seven_eight_trump
|
|
297
299
|
|
|
298
300
|
# Boss: L'Agent Double (Partner sabotages on 3 random tricks)
|
|
@@ -104,6 +104,16 @@ class BelAtroRun:
|
|
|
104
104
|
# future save/load round-trip — gets the same protection automatically.
|
|
105
105
|
_applied_voucher_ids: set[str] = field(default_factory=set)
|
|
106
106
|
|
|
107
|
+
# ── Idempotency guard for Joker.on_purchase() ──────────
|
|
108
|
+
# Corrupted jokers with non-idempotent on_purchase actions (LeDemon's
|
|
109
|
+
# trust subtraction) record their id here on first apply. Re-application
|
|
110
|
+
# paths (a future save/load round-trip, replay tooling) consult this set
|
|
111
|
+
# and short-circuit so a once-paid cost never compounds. Joker on_purchase
|
|
112
|
+
# actions that are already idempotent (boolean flags like
|
|
113
|
+
# LeTraitre.partner_throws_trick, LAgentDouble.agent_double_joker) don't
|
|
114
|
+
# need to consult this set, but adding their id is harmless. 4.7.3.
|
|
115
|
+
_applied_purchase_ids: set[str] = field(default_factory=set)
|
|
116
|
+
|
|
107
117
|
# ── Recent-boss tracker (3.9.3 Phase 5) ────────────────
|
|
108
118
|
# Used by the BelAtro main loop to suppress immediate boss repeats in
|
|
109
119
|
# endless mode. The deque holds at most 2 recent boss ids; the selector
|
|
@@ -407,13 +407,23 @@ class ScoreAccumulator:
|
|
|
407
407
|
# of lead (legal_cards forbids trumping while holding lead).
|
|
408
408
|
# +2 Mult, +$1.
|
|
409
409
|
if joker_state.get("ghost_lead") and event.trump is not None:
|
|
410
|
-
|
|
410
|
+
lead_card = event.cards[0] if event.cards else None
|
|
411
|
+
lead_suit = lead_card.suit if lead_card else None
|
|
411
412
|
# Under TOUT_ATOUT every card is trump, so no play can be
|
|
412
413
|
# "void of the led suit" — is_trump_lead resolves to True
|
|
413
414
|
# and the bonus is correctly gated off.
|
|
415
|
+
# Under La Déluge (seven_eight_trump), a 7 or 8 of any
|
|
416
|
+
# suit also functions as trump — a 7-led trick is a
|
|
417
|
+
# trump-led trick even when lead_suit != event.trump.
|
|
418
|
+
se_lead = (
|
|
419
|
+
state.boss_modifiers.seven_eight_trump
|
|
420
|
+
and lead_card is not None
|
|
421
|
+
and lead_card.rank in (Rank.SEVEN, Rank.EIGHT)
|
|
422
|
+
)
|
|
414
423
|
is_trump_lead = (
|
|
415
424
|
lead_suit == event.trump
|
|
416
425
|
or event.trump == Suit.TOUT_ATOUT
|
|
426
|
+
or se_lead
|
|
417
427
|
)
|
|
418
428
|
if lead_suit is not None and not is_trump_lead:
|
|
419
429
|
seat = event.leader_seat
|
|
@@ -40,7 +40,13 @@ class LeDemon(Joker):
|
|
|
40
40
|
is_corrupted = True
|
|
41
41
|
|
|
42
42
|
def on_purchase(self, run: BelAtroRun) -> None:
|
|
43
|
-
# Degrade trust by 3, making partner play worse
|
|
43
|
+
# Degrade trust by 3, making partner play worse. 4.7.3: idempotency
|
|
44
|
+
# guard — without it, a save/load round-trip or replay-resume tool
|
|
45
|
+
# that re-runs on_purchase on already-owned jokers would compound
|
|
46
|
+
# the cost. Voucher.apply() solved the same problem in 3.9.3.
|
|
47
|
+
if self.id in run._applied_purchase_ids:
|
|
48
|
+
return
|
|
49
|
+
run._applied_purchase_ids.add(self.id)
|
|
44
50
|
run.partner.trust.value = max(0, run.partner.trust.value - 3)
|
|
45
51
|
|
|
46
52
|
def on_trick_won(self, event: TrickWonEvent, state: dict[str, Any]) -> JokerResult | None:
|
|
@@ -160,15 +160,20 @@ class BelAtroHUD:
|
|
|
160
160
|
# 3.4.0: joker pip strip on row 1 (above the existing HUD lines), shown
|
|
161
161
|
# in every layout including compact. Cheap — empty inventory still
|
|
162
162
|
# paints the dotted-slot capacity so the player learns the slot count.
|
|
163
|
+
#
|
|
164
|
+
# 4.7.3: the strip + tooltip are BUILT into strings here and embedded
|
|
165
|
+
# in the same write as the rest of the HUD. Pre-4.7.3 each helper
|
|
166
|
+
# did its own write+flush, so the HUD render syscalled 2–3 times
|
|
167
|
+
# instead of once. Compact path mirrors this (see `_render_compact`).
|
|
168
|
+
pip_strip = ""
|
|
169
|
+
synergy_tip = ""
|
|
163
170
|
if not state.boss_modifiers.hide_hud:
|
|
164
|
-
|
|
165
|
-
# Synergy tooltip below the score line; only fires when at least
|
|
166
|
-
# one pair is active. Compact layouts get one line; verbose two.
|
|
171
|
+
pip_strip = build_joker_pip_strip(run, term_w, row=1)
|
|
167
172
|
tooltip_row = 4 if layout.hud_style == "compact" else 5
|
|
168
|
-
|
|
173
|
+
synergy_tip = build_synergy_tooltip(list(run.jokers), term_w, row=tooltip_row)
|
|
169
174
|
|
|
170
175
|
if layout.hud_style == "compact":
|
|
171
|
-
self._render_compact(acc, state, term_w)
|
|
176
|
+
self._render_compact(acc, state, term_w, pip_strip, synergy_tip)
|
|
172
177
|
return
|
|
173
178
|
|
|
174
179
|
# Standard / verbose path (current behaviour, with a small mood glyph
|
|
@@ -176,7 +181,12 @@ class BelAtroHUD:
|
|
|
176
181
|
# write+flush so the BelAtro HUD lays down all rows in one syscall.
|
|
177
182
|
target_str = str(run.target_score)
|
|
178
183
|
mood = _MOOD_GLYPH.get(run.partner_mood, "○")
|
|
179
|
-
|
|
184
|
+
# 4.7.3: prepend the row-1 pip strip + the row-{4|5} synergy tooltip
|
|
185
|
+
# so the whole HUD ships in one write+flush below.
|
|
186
|
+
parts: list[str] = []
|
|
187
|
+
if pip_strip:
|
|
188
|
+
parts.append(pip_strip)
|
|
189
|
+
parts.append(
|
|
180
190
|
move(2, 2)
|
|
181
191
|
+ white_fg()
|
|
182
192
|
+ "Ante: "
|
|
@@ -204,7 +214,7 @@ class BelAtroHUD:
|
|
|
204
214
|
+ f"Partner: {mood}"
|
|
205
215
|
+ RESET
|
|
206
216
|
+ "\n"
|
|
207
|
-
|
|
217
|
+
)
|
|
208
218
|
|
|
209
219
|
# Row 3: Score (hidden by Le Brouillard boss). Also suppressed under
|
|
210
220
|
# La Compétition (`separate_scoring`) because the live running total
|
|
@@ -252,13 +262,27 @@ class BelAtroHUD:
|
|
|
252
262
|
# today but defensive) wouldn't paint either.
|
|
253
263
|
_emit_tally_readout(parts, state, term_w, term_h)
|
|
254
264
|
|
|
265
|
+
# 4.7.3: tooltip ships in the same batched write as everything else.
|
|
266
|
+
if synergy_tip:
|
|
267
|
+
parts.append(synergy_tip)
|
|
268
|
+
|
|
255
269
|
sys.stdout.write("".join(parts))
|
|
256
270
|
sys.stdout.flush()
|
|
257
271
|
|
|
258
|
-
def _render_compact(
|
|
272
|
+
def _render_compact(
|
|
273
|
+
self,
|
|
274
|
+
acc: ScoreAccumulator,
|
|
275
|
+
state: GameState,
|
|
276
|
+
term_w: int,
|
|
277
|
+
pip_strip: str = "",
|
|
278
|
+
synergy_tip: str = "",
|
|
279
|
+
) -> None:
|
|
259
280
|
"""Compact HUD: single-line summary, joker count instead of names.
|
|
260
281
|
|
|
261
282
|
Press J for the full joker list (handled by the gameplay loop, not here).
|
|
283
|
+
|
|
284
|
+
4.7.3: `pip_strip` / `synergy_tip` are pre-built by the caller so the
|
|
285
|
+
compact HUD also ships in a single write+flush.
|
|
262
286
|
"""
|
|
263
287
|
run = self.run
|
|
264
288
|
mood = _MOOD_GLYPH.get(run.partner_mood, "○")
|
|
@@ -277,11 +301,16 @@ class BelAtroHUD:
|
|
|
277
301
|
|
|
278
302
|
# Compose both halves on row 2. 3.9.3: batched into a single
|
|
279
303
|
# write/flush so the compact HUD lays down all rows atomically.
|
|
304
|
+
# 4.7.3: prepend the pip strip so the row-1/row-2 strip + summary
|
|
305
|
+
# ship in the same write.
|
|
280
306
|
right_col = max(2, term_w - visible_len(joker_label) - 1)
|
|
281
|
-
parts: list[str] = [
|
|
307
|
+
parts: list[str] = []
|
|
308
|
+
if pip_strip:
|
|
309
|
+
parts.append(pip_strip)
|
|
310
|
+
parts.extend([
|
|
282
311
|
move(2, 2) + left + "\n",
|
|
283
312
|
move(2, right_col) + joker_label + "\n",
|
|
284
|
-
]
|
|
313
|
+
])
|
|
285
314
|
|
|
286
315
|
# Row 3: chips × mult on the right (hidden by Le Brouillard, also
|
|
287
316
|
# suppressed under La Compétition since the running total diverges
|
|
@@ -308,6 +337,10 @@ class BelAtroHUD:
|
|
|
308
337
|
_, term_h = _gt()
|
|
309
338
|
_emit_tally_readout(parts, state, term_w, term_h)
|
|
310
339
|
|
|
340
|
+
# 4.7.3: synergy tooltip ships in the same batched write.
|
|
341
|
+
if synergy_tip:
|
|
342
|
+
parts.append(synergy_tip)
|
|
343
|
+
|
|
311
344
|
sys.stdout.write("".join(parts))
|
|
312
345
|
sys.stdout.flush()
|
|
313
346
|
|
|
@@ -339,21 +372,22 @@ def _edition_color(ed_value: str) -> str:
|
|
|
339
372
|
return str(white_fg())
|
|
340
373
|
|
|
341
374
|
|
|
342
|
-
def
|
|
343
|
-
"""
|
|
375
|
+
def build_joker_pip_strip(run: BelAtroRun, term_w: int, row: int = 1) -> str:
|
|
376
|
+
"""Build the joker pip strip as a string (caller decides when to write).
|
|
344
377
|
|
|
345
378
|
Layout: `J: [Co][To*][..][..][..]` — 4 chars per slot, leading "J: " label,
|
|
346
379
|
`*` marker on slots involved in an active synergy pair. Empty slots are
|
|
347
380
|
rendered with `··` so the player sees their capacity at a glance.
|
|
348
381
|
|
|
349
|
-
|
|
382
|
+
Returns "" when the strip should be suppressed (HUD toggled off, or
|
|
383
|
+
`term_w < 24` — not enough room for a 5-slot strip).
|
|
350
384
|
"""
|
|
351
385
|
from .announce import is_top_hud_visible
|
|
352
386
|
|
|
353
387
|
if not is_top_hud_visible():
|
|
354
|
-
return
|
|
388
|
+
return ""
|
|
355
389
|
if term_w < 24:
|
|
356
|
-
return
|
|
390
|
+
return ""
|
|
357
391
|
slots = max(1, run.joker_slots)
|
|
358
392
|
jokers = list(run.jokers)
|
|
359
393
|
# Detect which joker ids are in an active synergy so we can mark their pips
|
|
@@ -379,28 +413,40 @@ def render_joker_pip_strip(run: BelAtroRun, term_w: int, row: int = 1) -> None:
|
|
|
379
413
|
)
|
|
380
414
|
else:
|
|
381
415
|
parts.append(f"{DIM}[··]{RESET}")
|
|
382
|
-
strip = "".join(parts)
|
|
383
416
|
# Center is overkill; anchor at col 2 so it doesn't fight the score line
|
|
384
|
-
# on the right of row 2.
|
|
385
|
-
|
|
417
|
+
# on the right of row 2.
|
|
418
|
+
strip: str = move(row, 2) + "".join(parts) + "\n"
|
|
419
|
+
return strip
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def render_joker_pip_strip(run: BelAtroRun, term_w: int, row: int = 1) -> None:
|
|
423
|
+
"""Standalone writer for callers/tests that paint the pip strip directly.
|
|
424
|
+
|
|
425
|
+
BelAtroHUD's main render path embeds `build_joker_pip_strip` output in
|
|
426
|
+
its single batched write (4.7.3) — call this only when there's no
|
|
427
|
+
surrounding `parts` list to compose into.
|
|
428
|
+
"""
|
|
429
|
+
strip = build_joker_pip_strip(run, term_w, row=row)
|
|
430
|
+
if not strip:
|
|
431
|
+
return
|
|
432
|
+
sys.stdout.write(strip)
|
|
386
433
|
sys.stdout.flush()
|
|
387
434
|
|
|
388
435
|
|
|
389
|
-
def
|
|
390
|
-
"""
|
|
436
|
+
def build_synergy_tooltip(jokers: Sequence[object], term_w: int, row: int = 5) -> str:
|
|
437
|
+
"""Build synergy tooltip lines as a single string (caller decides when to write).
|
|
391
438
|
|
|
392
|
-
|
|
393
|
-
|
|
439
|
+
Returns "" when there are no active synergies, the HUD is hidden, or the
|
|
440
|
+
tooltip should otherwise be suppressed.
|
|
394
441
|
"""
|
|
395
442
|
from .announce import is_top_hud_visible
|
|
396
443
|
|
|
397
444
|
if not is_top_hud_visible():
|
|
398
|
-
return
|
|
445
|
+
return ""
|
|
399
446
|
pairs = detect_synergies_full(list(jokers))
|
|
400
447
|
if not pairs:
|
|
401
|
-
return
|
|
448
|
+
return ""
|
|
402
449
|
# Show up to two synergies; further ones are summarised as "+N more".
|
|
403
|
-
# 3.9.3: batched single write/flush.
|
|
404
450
|
max_w = max(20, term_w - 4)
|
|
405
451
|
out: list[str] = []
|
|
406
452
|
for i, (_a, _b, desc) in enumerate(pairs[:2]):
|
|
@@ -413,5 +459,17 @@ def render_synergy_tooltip(jokers: Sequence[object], term_w: int, row: int = 5)
|
|
|
413
459
|
if len(pairs) > 2:
|
|
414
460
|
extra = f"{DIM}+{len(pairs) - 2} more synergies{RESET}"
|
|
415
461
|
out.append(move(row + 2, 2) + extra + "\n")
|
|
416
|
-
|
|
462
|
+
return "".join(out)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def render_synergy_tooltip(jokers: Sequence[object], term_w: int, row: int = 5) -> None:
|
|
466
|
+
"""Standalone writer for callers/tests that paint the tooltip directly.
|
|
467
|
+
|
|
468
|
+
BelAtroHUD's main render path embeds `build_synergy_tooltip` output in
|
|
469
|
+
its single batched write (4.7.3); use this only for direct callers.
|
|
470
|
+
"""
|
|
471
|
+
out = build_synergy_tooltip(jokers, term_w, row=row)
|
|
472
|
+
if not out:
|
|
473
|
+
return
|
|
474
|
+
sys.stdout.write(out)
|
|
417
475
|
sys.stdout.flush()
|
|
@@ -48,11 +48,18 @@ def announce(
|
|
|
48
48
|
)
|
|
49
49
|
sys.stdout.write(move(max(1, term_h - 1), 1) + clear_line() + banner)
|
|
50
50
|
sys.stdout.flush()
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
# The banner is painted with absolute positioning, bypassing display()'s
|
|
52
|
+
# diff cache. The next display() would diff against the pre-banner cached
|
|
53
|
+
# frame and could skip repainting the banner row, leaving the message as a
|
|
54
|
+
# ghost. Mirrors the finally pattern in animate_score_update (4.6.4).
|
|
55
|
+
try:
|
|
56
|
+
if reader and duration > 0:
|
|
57
|
+
return interruptible_sleep(duration, reader)
|
|
58
|
+
if duration > 0:
|
|
59
|
+
time.sleep(duration)
|
|
60
|
+
return None
|
|
61
|
+
finally:
|
|
62
|
+
invalidate_diff()
|
|
56
63
|
|
|
57
64
|
|
|
58
65
|
def show_round_summary(
|
|
@@ -306,12 +306,18 @@ def _get_card_face(
|
|
|
306
306
|
legal: bool = True,
|
|
307
307
|
layout: LayoutPreset = STANDARD,
|
|
308
308
|
) -> list[str]:
|
|
309
|
-
"""Helper to call cached _card_face with current global state.
|
|
309
|
+
"""Helper to call cached _card_face with current global state.
|
|
310
|
+
|
|
311
|
+
Uses the module-local `_cached_theme_name` (kept in sync by the theme
|
|
312
|
+
callback) instead of the live `theme_manager.current_name` property to
|
|
313
|
+
skip a property lookup + attribute dereference per card render
|
|
314
|
+
(~52 calls per game frame).
|
|
315
|
+
"""
|
|
310
316
|
return _card_face_internal(
|
|
311
317
|
card,
|
|
312
318
|
selected,
|
|
313
319
|
legal,
|
|
314
|
-
|
|
320
|
+
_cached_theme_name,
|
|
315
321
|
TERMINAL.has_utf8,
|
|
316
322
|
layout.card_w,
|
|
317
323
|
layout.card_h,
|
|
@@ -168,6 +168,40 @@ def test_ghost_lead_silent_when_winner_didnt_trump_void() -> None:
|
|
|
168
168
|
assert out._bonus_money == 0
|
|
169
169
|
|
|
170
170
|
|
|
171
|
+
def test_ghost_lead_silent_when_lead_is_seven_under_deluge() -> None:
|
|
172
|
+
"""4.7.3 regression: under La Déluge (seven_eight_trump), a 7 or 8
|
|
173
|
+
of any suit functions as trump. A 7-led trick is therefore a
|
|
174
|
+
trump-led trick — L'Infiltré must NOT pay even though
|
|
175
|
+
`lead_suit != event.trump`. Pre-4.7.3 the is_trump_lead check
|
|
176
|
+
didn't honour the boss flag and the bonus fired spuriously.
|
|
177
|
+
"""
|
|
178
|
+
from belote.game import BossModifiers
|
|
179
|
+
|
|
180
|
+
state = GameState(
|
|
181
|
+
hands=((), (), (), ()),
|
|
182
|
+
_joker_state={"ghost_lead": True},
|
|
183
|
+
boss_modifiers=BossModifiers(seven_eight_trump=True),
|
|
184
|
+
)
|
|
185
|
+
acc = ScoreAccumulator()
|
|
186
|
+
event = TrickWonEvent(
|
|
187
|
+
winner=Seat.NORTH,
|
|
188
|
+
cards=(
|
|
189
|
+
Card(Suit.HEARTS, Rank.SEVEN), # SOUTH — leads a 7 = trump under Déluge
|
|
190
|
+
Card(Suit.HEARTS, Rank.TEN), # EAST
|
|
191
|
+
Card(Suit.SPADES, Rank.JACK), # NORTH — plays trump
|
|
192
|
+
Card(Suit.HEARTS, Rank.KING), # WEST
|
|
193
|
+
),
|
|
194
|
+
trick_number=3,
|
|
195
|
+
is_last=False,
|
|
196
|
+
card_points=30,
|
|
197
|
+
trump=Suit.SPADES,
|
|
198
|
+
leader_seat=Seat.SOUTH,
|
|
199
|
+
)
|
|
200
|
+
out = acc.update_state(state, event)
|
|
201
|
+
assert out._mult == 1.0, "ghost_lead must not fire when lead is a Déluge-trump 7/8"
|
|
202
|
+
assert out._bonus_money == 0
|
|
203
|
+
|
|
204
|
+
|
|
171
205
|
# ── L'Architecte annonce-cash-x2 ────────────────────────────────────────────
|
|
172
206
|
|
|
173
207
|
|
|
@@ -84,3 +84,52 @@ def test_trust_bar_paints_when_visible() -> None:
|
|
|
84
84
|
bar = TrustBar(TrustTrack(value=5))
|
|
85
85
|
out = _capture(bar.render)
|
|
86
86
|
assert "Trust:" in out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_belatro_hud_render_writes_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
90
|
+
"""4.7.3: BelAtroHUD.render must batch the pip strip, summary rows,
|
|
91
|
+
score line, joker list, tally readout, and synergy tooltip into a
|
|
92
|
+
SINGLE sys.stdout.write call. Pre-4.7.3 the pip strip and synergy
|
|
93
|
+
tooltip each did their own write+flush, costing 2–3 syscalls per HUD
|
|
94
|
+
refresh.
|
|
95
|
+
|
|
96
|
+
Same single-write convention as ShopScreen._render (pinned in
|
|
97
|
+
test_render_diff.py::test_shop_render_writes_once_per_frame).
|
|
98
|
+
"""
|
|
99
|
+
from belote.belatro.core.scoring import ScoreAccumulator
|
|
100
|
+
from belote.belatro.items.registry import register_all_items
|
|
101
|
+
from belote.belatro.ui.hud import BelAtroHUD
|
|
102
|
+
from belote.game import new_game
|
|
103
|
+
|
|
104
|
+
register_all_items()
|
|
105
|
+
run = BelAtroRun(seed=1)
|
|
106
|
+
h = BelAtroHUD(run)
|
|
107
|
+
acc = ScoreAccumulator()
|
|
108
|
+
state = new_game()
|
|
109
|
+
acc.trigger_round_start(state)
|
|
110
|
+
|
|
111
|
+
class _CountingBuf(io.StringIO):
|
|
112
|
+
write_count = 0
|
|
113
|
+
|
|
114
|
+
def write(self, s: str) -> int: # type: ignore[override]
|
|
115
|
+
self.write_count += 1
|
|
116
|
+
return super().write(s)
|
|
117
|
+
|
|
118
|
+
buf = _CountingBuf()
|
|
119
|
+
saved = sys.stdout
|
|
120
|
+
sys.stdout = buf
|
|
121
|
+
try:
|
|
122
|
+
# `get_term_size` is imported locally inside BelAtroHUD.render from
|
|
123
|
+
# `belote.ui.render`; patch the source module so both that import and
|
|
124
|
+
# the `_render_compact` fallback see the deterministic size.
|
|
125
|
+
render_mod = sys.modules["belote.ui.render"]
|
|
126
|
+
monkeypatch.setattr(render_mod, "get_term_size", lambda: (120, 40))
|
|
127
|
+
h.render(acc, state)
|
|
128
|
+
finally:
|
|
129
|
+
sys.stdout = saved
|
|
130
|
+
|
|
131
|
+
assert buf.write_count == 1, (
|
|
132
|
+
f"BelAtroHUD.render must batch into one write; got {buf.write_count}. "
|
|
133
|
+
f"Pre-4.7.3 this was 2–3 due to the pip-strip / tooltip helpers "
|
|
134
|
+
f"writing independently."
|
|
135
|
+
)
|
|
@@ -392,6 +392,29 @@ def test_lagent_double_seat_keyed_to_south() -> None:
|
|
|
392
392
|
assert LAgentDouble().on_trick_won(_trick(winner=Seat.NORTH), {}) is None
|
|
393
393
|
|
|
394
394
|
|
|
395
|
+
def test_le_demon_on_purchase_is_idempotent() -> None:
|
|
396
|
+
"""4.7.3: LeDemon.on_purchase degrades trust by 3; re-applying it (e.g.,
|
|
397
|
+
via a save/load round-trip or replay-resume tool) must NOT compound the
|
|
398
|
+
cost. The guard lives on `run._applied_purchase_ids`, mirroring
|
|
399
|
+
`_applied_voucher_ids` from 3.9.3.
|
|
400
|
+
"""
|
|
401
|
+
from belote.belatro.core.run_state import BelAtroRun
|
|
402
|
+
|
|
403
|
+
run = BelAtroRun(seed=1)
|
|
404
|
+
starting_trust = run.partner.trust.value
|
|
405
|
+
j = LeDemon()
|
|
406
|
+
j.on_purchase(run)
|
|
407
|
+
after_first = run.partner.trust.value
|
|
408
|
+
assert after_first == max(0, starting_trust - 3)
|
|
409
|
+
# Second call must be a no-op.
|
|
410
|
+
j.on_purchase(run)
|
|
411
|
+
assert run.partner.trust.value == after_first
|
|
412
|
+
# A fresh LeDemon instance with the same id should also short-circuit on
|
|
413
|
+
# the same run (save/load → distinct Python object, same logical joker).
|
|
414
|
+
LeDemon().on_purchase(run)
|
|
415
|
+
assert run.partner.trust.value == after_first
|
|
416
|
+
|
|
417
|
+
|
|
395
418
|
# ── trick_timing.py ────────────────────────────────────────────────────────
|
|
396
419
|
|
|
397
420
|
|