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.
- {belote_cli-2.9.5 → belote_cli-3.0.2}/.claude/settings.local.json +2 -1
- {belote_cli-2.9.5 → belote_cli-3.0.2}/CHANGELOG.md +101 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/DEVELOPMENT.md +45 -8
- {belote_cli-2.9.5 → belote_cli-3.0.2}/PKG-INFO +26 -3
- {belote_cli-2.9.5 → belote_cli-3.0.2}/README.md +25 -2
- {belote_cli-2.9.5 → belote_cli-3.0.2}/pyproject.toml +1 -1
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/__init__.py +1 -1
- belote_cli-3.0.2/src/belote/a11y.py +93 -0
- belote_cli-3.0.2/src/belote/achievements.py +104 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/ai.py +48 -14
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/ansi.py +42 -18
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/core/scoring.py +38 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/engine/modifier_patch.py +2 -1
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/engine/round_driver.py +26 -0
- belote_cli-3.0.2/src/belote/belatro/ghost_run.py +83 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/base.py +19 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/registry.py +27 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/main.py +34 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/run/boss.py +50 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/run/shop.py +37 -2
- belote_cli-3.0.2/src/belote/belatro/run_summary.py +71 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/ui/announce.py +28 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/ui/hud.py +49 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/config.py +8 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/game.py +13 -4
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/gameflow.py +43 -2
- belote_cli-3.0.2/src/belote/replay.py +69 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/scoring.py +140 -37
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/stats.py +21 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/themes.py +35 -2
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/ui/menu.py +4 -4
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/ui/prompts.py +27 -26
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/ui/render.py +12 -11
- belote_cli-3.0.2/tests/belatro/test_dead_flag_fixes.py +780 -0
- belote_cli-3.0.2/tests/belatro/test_ghost_run.py +49 -0
- belote_cli-3.0.2/tests/belatro/test_hud_synergy.py +61 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_phase0_coverage.py +27 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_progression.py +63 -0
- belote_cli-3.0.2/tests/test_a11y.py +135 -0
- belote_cli-3.0.2/tests/test_achievements.py +58 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_ai.py +66 -0
- belote_cli-3.0.2/tests/test_ansi_helpers.py +162 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_belote.py +91 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_official_rules.py +3 -2
- belote_cli-3.0.2/tests/test_replay.py +48 -0
- belote_cli-2.9.5/tests/belatro/test_dead_flag_fixes.py +0 -290
- {belote_cli-2.9.5 → belote_cli-3.0.2}/.gitignore +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/.python-version +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/LICENSE +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/scripts/benchmark.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/__init__.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/__init__.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/core/__init__.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/core/economy.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/core/run_state.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/engine/__init__.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/engine/event_bus.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/__init__.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/jokers/__init__.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/jokers/annonces.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/jokers/coinche.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/jokers/contract.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/jokers/corrupted.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/jokers/economy.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/planets.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/tarots.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/items/vouchers.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/partner/__init__.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/partner/partner_state.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/partner/personality.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/partner/trust.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/progression/__init__.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/progression/save.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/progression/unlocks.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/run/__init__.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/run/ante.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/run/ante_themes.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/run/decks.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/ui/__init__.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/ui/collection.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/ui/menu.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/ui/rules.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/ui/shop.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/belatro/ui/trust_bar.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/context.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/deck.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/input.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/main.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/rules.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/ui/__init__.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/ui/announce.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/src/belote/ui/layout.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/__init__.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/__init__.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_belatro.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_boss_modifiers_integration.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_collection_logic.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_contract_unlocks.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_deck_variants.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_partner_trust.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_phase1_plumbing.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_phase2_content.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_phase3_meta.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/belatro/test_round_driver.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_extended.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_game_logic.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_gameflow.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_layout.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_new_coverage.py +0 -0
- {belote_cli-2.9.5 → belote_cli-3.0.2}/tests/test_properties.py +0 -0
- {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**:
|
|
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:
|
|
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
|
-
##
|
|
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 **
|
|
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
|
-
##
|
|
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 **
|
|
250
|
+
Currently **510 tests** passing with 100% coverage on game-logic modules.
|
|
228
251
|
|
|
229
252
|
## Technical Integrity
|
|
230
253
|
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
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 <
|
|
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(
|
|
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)
|