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.
Files changed (154) hide show
  1. {belote_cli-4.7.2 → belote_cli-4.7.3}/CHANGELOG.md +87 -0
  2. {belote_cli-4.7.2 → belote_cli-4.7.3}/DEVELOPMENT.md +1 -1
  3. {belote_cli-4.7.2 → belote_cli-4.7.3}/PKG-INFO +4 -4
  4. {belote_cli-4.7.2 → belote_cli-4.7.3}/README.md +3 -3
  5. {belote_cli-4.7.2 → belote_cli-4.7.3}/pyproject.toml +1 -1
  6. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/__init__.py +1 -1
  7. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ai.py +5 -3
  8. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/core/run_state.py +10 -0
  9. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/core/scoring.py +11 -1
  10. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/jokers/corrupted.py +7 -1
  11. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/hud.py +84 -26
  12. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ui/announce.py +12 -5
  13. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ui/render.py +8 -2
  14. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_decks_4_5.py +34 -0
  15. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_hud_toggle.py +49 -0
  16. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_joker_contracts.py +23 -0
  17. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_render_diff.py +31 -0
  18. belote_cli-4.7.2/.antigravitycli/69dd05ce-2c1c-4419-8755-e4dd0d4495e8.json +0 -1
  19. {belote_cli-4.7.2 → belote_cli-4.7.3}/.claude/settings.local.json +0 -0
  20. {belote_cli-4.7.2 → belote_cli-4.7.3}/.gitignore +0 -0
  21. {belote_cli-4.7.2 → belote_cli-4.7.3}/.python-version +0 -0
  22. {belote_cli-4.7.2 → belote_cli-4.7.3}/LICENSE +0 -0
  23. {belote_cli-4.7.2 → belote_cli-4.7.3}/scripts/benchmark.py +0 -0
  24. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/__init__.py +0 -0
  25. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/a11y.py +0 -0
  26. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/achievements.py +0 -0
  27. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ansi.py +0 -0
  28. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/__init__.py +0 -0
  29. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/core/__init__.py +0 -0
  30. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/core/economy.py +0 -0
  31. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/core/round_ledger.py +0 -0
  32. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/engine/__init__.py +0 -0
  33. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/engine/event_bus.py +0 -0
  34. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/engine/modifier_patch.py +0 -0
  35. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/engine/round_driver.py +0 -0
  36. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ghost_run.py +0 -0
  37. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/__init__.py +0 -0
  38. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/base.py +0 -0
  39. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/jokers/__init__.py +0 -0
  40. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/jokers/annonces.py +0 -0
  41. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/jokers/coinche.py +0 -0
  42. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/jokers/contract.py +0 -0
  43. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/jokers/economy.py +0 -0
  44. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  45. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
  46. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  47. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  48. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  49. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  50. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/planets.py +0 -0
  51. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/registry.py +0 -0
  52. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/tarots.py +0 -0
  53. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/items/vouchers.py +0 -0
  54. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/main.py +0 -0
  55. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/partner/__init__.py +0 -0
  56. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/partner/partner_state.py +0 -0
  57. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/partner/personality.py +0 -0
  58. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/partner/trust.py +0 -0
  59. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/progression/__init__.py +0 -0
  60. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/progression/save.py +0 -0
  61. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/progression/unlocks.py +0 -0
  62. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/run/__init__.py +0 -0
  63. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/run/ante.py +0 -0
  64. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/run/ante_themes.py +0 -0
  65. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/run/boss.py +0 -0
  66. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/run/decks.py +0 -0
  67. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/run/shop.py +0 -0
  68. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/run_summary.py +0 -0
  69. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/__init__.py +0 -0
  70. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/announce.py +0 -0
  71. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/collection.py +0 -0
  72. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/consumables.py +0 -0
  73. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/history.py +0 -0
  74. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/inventory.py +0 -0
  75. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/menu.py +0 -0
  76. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/rules.py +0 -0
  77. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/shop.py +0 -0
  78. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/belatro/ui/trust_bar.py +0 -0
  79. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/config.py +0 -0
  80. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/context.py +0 -0
  81. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/deck.py +0 -0
  82. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/game.py +0 -0
  83. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/gameflow.py +0 -0
  84. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/input.py +0 -0
  85. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/main.py +0 -0
  86. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/replay.py +0 -0
  87. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/rules.py +0 -0
  88. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/scoring.py +0 -0
  89. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/stats.py +0 -0
  90. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/themes.py +0 -0
  91. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ui/__init__.py +0 -0
  92. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ui/fit_guard.py +0 -0
  93. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ui/layout.py +0 -0
  94. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ui/menu.py +0 -0
  95. {belote_cli-4.7.2 → belote_cli-4.7.3}/src/belote/ui/prompts.py +0 -0
  96. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/__init__.py +0 -0
  97. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/__init__.py +0 -0
  98. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_belatro.py +0 -0
  99. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_boss_contracts.py +0 -0
  100. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_boss_modifiers_integration.py +0 -0
  101. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_collection_logic.py +0 -0
  102. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_consumables_ui.py +0 -0
  103. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_contract_unlocks.py +0 -0
  104. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_dead_flag_fixes.py +0 -0
  105. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_deck_variants.py +0 -0
  106. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_endless.py +0 -0
  107. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_event_bus.py +0 -0
  108. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_ghost_run.py +0 -0
  109. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_heist.py +0 -0
  110. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_history_overlay.py +0 -0
  111. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_hud_synergy.py +0 -0
  112. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_inventory_overlay.py +0 -0
  113. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_jokers_4_5.py +0 -0
  114. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_partner_jokers.py +0 -0
  115. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_partner_trust.py +0 -0
  116. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_phase0_coverage.py +0 -0
  117. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_phase1_plumbing.py +0 -0
  118. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_phase2_content.py +0 -0
  119. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_phase3_meta.py +0 -0
  120. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_progression.py +0 -0
  121. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_round_driver.py +0 -0
  122. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_run_summary.py +0 -0
  123. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_shop_empty_pools.py +0 -0
  124. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_slot_machine_tally.py +0 -0
  125. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/belatro/test_voucher_idempotency.py +0 -0
  126. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/perf_baselines.json +0 -0
  127. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_a11y.py +0 -0
  128. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_achievements.py +0 -0
  129. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_ai.py +0 -0
  130. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_alt_screen_scroll.py +0 -0
  131. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_announce_stats.py +0 -0
  132. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_ansi_helpers.py +0 -0
  133. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_belote.py +0 -0
  134. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_benchmark_smoke.py +0 -0
  135. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_bidding_all_pass.py +0 -0
  136. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_declaration_tiebreak.py +0 -0
  137. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_extended.py +0 -0
  138. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_game_logic.py +0 -0
  139. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_gameflow.py +0 -0
  140. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_hand_auto_sort.py +0 -0
  141. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_history_overlay_cache.py +0 -0
  142. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_input_eof.py +0 -0
  143. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_input_wasd.py +0 -0
  144. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_invariants.py +0 -0
  145. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_layout.py +0 -0
  146. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_new_coverage.py +0 -0
  147. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_no_color.py +0 -0
  148. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_official_rules.py +0 -0
  149. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_perf_regression.py +0 -0
  150. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_properties.py +0 -0
  151. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_render_felt_polish.py +0 -0
  152. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_replay.py +0 -0
  153. {belote_cli-4.7.2 → belote_cli-4.7.3}/tests/test_undo.py +0 -0
  154. {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
@@ -84,7 +84,7 @@ PYTHONPATH=src mypy --strict src/
84
84
  # Linting (0 violations expected)
85
85
  ruff check src/ tests/
86
86
 
87
- # Full test suite (1003 tests expected)
87
+ # Full test suite (1007 tests expected)
88
88
  PYTHONPATH=src pytest
89
89
  ```
90
90
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: belote-cli
3
- Version: 4.7.2
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 (1003 tests)
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 **1003 tests** passing with 100% coverage on game-logic modules (4.7.2).
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**: 1003/1003 passed
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 (1003 tests)
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 **1003 tests** passing with 100% coverage on game-logic modules (4.7.2).
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**: 1003/1003 passed
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "belote-cli"
7
- version = "4.7.2"
7
+ version = "4.7.3"
8
8
  description = "A 4-player terminal card game"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
- __version__ = "4.7.2"
1
+ __version__ = "4.7.3"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -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 promotes 7s/8s of trump above the Jack every ranking
294
- # / point read in this method must respect that flag or the AI will
295
- # pick the wrong cards.
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
- lead_suit = event.cards[0].suit if event.cards else None
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
- render_joker_pip_strip(run, term_w, row=1)
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
- render_synergy_tooltip(list(run.jokers), term_w, row=tooltip_row)
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
- parts: list[str] = [
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(self, acc: ScoreAccumulator, state: GameState, term_w: int) -> None:
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 render_joker_pip_strip(run: BelAtroRun, term_w: int, row: int = 1) -> None:
343
- """Render a compact one-row strip of joker slots at `row` (default top).
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
- No-ops when `term_w < 24` (not enough room for a 5-slot strip).
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. 3.9.3: single write+flush.
385
- sys.stdout.write(move(row, 2) + strip + "\n")
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 render_synergy_tooltip(jokers: Sequence[object], term_w: int, row: int = 5) -> None:
390
- """Render one-line synergy descriptions at `row` if any pair is active.
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
- No-ops when there are no active synergies. Truncates each line to the
393
- available width so we never wrap.
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
- sys.stdout.write("".join(out))
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
- if reader and duration > 0:
52
- return interruptible_sleep(duration, reader)
53
- if duration > 0:
54
- time.sleep(duration)
55
- return None
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
- theme_manager.current_name,
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