belote-cli 2.9.5__tar.gz → 3.0.2__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 (118) hide show
  1. {belote_cli-2.9.5 → belote_cli-3.0.2}/.claude/settings.local.json +2 -1
  2. {belote_cli-2.9.5 → belote_cli-3.0.2}/CHANGELOG.md +101 -0
  3. {belote_cli-2.9.5 → belote_cli-3.0.2}/DEVELOPMENT.md +45 -8
  4. {belote_cli-2.9.5 → belote_cli-3.0.2}/PKG-INFO +26 -3
  5. {belote_cli-2.9.5 → belote_cli-3.0.2}/README.md +25 -2
  6. {belote_cli-2.9.5 → belote_cli-3.0.2}/pyproject.toml +1 -1
  7. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/__init__.py +1 -1
  8. belote_cli-3.0.2/src/belote/a11y.py +93 -0
  9. belote_cli-3.0.2/src/belote/achievements.py +104 -0
  10. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/ai.py +48 -14
  11. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/ansi.py +42 -18
  12. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/core/scoring.py +38 -0
  13. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/engine/modifier_patch.py +2 -1
  14. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/engine/round_driver.py +26 -0
  15. belote_cli-3.0.2/src/belote/belatro/ghost_run.py +83 -0
  16. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/base.py +19 -0
  17. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/registry.py +27 -0
  18. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/main.py +34 -0
  19. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/run/boss.py +50 -0
  20. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/run/shop.py +37 -2
  21. belote_cli-3.0.2/src/belote/belatro/run_summary.py +71 -0
  22. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/ui/announce.py +28 -0
  23. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/ui/hud.py +49 -0
  24. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/config.py +8 -0
  25. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/game.py +13 -4
  26. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/gameflow.py +43 -2
  27. belote_cli-3.0.2/src/belote/replay.py +69 -0
  28. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/scoring.py +140 -37
  29. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/stats.py +21 -0
  30. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/themes.py +35 -2
  31. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/ui/menu.py +4 -4
  32. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/ui/prompts.py +27 -26
  33. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/ui/render.py +12 -11
  34. belote_cli-3.0.2/tests/belatro/test_dead_flag_fixes.py +780 -0
  35. belote_cli-3.0.2/tests/belatro/test_ghost_run.py +49 -0
  36. belote_cli-3.0.2/tests/belatro/test_hud_synergy.py +61 -0
  37. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_phase0_coverage.py +27 -0
  38. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_progression.py +63 -0
  39. belote_cli-3.0.2/tests/test_a11y.py +135 -0
  40. belote_cli-3.0.2/tests/test_achievements.py +58 -0
  41. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_ai.py +66 -0
  42. belote_cli-3.0.2/tests/test_ansi_helpers.py +162 -0
  43. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_belote.py +91 -0
  44. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_official_rules.py +3 -2
  45. belote_cli-3.0.2/tests/test_replay.py +48 -0
  46. belote_cli-2.9.5/tests/belatro/test_dead_flag_fixes.py +0 -290
  47. {belote_cli-2.9.5 → belote_cli-3.0.2}/.gitignore +0 -0
  48. {belote_cli-2.9.5 → belote_cli-3.0.2}/.python-version +0 -0
  49. {belote_cli-2.9.5 → belote_cli-3.0.2}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
  50. {belote_cli-2.9.5 → belote_cli-3.0.2}/LICENSE +0 -0
  51. {belote_cli-2.9.5 → belote_cli-3.0.2}/scripts/benchmark.py +0 -0
  52. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/__init__.py +0 -0
  53. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/__init__.py +0 -0
  54. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/core/__init__.py +0 -0
  55. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/core/economy.py +0 -0
  56. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/core/run_state.py +0 -0
  57. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/engine/__init__.py +0 -0
  58. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/engine/event_bus.py +0 -0
  59. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/__init__.py +0 -0
  60. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/jokers/__init__.py +0 -0
  61. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/jokers/annonces.py +0 -0
  62. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/jokers/coinche.py +0 -0
  63. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/jokers/contract.py +0 -0
  64. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  65. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/jokers/economy.py +0 -0
  66. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  67. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
  68. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  69. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  70. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  71. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  72. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/planets.py +0 -0
  73. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/tarots.py +0 -0
  74. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/vouchers.py +0 -0
  75. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/partner/__init__.py +0 -0
  76. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/partner/partner_state.py +0 -0
  77. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/partner/personality.py +0 -0
  78. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/partner/trust.py +0 -0
  79. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/progression/__init__.py +0 -0
  80. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/progression/save.py +0 -0
  81. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/progression/unlocks.py +0 -0
  82. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/run/__init__.py +0 -0
  83. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/run/ante.py +0 -0
  84. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/run/ante_themes.py +0 -0
  85. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/run/decks.py +0 -0
  86. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/ui/__init__.py +0 -0
  87. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/ui/collection.py +0 -0
  88. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/ui/menu.py +0 -0
  89. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/ui/rules.py +0 -0
  90. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/ui/shop.py +0 -0
  91. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/ui/trust_bar.py +0 -0
  92. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/context.py +0 -0
  93. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/deck.py +0 -0
  94. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/input.py +0 -0
  95. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/main.py +0 -0
  96. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/rules.py +0 -0
  97. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/ui/__init__.py +0 -0
  98. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/ui/announce.py +0 -0
  99. {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/ui/layout.py +0 -0
  100. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/__init__.py +0 -0
  101. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/__init__.py +0 -0
  102. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_belatro.py +0 -0
  103. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_boss_modifiers_integration.py +0 -0
  104. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_collection_logic.py +0 -0
  105. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_contract_unlocks.py +0 -0
  106. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_deck_variants.py +0 -0
  107. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_partner_trust.py +0 -0
  108. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_phase1_plumbing.py +0 -0
  109. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_phase2_content.py +0 -0
  110. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_phase3_meta.py +0 -0
  111. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_round_driver.py +0 -0
  112. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_extended.py +0 -0
  113. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_game_logic.py +0 -0
  114. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_gameflow.py +0 -0
  115. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_layout.py +0 -0
  116. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_new_coverage.py +0 -0
  117. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_properties.py +0 -0
  118. {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_undo.py +0 -0
@@ -10,7 +10,8 @@
10
10
  "Bash(python3 -m pytest tests/ -q --tb=no)",
11
11
  "Bash(python3 -m pytest tests/ -x -q)",
12
12
  "Bash(PYTHONPATH=src python3 *)",
13
- "Bash(.venv/bin/python -m mypy src/)"
13
+ "Bash(.venv/bin/python -m mypy src/)",
14
+ "Bash(PYTHONPATH=src python -m pytest --tb=short -q)"
14
15
  ]
15
16
  }
16
17
  }
@@ -5,6 +5,107 @@ 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.0.2] - 2026-05-08
9
+
10
+ Audit pass — wired two previously-dead 3.0.0 modules behind opt-in env vars, removed redundant work from `score_round()`, and pinned every boss modifier's patch keys against typo regressions.
11
+
12
+ ### Fixed
13
+
14
+ - **`src/belote/belatro/main.py` + `src/belote/belatro/engine/round_driver.py`** — `GhostRecorder` (`src/belote/belatro/ghost_run.py`) was imported nowhere outside its own tests since it shipped in 3.0.0; ghost recording silently never happened. Wired through `drive_round()` via a new optional `recorder` param so bids, plays, and round-end breakdowns are now captured. Gated on `BELOTE_GHOST=1` so default play is unchanged. Saves to `~/.local/share/belote/ghosts/<label>-<seed>.json` on run end.
15
+ - **`src/belote/gameflow.py`** — `replay.analyze_round()` / `summarize()` (`src/belote/replay.py`) was never called from any runtime path since it shipped in 3.0.0; the post-round Hard-AI comparison the README advertised silently never fired. `run_play()` now optionally accumulates `(state, played_card)` pairs for South; `run_round()` reads `BELOTE_REPLAY=1` once per round, runs the analyzer post-scoring, and prints a one-line `Replay: Optimal plays: N/M (P%)` summary. UNDO clears the buffer so the report matches the play that actually finished the round.
16
+
17
+ ### Improved
18
+
19
+ - **`src/belote/scoring.py::score_round`** — pre-computes the per-trick winner list once at the top and threads it into `_calculate_base_points` and `_apply_scoring_modifiers`. Both helpers used to re-call `trick_winner_seat()` for every completed trick; under `separate_scoring` + `queen_spades_penalty` the 8-trick walk could run 3× per round.
20
+ - **`src/belote/belatro/items/registry.py::register_all_items`** — now idempotent. A module-level `_registered` flag short-circuits subsequent calls; second clause `and registry.jokers` re-runs when a caller has swapped the global to a fresh empty `ItemRegistry` (the test-suite pattern at `tests/belatro/test_belatro.py::TestItemRegistry.setup_method`). Saves the 4× `dir(mod)` walk on every `BelAtroRun` after the first.
21
+ - **`src/belote/ai.py::_special_bid`** — `_suit_lengths(hand)` is now computed once and threaded into `_easy_special` / `_medium_special` / `_hard_special`. The three branches each used to rebuild it from scratch.
22
+
23
+ ### Added
24
+
25
+ - **`tests/belatro/test_phase0_coverage.py::test_every_boss_modifier_actually_patches_a_flag`** — pin against a typo'd `state.patch("_misspelled", True)` key silently producing a no-op boss. Iterates `ALL_BOSS_MODIFIERS`, asserts each `.flags()` differs from default `BossModifiers()`. The `boss_fields` allow-list in `engine/modifier_patch.py` is now load-bearing for correctness — this test surfaces drift.
26
+ - **`DEVELOPMENT.md`** — new "Optional Runtime Flags" section documenting `BELOTE_REPLAY` and `BELOTE_GHOST` next to the existing `BELOTE_A11Y` entry.
27
+
28
+ ### Internal
29
+
30
+ - **Tests**: 509 → 510 (+1).
31
+ - **Strict gates**: pytest 510/510, mypy 0 errors, ruff 0 violations.
32
+
33
+ ### Known issue (not fixed in this cut)
34
+
35
+ - `src/belote/belatro/engine/event_bus.py::EventBus.emit` is never called anywhere in the source. `unlock_tracker.subscribe_to(bus)` registers `on_event` but receives no events, so `_handle_round_end`'s unlocks for **L'Exécuteur** (first Capot), **L'Idéologue** (Sans Atout NS win), and **Le Fanatique** (Tout Atout NS win) silently never fire under normal play. Out of scope for 3.0.2 (fix would change ordering with `acc.update_state` in `round_driver._emit`); flagged for a follow-up.
36
+
37
+ ## [3.0.1] - 2026-05-07
38
+
39
+ Post-3.0.0 audit pass — four player-visible / correctness bugs introduced or missed by the 3.0.0 cut, plus a batch of test-coverage and small-correctness improvements. No behaviour changes for code paths that were already correct.
40
+
41
+ ### Fixed
42
+
43
+ - **`src/belote/game.py:860-873`** — `play_card()`'s per-trick running total honoured `kings_zero` and `tens_zero` but ignored the new 3.0.0 `aces_zero` and `jacks_zero` flags. Final scoring (`scoring.py::_calculate_base_points`) was correct, but the live HUD running total under Le Sauvage / L'Iconoclaste was wrong until the round ended. Mirrored the canonical `scoring.py` zero-rank pattern; `bm` aliased once for readability.
44
+ - **`src/belote/belatro/ui/hud.py::_SYNERGY_PAIRS`** — referenced four joker IDs (`le_finisseur`, `le_dix_de_der`, `le_marseillais`, `carre_aces_x2`) that don't exist in the registry. Two of the four pairs were dead code that could never fire. Removed; `validate_synergy_ids()` now exposes a self-check, and `register_all_items()` asserts every synergy ID resolves so future typos surface at import time.
45
+ - **`src/belote/gameflow.py:248-258`** — the a11y trick-winner announcement used raw `card_points()` and ignored every boss zero-rank flag, so screen-reader users heard inflated trick scores under Le Sauvage / L'Iconoclaste / Le Roi Mort / Les Dix Maudits / Les Clubs Bannis. New canonical helper `scoring.trick_card_points(state, trick)` is now the single source of truth for per-trick boss-aware totals.
46
+ - **`src/belote/ai.py::AIMemory.last_voids_key`** — the new-round reset in `update_memory()` cleared `played`, `known_voids`, and `processed_tricks_count` but not the cache key. After the prior round's final key (e.g. `(7, 4)`), a fresh-round `(0, 0)` or `(0, 1)` could coincidentally match a key seen during round 1 and cause `_update_voids` to skip processing. Added `last_voids_key = None` to the reset.
47
+
48
+ ### Added
49
+
50
+ - **`src/belote/scoring.py::trick_card_points`** — public helper for "card-point sum of one trick under all active boss zero-rank flags." Used by gameflow's a11y hook.
51
+ - **`src/belote/belatro/ui/hud.py::validate_synergy_ids`** — return the synergy IDs that aren't registered as jokers. Used by `register_all_items()` for the new startup self-check.
52
+ - **Tests**: `tests/test_a11y.py` (8 cases for `trick_card_points` + a11y stderr emit), `tests/belatro/test_hud_synergy.py` (5 cases for the synergy registry), HOLO/POLYCHROME edition tests + four `separate_scoring × zero-flag` composition tests in `tests/belatro/test_dead_flag_fixes.py`, cross-round void-cache regression test in `tests/test_ai.py`. Tests grew 489 → 509 (+20).
53
+
54
+ ### Improved
55
+
56
+ - **`src/belote/belatro/run_summary.py`** — resolved path is cached at module level after the first call, so `mkdir` is no longer re-attempted on every BelAtro exit.
57
+ - **`src/belote/a11y.py`** — `BELOTE_A11Y` env var resolved once at module import (`_ENABLED` module variable). Tests use `_refresh_enabled_from_env()` to re-read after `monkeypatch.setenv`. Saves ~30 environ lookups per round in the disabled path.
58
+ - **`src/belote/scoring.py:649-668`** — Sans Atout Capot branch now asserts `taker_belote == 0 and defender_belote == 0`. Belote/Rebelote requires a unique trump suit, so this invariant should always hold under SA — the assertion documents it and surfaces any future regression that leaks belote points into the SA path.
59
+ - **`src/belote/belatro/run/boss.py::LeMime`** — docstring notes the redundancy between `declarations_zero` and `separate_scoring` and points to the regression test that pins their composition.
60
+
61
+ ### Internal
62
+
63
+ - **Tests**: 489 → 509 (+20). All new modules covered: a11y boss-aware pts (8), HUD synergy registry (5), HOLO/POLYCHROME editions (2), separate_scoring × zero-flag matrix (4), cross-round void cache (1).
64
+ - **Strict gates**: pytest 509/509, mypy 0 errors, ruff 0 violations.
65
+ - **Perf baseline**: unchanged from 3.0.0 (sub-millisecond throughout).
66
+
67
+ ## [3.0.0] - 2026-05-07
68
+
69
+ Bug-hunt + audit pass — three player-visible features that were registered but silently no-ops are now wired, the Capot scoring under Sans Atout / Tout Atout has been corrected, mypy is once again strict-clean, and a batch of P3 features lands behind the new BelAtro Endless mode flow.
70
+
71
+ ### Fixed
72
+
73
+ - **`src/belote/scoring.py::score_round`** — Capot reward used a flat `CAPOT_BASE = 252` for every contract, over-paying SA Capots (252 vs the contract-correct 220) and under-paying TA Capots (252 vs 348). New `CAPOT_BASE_SANS_ATOUT = 220` and `CAPOT_BASE_TOUT_ATOUT = 348` constants in `config.py`; scoring now branches on `state.contract`. `tests/test_belote.py::TestCapotPerContract` covers all three contracts; `tests/test_official_rules.py::test_sans_atout_score_round_baseline` updated from 252 → 220.
74
+ - **`src/belote/belatro/items/planets.py::TheSun`** — `level_up_reward()` returned `{"bonus_mult_per_trick": 1.0}` but no consumer ever read the key. Wired into `belatro/core/scoring.py::ScoreAccumulator` on `TrickWonEvent` when `event.trump == Suit.TOUT_ATOUT and event.trick_number > 4`.
75
+ - **`src/belote/belatro/items/planets.py::Libra`** — `coinche_multiplier` was set on the contract level but never read. Now consumed at `RoundEndEvent` time, scaled by `event.coinche_level`, and gated on the round being a non-failed taker win for NS.
76
+ - **`src/belote/scoring.py`** — `RoundScore` was constructed via `**common_kwargs` splat; mypy lost the per-field types and reported 8 errors. Inlined into both branches.
77
+ - **`src/belote/ui/render.py::_slot_frame_row`** — variable shadowing (`for c in range(...)` then `for c in cells`) made mypy infer `cells[i]` as `int`. Renamed the inner loop variables (`c → i`, `c → cell`).
78
+ - **`src/belote/ui/prompts.py`** — three untyped helpers (`_hist_taker_label`, `_hist_contract_label`, `_hist_status`) now annotated with `RoundScore`.
79
+ - **`src/belote/ui/prompts.py::show_history`** — N806 lint on `W_RD/W_TKR/...` constants resolved by lowercasing the locals; behaviour unchanged.
80
+ - **`src/belote/ai.py::_process_trick_voids`** — under the `republicain_wild` flag (Le Républicain deck / boss reuse), playing a 7 or 8 off-suit no longer falsely flags the player as void in the lead suit. Hard AI's void inference now skips wild ranks when the flag is active.
81
+
82
+ ### Added
83
+
84
+ - **`src/belote/belatro/main.py`** — post-Ante-8 endless prompt. After winning Ante 8, the run offers `Continue into Endless Mode? (Ante 9+ scales ×2.2)`. Built on the existing `BelAtroRun.endless` / `endless_ante_offset` infrastructure plus a new `BelAtroAnnounce.yes_no` helper.
85
+ - **`src/belote/belatro/items/base.py::Edition`** — new enum (NONE/FOIL/HOLO/POLYCHROME/NEGATIVE). Shop generation rolls per-joker editions; Foil adds +50 chips per trigger, Holo +10 mult, Polychrome ×1.5 mult, Negative grants an extra joker slot at purchase. Wiring lives in `belatro/run/shop.py` and `belatro/core/scoring.py::_apply_edition`.
86
+ - **`src/belote/belatro/run/boss.py`** — three new boss blinds (Le Sauvage / L'Iconoclaste / Le Mime) and three new `BossModifiers` flags (`aces_zero`, `jacks_zero`, `declarations_zero`) read by the existing scoring path.
87
+ - **`src/belote/belatro/ui/hud.py::detect_synergies`** — small registry of known joker pair combos; renders a `★ SYN×N` badge on the HUD when any pair (or 3+ jokers) is held.
88
+ - **`src/belote/achievements.py`** + **`src/belote/stats.py::Statistics.achievements`** — six classic-mode achievements (first Capot, 3 Capots in a session, 2 Capot streak, 300+ point round, hard win, ten games played) auto-evaluated post-round / post-game.
89
+ - **`src/belote/themes.py::THEMES["colorblind"]`** — deuteranopia/protanopia-friendly palette using blue/cyan/orange instead of red/green.
90
+ - **`src/belote/a11y.py`** — screen-reader hints. Cards played, trick winners, and round results emit one-line plain-text descriptions to stderr when the env var `BELOTE_A11Y=1` is set.
91
+ - **`src/belote/replay.py`** — `analyze_round()` runs the just-played decisions through the Hard AI and reports per-decision agreement; `summarize()` produces a one-line `Optimal: N/M (X%)` string.
92
+ - **`src/belote/belatro/ghost_run.py`** — `GhostRecorder` accumulates seed + bid + play events and serializes them to JSONL under the user data dir. Foundation for a future ghost-replay viewer; the JSON format is versioned (v1).
93
+ - **`src/belote/belatro/run_summary.py`** — appends a one-line per-run summary (deck, ante, jokers, won) to `~/.local/share/belote/run_history.jsonl` on BelAtro exit. Best-effort, OSError-swallowed.
94
+ - **`src/belote/gameflow.py::show_hand_preview`** — short `Dealing…` flourish before the hand preview, gated on the existing speed setting and skippable via Space/Esc.
95
+
96
+ ### Improved
97
+
98
+ - **`src/belote/ansi.py`** — 16 palette accessors (`felt_bg()`, `red_fg()`, …) previously hit `theme_manager.get_current()` per call. Cached the active `Theme` at module level and registered an invalidation callback with `theme_manager`. Lower per-render dict-lookup overhead.
99
+ - **`src/belote/themes.py::ThemeManager`** — redundant class-level `_current_theme_name` removed; new public `current_name` property; `_initialized` guard now uses `getattr` for clarity. Eight call sites in `ui/menu.py`, `ui/prompts.py`, and `ui/render.py` migrated off `_current_theme_name`.
100
+ - **`src/belote/ui/render.py`** — `_LAST_RENDER_KEY` list-of-one singleton replaced with a module-level variable + `global` declaration; same behaviour, less Python idiom drag.
101
+ - **`src/belote/ai.py::AIMemory.last_voids_key`** — caches `(completed_count, current_trick_len)` so `_update_voids` short-circuits on repeat calls within the same trick decision (lookahead exploration triggered redundant scans).
102
+
103
+ ### Internal
104
+
105
+ - **Performance baseline (post-fix)**: `scripts/benchmark.py` reports render 0.271ms (±0.044), AI Easy/Med/Hard 0.015 / 0.030 / 0.026 ms, BelAtro update 0.032 ms, scoring 0.169 ms, deal 0.071 ms, legal_cards 0.012 ms.
106
+ - **Tests**: 446 → 489 (+43). New files: `tests/test_ansi_helpers.py`, `tests/test_achievements.py`, `tests/test_replay.py`, `tests/belatro/test_ghost_run.py`. Existing files extended with Capot-per-contract, Sun/Libra wiring, TA→le_fanatique unlock, republicain-void edge case, and three new boss tests.
107
+ - **Strict mode**: README's "0 mypy errors / 0 ruff violations" claim was inaccurate at 2.9.5 (18 mypy + 10 ruff at audit time). Both gates are now actually clean.
108
+
8
109
  ## [2.9.5] - 2026-05-07
9
110
 
10
111
  In-game keyboard shortcuts cleaned up, the trick mat now anchors every played card inside a visible per-seat slot, the round history overlay carries the full per-round picture, and the cards have been redrawn in a GRIMAUD-1898 style with both-corner indices and patterned pip layouts.
@@ -78,21 +78,28 @@ PYTHONPATH=src pytest --cov=belote --cov-report=term-missing
78
78
  The project maintains zero lint and type-check violations. Run all checks with:
79
79
 
80
80
  ```bash
81
- # Type checking (0 errors expected)
82
- PYTHONPATH=src mypy .
81
+ # Type checking (0 errors expected, strict mode)
82
+ PYTHONPATH=src mypy --strict src/
83
83
 
84
84
  # Linting (0 violations expected)
85
- ruff check .
86
- # Full test suite (435 tests expected)
87
- PYTHONPATH=src pytest
85
+ ruff check src/ tests/
88
86
 
89
- # ...
87
+ # Full test suite (510 tests expected)
88
+ PYTHONPATH=src pytest
89
+ ```
90
90
 
91
- Current baseline:
91
+ Current baseline (3.0.2):
92
92
  - **mypy**: 0 errors (strict mode)
93
93
  - **ruff**: 0 violations
94
- - **pytest**: 435 tests, 0 failures
94
+ - **pytest**: 510 tests, 0 failures
95
95
 
96
+ Run all gates before committing:
97
+
98
+ ```bash
99
+ PYTHONPATH=src python -m pytest --tb=short -q && \
100
+ python -m mypy --strict src/ && \
101
+ python -m ruff check src/ tests/
102
+ ```
96
103
 
97
104
  ## Benchmarking
98
105
 
@@ -101,6 +108,36 @@ A benchmarking script is provided to measure rendering and AI performance:
101
108
  PYTHONPATH=src python scripts/benchmark.py
102
109
  ```
103
110
 
111
+ 3.0.0 baseline numbers (Linux, Python 3.10+, 1000 iterations):
112
+ - Render: 0.27 ms (±0.04)
113
+ - AI Hard decide_card: 0.026 ms (±0.003)
114
+ - BelAtro state update: 0.032 ms (±0.004)
115
+ - score_round: 0.169 ms
116
+ - legal_cards: 0.012 ms
117
+
118
+ Use these as a regression-detection floor for future changes.
119
+
120
+ ## Accessibility
121
+
122
+ Set `BELOTE_A11Y=1` to emit one-line plain-text descriptions of card plays,
123
+ trick winners, and round results to stderr — readable by terminal screen
124
+ readers (Orca, NVDA over WSL, VoiceOver via iTerm2).
125
+
126
+ ## Optional Runtime Flags
127
+
128
+ The following environment variables enable opt-in features. Each is read
129
+ once at startup; toggling mid-run has no effect.
130
+
131
+ - `BELOTE_REPLAY=1` — after every Classic round, print a one-line summary
132
+ of how often South's plays matched the Hard-AI's preferred line
133
+ (e.g. `Replay: Optimal plays: 6/8 (75%)`). Educational only — never
134
+ affects scoring. Backed by `src/belote/replay.py`.
135
+ - `BELOTE_GHOST=1` — silently record every BelAtro run (seed, deck,
136
+ bids, plays, round outcomes) to
137
+ `~/.local/share/belote/ghosts/<label>-<seed>.json`. The file is written
138
+ once when the run ends. Useful for sharing or replaying interesting
139
+ runs. Backed by `src/belote/belatro/ghost_run.py`.
140
+
104
141
  ## Releasing a New Version
105
142
 
106
143
  ### Code-only update (push to GitHub without releasing a new PyPI version)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: belote-cli
3
- Version: 2.9.5
3
+ Version: 3.0.2
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,7 +45,30 @@ 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
- ## Now Featuring: BelAtro Expansion
48
+ ## What's new in 3.0.2
49
+
50
+ - **Replay analyzer + Ghost run wired up** — both shipped in 3.0.0 as code modules but were never called from the running game. Now opt-in behind `BELOTE_REPLAY=1` (post-round Hard-AI comparison) and `BELOTE_GHOST=1` (per-run JSON dump to `~/.local/share/belote/ghosts/`). See DEVELOPMENT.md › Optional Runtime Flags.
51
+ - **Performance** — `score_round()` now caches per-trick winners once instead of recomputing them in each boss-modifier helper (2-3× walks → 1× walk per round). `register_all_items()` is now idempotent so test setup no longer re-walks every items module per `BelAtroRun`. Bidding's special-bid path (TA / SA) hoists `_suit_lengths` out of the per-difficulty branches.
52
+ - **Defensive pin** — every entry in `ALL_BOSS_MODIFIERS` is now asserted to actually toggle a `BossModifiers` field via `.flags()`. Catches typo'd `state.patch("_misspelled", True)` keys at test time rather than letting the boss silently no-op.
53
+ - **Test coverage** — 510 tests (up from 509).
54
+
55
+ ## What's new in 3.0.1
56
+
57
+ - **Bug fixes** — `play_card()` running total now honours `aces_zero` / `jacks_zero` (Le Sauvage / L'Iconoclaste); the screen-reader trick-winner pts are now boss-aware; the HUD synergy registry no longer references nonexistent joker IDs; the AI void-cache key is now reset across rounds.
58
+ - **Test coverage** — 509 tests (up from 489): HOLO/POLYCHROME editions, multi-boss composition (`separate_scoring × zero-flag`), a11y boss-aware pts, synergy registry self-check.
59
+
60
+ ## What's new in 3.0.0
61
+
62
+ - **Endless mode** — beat Ante 8 in BelAtro and the run offers a continuation: targets scale ×2.2 per ante and a furthest-ante leaderboard tracks how deep you go.
63
+ - **Joker editions** — Foil (+50 chips), Holo (+10 mult), Polychrome (×1.5 mult), Negative (extra slot) randomly stamp shop jokers.
64
+ - **Three new boss blinds** — Le Sauvage (Aces = 0), L'Iconoclaste (Jacks = 0, even trump-J), Le Mime (Declarations = 0).
65
+ - **Achievements** — six classic-mode milestones tracked across sessions.
66
+ - **Colorblind palette** + **screen-reader hints** (`BELOTE_A11Y=1`) for accessibility.
67
+ - **Replay analyzer** — module added (post-round Hard-AI comparison). User-facing wiring landed in 3.0.2 behind `BELOTE_REPLAY=1`.
68
+ - **Ghost run recording** + **run summary log** — modules added (serialize a run / append per-run JSON). Run-summary fires automatically; ghost-run user-facing wiring landed in 3.0.2 behind `BELOTE_GHOST=1`.
69
+ - **Bug fixes** — Capot under Sans Atout / Tout Atout now uses the correct base (220 / 348, not 252); The Sun and Libra planets actually do something now; AI void inference no longer mis-flags voids under Le Républicain wild 7/8.
70
+
71
+ ## BelAtro Expansion
49
72
 
50
73
  **BelAtro** is a major roguelite expansion inspired by *Balatro*. Play through 8 Antes of escalating difficulty, build a deck of powerful Jokers, and use Tarot cards and Planets to break the game!
51
74
 
@@ -267,7 +290,7 @@ belote/
267
290
  PYTHONPATH=src pytest
268
291
  ```
269
292
 
270
- Currently **435 tests** passing with 100% coverage on core logic.
293
+ Currently **510 tests** passing with 100% coverage on game-logic modules.
271
294
 
272
295
  ## Technical Integrity
273
296
 
@@ -2,7 +2,30 @@
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
- ## Now Featuring: BelAtro Expansion
5
+ ## What's new in 3.0.2
6
+
7
+ - **Replay analyzer + Ghost run wired up** — both shipped in 3.0.0 as code modules but were never called from the running game. Now opt-in behind `BELOTE_REPLAY=1` (post-round Hard-AI comparison) and `BELOTE_GHOST=1` (per-run JSON dump to `~/.local/share/belote/ghosts/`). See DEVELOPMENT.md › Optional Runtime Flags.
8
+ - **Performance** — `score_round()` now caches per-trick winners once instead of recomputing them in each boss-modifier helper (2-3× walks → 1× walk per round). `register_all_items()` is now idempotent so test setup no longer re-walks every items module per `BelAtroRun`. Bidding's special-bid path (TA / SA) hoists `_suit_lengths` out of the per-difficulty branches.
9
+ - **Defensive pin** — every entry in `ALL_BOSS_MODIFIERS` is now asserted to actually toggle a `BossModifiers` field via `.flags()`. Catches typo'd `state.patch("_misspelled", True)` keys at test time rather than letting the boss silently no-op.
10
+ - **Test coverage** — 510 tests (up from 509).
11
+
12
+ ## What's new in 3.0.1
13
+
14
+ - **Bug fixes** — `play_card()` running total now honours `aces_zero` / `jacks_zero` (Le Sauvage / L'Iconoclaste); the screen-reader trick-winner pts are now boss-aware; the HUD synergy registry no longer references nonexistent joker IDs; the AI void-cache key is now reset across rounds.
15
+ - **Test coverage** — 509 tests (up from 489): HOLO/POLYCHROME editions, multi-boss composition (`separate_scoring × zero-flag`), a11y boss-aware pts, synergy registry self-check.
16
+
17
+ ## What's new in 3.0.0
18
+
19
+ - **Endless mode** — beat Ante 8 in BelAtro and the run offers a continuation: targets scale ×2.2 per ante and a furthest-ante leaderboard tracks how deep you go.
20
+ - **Joker editions** — Foil (+50 chips), Holo (+10 mult), Polychrome (×1.5 mult), Negative (extra slot) randomly stamp shop jokers.
21
+ - **Three new boss blinds** — Le Sauvage (Aces = 0), L'Iconoclaste (Jacks = 0, even trump-J), Le Mime (Declarations = 0).
22
+ - **Achievements** — six classic-mode milestones tracked across sessions.
23
+ - **Colorblind palette** + **screen-reader hints** (`BELOTE_A11Y=1`) for accessibility.
24
+ - **Replay analyzer** — module added (post-round Hard-AI comparison). User-facing wiring landed in 3.0.2 behind `BELOTE_REPLAY=1`.
25
+ - **Ghost run recording** + **run summary log** — modules added (serialize a run / append per-run JSON). Run-summary fires automatically; ghost-run user-facing wiring landed in 3.0.2 behind `BELOTE_GHOST=1`.
26
+ - **Bug fixes** — Capot under Sans Atout / Tout Atout now uses the correct base (220 / 348, not 252); The Sun and Libra planets actually do something now; AI void inference no longer mis-flags voids under Le Républicain wild 7/8.
27
+
28
+ ## BelAtro Expansion
6
29
 
7
30
  **BelAtro** is a major roguelite expansion inspired by *Balatro*. Play through 8 Antes of escalating difficulty, build a deck of powerful Jokers, and use Tarot cards and Planets to break the game!
8
31
 
@@ -224,7 +247,7 @@ belote/
224
247
  PYTHONPATH=src pytest
225
248
  ```
226
249
 
227
- Currently **435 tests** passing with 100% coverage on core logic.
250
+ Currently **510 tests** passing with 100% coverage on game-logic modules.
228
251
 
229
252
  ## Technical Integrity
230
253
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "belote-cli"
7
- version = "2.9.5"
7
+ version = "3.0.2"
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__ = "2.9.5"
1
+ __version__ = "3.0.2"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -0,0 +1,93 @@
1
+ """3.0.0: optional screen-reader hints.
2
+
3
+ When the env var ``BELOTE_A11Y`` is truthy, key in-game events emit a plain-text
4
+ line to stderr — readable by terminal screen readers such as Orca, NVDA in WSL,
5
+ or VoiceOver via iTerm2. Disabled by default so it doesn't pollute output for
6
+ sighted players.
7
+
8
+ Hooked from gameflow.py (card plays, trick winners, round results) and from
9
+ belatro/main.py (boss reveal, ante advance, run won/lost). Each hook is a
10
+ single line — no rich formatting — so the screen reader can speak it cleanly.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+ from typing import TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING:
20
+ from .deck import Card
21
+ from .game import Seat
22
+
23
+
24
+ # 3.0.1: resolve the env var once at import. The flag is read on every card
25
+ # play (~32 times/round); a dict lookup is cheap but bypassing it keeps
26
+ # `speak()` essentially free in the disabled path.
27
+ _TRUTHY = {"1", "true", "yes", "on"}
28
+ _ENABLED: bool = os.environ.get("BELOTE_A11Y", "").lower() in _TRUTHY
29
+
30
+
31
+ def is_enabled() -> bool:
32
+ """Return whether a11y hints are enabled.
33
+
34
+ Reads the cached module-level value (set at import). Tests that use
35
+ `monkeypatch.setenv("BELOTE_A11Y", ...)` should call
36
+ `_refresh_enabled_from_env()` after the patch.
37
+ """
38
+ return _ENABLED
39
+
40
+
41
+ def _refresh_enabled_from_env() -> None:
42
+ """Re-read BELOTE_A11Y from the live environment. Public for tests."""
43
+ global _ENABLED
44
+ _ENABLED = os.environ.get("BELOTE_A11Y", "").lower() in _TRUTHY
45
+
46
+
47
+ def speak(line: str) -> None:
48
+ """Emit one line to stderr — only when BELOTE_A11Y is enabled."""
49
+ if _ENABLED:
50
+ sys.stderr.write(line + "\n")
51
+ sys.stderr.flush()
52
+
53
+
54
+ # ── Convenience formatters ────────────────────────────────────────────────
55
+
56
+
57
+ def _suit_word(suit_symbol: str) -> str:
58
+ return {
59
+ "♠": "spades",
60
+ "♥": "hearts",
61
+ "♦": "diamonds",
62
+ "♣": "clubs",
63
+ }.get(suit_symbol, suit_symbol)
64
+
65
+
66
+ def card_word(card: Card) -> str:
67
+ rank = card.rank.value
68
+ rank_word = {
69
+ "7": "seven",
70
+ "8": "eight",
71
+ "9": "nine",
72
+ "10": "ten",
73
+ "J": "jack",
74
+ "Q": "queen",
75
+ "K": "king",
76
+ "A": "ace",
77
+ }.get(rank, rank)
78
+ return f"{rank_word} of {_suit_word(card.suit.symbol)}"
79
+
80
+
81
+ def announce_play(seat: Seat, card: Card) -> None:
82
+ speak(f"{seat.name.lower()} plays {card_word(card)}.")
83
+
84
+
85
+ def announce_trick_won(winner: Seat, points: int) -> None:
86
+ speak(f"{winner.name.lower()} wins the trick worth {points} points.")
87
+
88
+
89
+ def announce_round_result(taker_total: int, defender_total: int, taker_team_label: str) -> None:
90
+ speak(
91
+ f"round complete. {taker_team_label} taker scores {taker_total}; "
92
+ f"defenders score {defender_total}."
93
+ )
@@ -0,0 +1,104 @@
1
+ """3.0.0: lightweight achievement registry for classic Belote.
2
+
3
+ Achievements are defined statically here, evaluated against the running
4
+ ``Statistics`` object after each round/game, and persisted via the existing
5
+ ``stats.save_stats()`` path. BelAtro has its own unlock system in
6
+ ``progression/save.py``; this module is for the classic mode only.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from .stats import Statistics
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Achievement:
18
+ id: str
19
+ title: str
20
+ description: str
21
+
22
+
23
+ # Catalog. New achievements: add a row here AND a check in
24
+ # `evaluate_round` / `evaluate_game`. IDs are stable strings — never rename.
25
+ ACHIEVEMENTS: tuple[Achievement, ...] = (
26
+ Achievement(
27
+ "first_capot",
28
+ "Le Grand Chelem",
29
+ "Score your first Capot.",
30
+ ),
31
+ Achievement(
32
+ "capot_x3",
33
+ "Triple Coup",
34
+ "Score 3 Capots in a single session.",
35
+ ),
36
+ Achievement(
37
+ "capot_streak_2",
38
+ "Vague de Capots",
39
+ "Capot two rounds in a row.",
40
+ ),
41
+ Achievement(
42
+ "high_round_300",
43
+ "Cartes Pleines",
44
+ "Score 300+ points in a single round (declarations + Capot).",
45
+ ),
46
+ Achievement(
47
+ "win_hard",
48
+ "Le Maître",
49
+ "Win a game on Hard difficulty.",
50
+ ),
51
+ Achievement(
52
+ "ten_games_played",
53
+ "Habitué",
54
+ "Play 10 games.",
55
+ ),
56
+ )
57
+
58
+
59
+ def evaluate_round(stats: Statistics, *, points_scored: int, was_capot: bool) -> list[Achievement]:
60
+ """Check post-round triggers; return list of newly unlocked achievements.
61
+
62
+ Mutates ``stats.achievements`` to record unlocks.
63
+ """
64
+ newly: list[Achievement] = []
65
+
66
+ def _try(aid: str) -> None:
67
+ if stats.unlock_achievement(aid):
68
+ for a in ACHIEVEMENTS:
69
+ if a.id == aid:
70
+ newly.append(a)
71
+ break
72
+
73
+ if was_capot:
74
+ if stats.capots_achieved == 1:
75
+ _try("first_capot")
76
+ if stats.capots_achieved >= 3:
77
+ _try("capot_x3")
78
+ if stats.current_capot_streak >= 2:
79
+ _try("capot_streak_2")
80
+
81
+ if points_scored >= 300:
82
+ _try("high_round_300")
83
+
84
+ return newly
85
+
86
+
87
+ def evaluate_game(
88
+ stats: Statistics, *, won: bool, difficulty: str
89
+ ) -> list[Achievement]:
90
+ newly: list[Achievement] = []
91
+
92
+ def _try(aid: str) -> None:
93
+ if stats.unlock_achievement(aid):
94
+ for a in ACHIEVEMENTS:
95
+ if a.id == aid:
96
+ newly.append(a)
97
+ break
98
+
99
+ if won and difficulty == "hard":
100
+ _try("win_hard")
101
+ if stats.games_played >= 10:
102
+ _try("ten_games_played")
103
+
104
+ return newly
@@ -35,6 +35,9 @@ class AIMemory:
35
35
  self.known_voids: dict[Seat, set[Suit]] = {s: set() for s in Seat}
36
36
  self.partner_hand: set[Card] = set()
37
37
  self.processed_tricks_count: int = 0
38
+ # (completed_count, current_trick_len) of the last _update_voids call.
39
+ # Lets us skip re-scanning a stable transient trick on each decision.
40
+ self.last_voids_key: tuple[int, int] | None = None
38
41
 
39
42
 
40
43
  class AIPlayer:
@@ -50,12 +53,16 @@ class AIPlayer:
50
53
  def update_memory(self, state: GameState) -> None:
51
54
  """Update memory with currently visible information."""
52
55
  if len(state.completed_tricks) == 0 and len(state.current_trick) == 0:
53
- # New round - reset memory
56
+ # New round - reset memory. Including the void-cache key — without
57
+ # this a (0, 0) / (0, 1) key from the first decision of *this* round
58
+ # could coincidentally match a leftover from the previous round and
59
+ # cause _update_voids to skip processing entirely.
54
60
  self.memory.played.clear()
55
61
  for s in Seat:
56
62
  self.memory.known_voids[s].clear()
57
63
  self.memory.partner_hand.clear()
58
64
  self.memory.processed_tricks_count = 0
65
+ self.memory.last_voids_key = None
59
66
 
60
67
  # Track all cards in completed tricks
61
68
  for trick in state.completed_tricks:
@@ -123,11 +130,15 @@ class AIPlayer:
123
130
  - Medium: weighted sum + personality jitter.
124
131
  - Hard: card-points-based + Jack/Ace bonuses.
125
132
  """
133
+ # The three special-bid heuristics each need a per-suit length count.
134
+ # Compute once and thread through; recomputing inside every branch
135
+ # was a measurable redundancy noted in the May-2026 perf audit.
136
+ lengths = self._suit_lengths(hand)
126
137
  if self.difficulty == Difficulty.EASY:
127
- return self._easy_special(hand)
138
+ return self._easy_special(hand, lengths)
128
139
  if self.difficulty == Difficulty.MEDIUM:
129
- return self._medium_special(hand, state)
130
- return self._hard_special(hand, state)
140
+ return self._medium_special(hand, state, lengths)
141
+ return self._hard_special(hand, state, lengths)
131
142
 
132
143
  @staticmethod
133
144
  def _suit_lengths(hand: tuple[Card, ...]) -> dict[Suit, int]:
@@ -139,7 +150,9 @@ class AIPlayer:
139
150
  lengths[c.suit] += 1
140
151
  return lengths
141
152
 
142
- def _easy_special(self, hand: tuple[Card, ...]) -> BidValue:
153
+ def _easy_special(
154
+ self, hand: tuple[Card, ...], lengths: dict[Suit, int]
155
+ ) -> BidValue:
143
156
  """Pick the contract that best fits the hand shape:
144
157
  - Tout Atout if Jack-heavy (≥3 Jacks/9s across ≥3 suits) — Jacks are
145
158
  the dominant card under TA in every suit.
@@ -153,12 +166,13 @@ class AIPlayer:
153
166
  return Suit.TOUT_ATOUT
154
167
 
155
168
  ace_ten_count = sum(1 for c in hand if c.rank in (Rank.ACE, Rank.TEN))
156
- lengths = self._suit_lengths(hand)
157
169
  if ace_ten_count >= 3 and max(lengths.values(), default=0) <= 3:
158
170
  return SANS_ATOUT_BID
159
171
  return None
160
172
 
161
- def _medium_special(self, hand: tuple[Card, ...], state: GameState) -> BidValue:
173
+ def _medium_special(
174
+ self, hand: tuple[Card, ...], state: GameState, lengths: dict[Suit, int]
175
+ ) -> BidValue:
162
176
  """Weighted score: TA leans on Jacks (each acts like a trump master in
163
177
  its own suit), SA leans on Aces and 10s with a flat-distribution bonus."""
164
178
  personality = self._rng.uniform(-0.5, 0.5)
@@ -173,7 +187,6 @@ class AIPlayer:
173
187
  # SA score: Aces and 10s win lead-suit tricks; flat distribution helps.
174
188
  sa_weights = {Rank.ACE: 3.0, Rank.TEN: 2.0, Rank.KING: 1.0}
175
189
  sa_score = sum(sa_weights.get(c.rank, 0.0) for c in hand)
176
- lengths = self._suit_lengths(hand)
177
190
  if max(lengths.values(), default=0) <= 3 and min(lengths.values(), default=8) >= 1:
178
191
  sa_score += 1.5 # flat-distribution bonus
179
192
 
@@ -188,7 +201,9 @@ class AIPlayer:
188
201
  return SANS_ATOUT_BID
189
202
  return None
190
203
 
191
- def _hard_special(self, hand: tuple[Card, ...], state: GameState) -> BidValue:
204
+ def _hard_special(
205
+ self, hand: tuple[Card, ...], state: GameState, lengths: dict[Suit, int]
206
+ ) -> BidValue:
192
207
  """Use actual card_points scales as the heuristic.
193
208
 
194
209
  TA: every card scores on the trump scale; threshold against the average
@@ -203,7 +218,6 @@ class AIPlayer:
203
218
  card_points_fn(c, None) for c in hand # type: ignore[arg-type, misc]
204
219
  )
205
220
  # Long suits are bad under SA — opponents won't follow your suit.
206
- lengths = self._suit_lengths(hand)
207
221
  long_suit_penalty = sum(max(0, n - 3) ** 2 for n in lengths.values()) * 4
208
222
  sa_score = sa_pts - long_suit_penalty
209
223
 
@@ -624,20 +638,40 @@ class AIPlayer:
624
638
 
625
639
  def _update_voids(self, state: GameState) -> None:
626
640
  """Infer voids incrementally."""
641
+ # Skip the work if neither the completed-count nor the current-trick
642
+ # length has changed since last call (decide_card may run multiple
643
+ # times for the same trick during e.g. lookahead exploration).
644
+ completed_count = len(state.completed_tricks)
645
+ key = (completed_count, len(state.current_trick))
646
+ if self.memory.last_voids_key == key:
647
+ return
648
+
649
+ # Le Républicain (or any deck/voucher that sets the flag): 7s and 8s
650
+ # are wild and may be played on any suit, so an off-suit 7/8 doesn't
651
+ # prove void in lead suit.
652
+ wild_active = bool(state._joker_state.get("republicain_wild"))
653
+
627
654
  # 1. Process new completed tricks
628
- while self.memory.processed_tricks_count < len(state.completed_tricks):
655
+ while self.memory.processed_tricks_count < completed_count:
629
656
  trick = state.completed_tricks[self.memory.processed_tricks_count]
630
- self._process_trick_voids(trick)
657
+ self._process_trick_voids(trick, wild_active)
631
658
  self.memory.processed_tricks_count += 1
632
659
 
633
660
  # 2. Process current trick (transient, so we don't increment processed_tricks_count)
634
- self._process_trick_voids(state.current_trick)
661
+ self._process_trick_voids(state.current_trick, wild_active)
662
+ self.memory.last_voids_key = key
635
663
 
636
- def _process_trick_voids(self, trick: tuple[TrickCard, ...]) -> None:
664
+ def _process_trick_voids(
665
+ self, trick: tuple[TrickCard, ...], wild_active: bool = False
666
+ ) -> None:
637
667
  """Analyze a trick for voids."""
638
668
  if len(trick) < 2:
639
669
  return
640
670
  lead_suit = trick[0].card.suit
641
671
  for tc in trick[1:]:
642
672
  if tc.card.suit != lead_suit:
673
+ # Under republicain_wild a 7 or 8 may be played on any suit,
674
+ # so an off-suit 7/8 doesn't prove void in the lead suit.
675
+ if wild_active and tc.card.rank in (Rank.SEVEN, Rank.EIGHT):
676
+ continue
643
677
  self.memory.known_voids[tc.seat].add(lead_suit)