belote-cli 4.8.0__tar.gz → 4.8.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-4.8.0 → belote_cli-4.8.2}/CHANGELOG.md +149 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/DEVELOPMENT.md +1 -1
- {belote_cli-4.8.0 → belote_cli-4.8.2}/PKG-INFO +5 -5
- {belote_cli-4.8.0 → belote_cli-4.8.2}/README.md +4 -4
- {belote_cli-4.8.0 → belote_cli-4.8.2}/pyproject.toml +1 -1
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/__init__.py +1 -1
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ai.py +114 -28
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/core/scoring.py +30 -2
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/engine/round_driver.py +14 -11
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/progression/unlocks.py +10 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/announce.py +4 -4
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/game.py +11 -1
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/scoring.py +11 -0
- belote_cli-4.8.2/tests/belatro/test_competition_malediction.py +118 -0
- belote_cli-4.8.2/tests/belatro/test_handler_registry.py +110 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_phase1_plumbing.py +37 -0
- belote_cli-4.8.2/tests/test_ai_deluge_voids.py +144 -0
- belote_cli-4.8.2/tests/test_ai_sans_atout.py +127 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_belote.py +39 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_bidding_all_pass.py +75 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/.claude/settings.local.json +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/.gitignore +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/.python-version +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/LICENSE +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/scripts/benchmark.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/__init__.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/a11y.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/achievements.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ansi.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/__init__.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/core/__init__.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/core/economy.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/core/round_ledger.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/core/run_state.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/engine/__init__.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/engine/event_bus.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/engine/modifier_patch.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ghost_run.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/__init__.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/base.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/__init__.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/annonces.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/coinche.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/contract.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/corrupted.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/economy.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/planets.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/registry.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/tarots.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/items/vouchers.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/main.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/partner/__init__.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/partner/partner_state.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/partner/personality.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/partner/trust.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/progression/__init__.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/progression/save.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/run/__init__.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/run/ante.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/run/ante_themes.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/run/boss.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/run/decks.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/run/shop.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/run_summary.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/__init__.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/collection.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/consumables.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/history.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/hud.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/inventory.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/menu.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/rules.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/shop.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/belatro/ui/trust_bar.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/config.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/context.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/deck.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/gameflow.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/input.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/main.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/replay.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/rules.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/stats.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/themes.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ui/__init__.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ui/anim.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ui/announce.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ui/fit_guard.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ui/layout.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ui/menu.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ui/prompts.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/src/belote/ui/render.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/__init__.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/__init__.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_belatro.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_boss_contracts.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_boss_modifiers_integration.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_collection_logic.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_consumables_ui.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_contract_unlocks.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_dead_flag_fixes.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_deck_variants.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_decks_4_5.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_endless.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_event_bus.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_ghost_run.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_heist.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_history_overlay.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_hud_synergy.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_hud_toggle.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_inventory_overlay.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_joker_callouts.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_joker_contracts.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_jokers_4_5.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_partner_jokers.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_partner_trust.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_phase0_coverage.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_phase2_content.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_phase3_meta.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_progression.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_round_driver.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_run_summary.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_shop_animations.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_shop_empty_pools.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_slot_machine_tally.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/belatro/test_voucher_idempotency.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/perf_baselines.json +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_a11y.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_achievements.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_ai.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_alt_screen_scroll.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_anim_helpers.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_announce_stats.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_ansi_helpers.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_belote_stinger.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_benchmark_smoke.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_declaration_tiebreak.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_extended.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_game_logic.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_gameflow.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_hand_auto_sort.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_history_overlay_cache.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_input_eof.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_input_wasd.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_invariants.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_layout.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_new_coverage.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_no_color.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_official_rules.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_perf_regression.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_properties.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_render_diff.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_render_felt_polish.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_replay.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_undo.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/tests/test_winner_glow.py +0 -0
- {belote_cli-4.8.0 → belote_cli-4.8.2}/uv.lock +0 -0
|
@@ -5,6 +5,155 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [4.8.2] - 2026-05-25
|
|
9
|
+
|
|
10
|
+
Audit-driven correctness + perf-hygiene release. A deep multi-lane audit
|
|
11
|
+
(bug hunt + logic + performance + game-mechanics-implementation review)
|
|
12
|
+
surfaced two real Sans Atout AI bugs, a joker-registry footgun, a
|
|
13
|
+
joker-state alias hazard, and a handful of low-severity loose ends. All
|
|
14
|
+
findings landed; no rules changes; AI play is more accurate under SA.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **(B1, HIGH) Hard AI no longer mis-scores off-suit cards as "winning" under
|
|
19
|
+
Sans Atout** (`src/belote/ai.py::_score_winning_strategy`). Pre-4.8.2 the
|
|
20
|
+
candidate's rank was `_NONTRUMP_ORDER[card.rank]` regardless of suit, while
|
|
21
|
+
`highest_rank` was computed from lead-suit cards only. An off-suit Ace
|
|
22
|
+
(rank 7) compared against a lead-suit Jack (rank 3) fired `win_bonus` even
|
|
23
|
+
though `_card_beats` (game.py) forbids off-suit cards from winning under SA.
|
|
24
|
+
The AI burned high off-suit cards when forced to discard. Fix: pin `rank=-1`
|
|
25
|
+
for off-suit candidates so `rank > highest_rank` is unreachable.
|
|
26
|
+
- **(B2, MED) 2-ply opponent-trump-fear penalty suppressed under SA**
|
|
27
|
+
(`src/belote/ai.py::_score_winning_strategy`). The penalty assumes opponents
|
|
28
|
+
can trump the winner. Under SA `trump is None`, so the conditions
|
|
29
|
+
`trump not in opp_voids` (always True for `None`) and
|
|
30
|
+
`card.suit != trump` (always True) both passed — the AI penalized any
|
|
31
|
+
winning play by -8 when the right-hand opponent was known void in lead.
|
|
32
|
+
Wrapped in `if not is_sa:` so the entire block is moot under SA.
|
|
33
|
+
- **(B3, MED) `ScoreAccumulator._handler_index` rebuilds on `_jokers` mutation**
|
|
34
|
+
(`src/belote/belatro/core/scoring.py::_fire_jokers`). Pre-4.8.2 the lazy
|
|
35
|
+
gate was `not self._handler_index and self._jokers`, which is False once
|
|
36
|
+
`attach_jokers([])` ran (the index has 5 truthy keys with empty lists).
|
|
37
|
+
Tests that appended to `_jokers` after the empty attach were silently
|
|
38
|
+
ignored. Fix: identity-set comparison (`_attached_joker_ids`) against the
|
|
39
|
+
current `_jokers` list — any drift triggers `attach_jokers(self._jokers)`.
|
|
40
|
+
- **(B4, MED) `state._joker_state` alias preserved across post-trigger
|
|
41
|
+
mutations** (`src/belote/belatro/engine/round_driver.py`). The post-
|
|
42
|
+
`trigger_round_start` agent_double sabotage block used
|
|
43
|
+
`replace(state, _joker_state={...})`, which broke the ledger alias and
|
|
44
|
+
forced the heist block into a defensive dual-write. Replaced with in-place
|
|
45
|
+
mutation; the heist dual-write is gone too. Classic-Belote reads via
|
|
46
|
+
`state._joker_state.get(...)` once again see live ledger mutations for the
|
|
47
|
+
rest of the round.
|
|
48
|
+
- **(B5, LOW) `_compute_belote_points` asserts the announcer↔tracker
|
|
49
|
+
invariant** (`src/belote/scoring.py`). Hand-built states with
|
|
50
|
+
`belote_announcer` set but `belote_tracker[0]=False` would silently award
|
|
51
|
+
belote points; now raises AssertionError.
|
|
52
|
+
- **(B6, LOW) `ContractReward.honor_bonus` default switched to `int 0`**
|
|
53
|
+
(`src/belote/belatro/core/scoring.py`). Default was `0.0` (float) but the
|
|
54
|
+
TypedDict declared `int` — worked by numeric duck-typing but drifted from
|
|
55
|
+
the contract. No behavior change.
|
|
56
|
+
|
|
57
|
+
### Performance
|
|
58
|
+
|
|
59
|
+
- **(P1) Hard AI rank lookups consolidated** (`src/belote/ai.py::_hard_play`).
|
|
60
|
+
Pre-4.8.2 `_score_winning_strategy` called `trick_rank(card, trump, ...)`
|
|
61
|
+
once per candidate plus once per visible partner card — O(legal ×
|
|
62
|
+
partner_hand) recomputations. Now `_hard_play` builds `candidate_ranks`
|
|
63
|
+
and `partner_ranks` once, threaded through `_score_card_play` and
|
|
64
|
+
`_score_winning_strategy` as new kwargs. The SA off-suit `-1` short-circuit
|
|
65
|
+
from B1 is baked into the cache, so the canonical comparison is a dict
|
|
66
|
+
lookup.
|
|
67
|
+
- **(P2) Dropped redundant `from collections import Counter` local in
|
|
68
|
+
`_hard_play`** — `Counter` is already imported at module top.
|
|
69
|
+
|
|
70
|
+
### Internal / Doc
|
|
71
|
+
|
|
72
|
+
- **(L1) `GameState._joker_state` docstring spells out the BelAtro
|
|
73
|
+
ledger-alias contract** (`src/belote/game.py`). After
|
|
74
|
+
`trigger_round_start` the dict is shared with `acc._ledger.joker_state`;
|
|
75
|
+
round-driver mutates in place to preserve the alias. Classic callers
|
|
76
|
+
outside that scope still use `replace()`.
|
|
77
|
+
- **(L2) `UnlockTracker.on_event` logs at DEBUG on unhandled event types**
|
|
78
|
+
(`src/belote/belatro/progression/unlocks.py`). Silent no-op was the
|
|
79
|
+
contract, but a new unlock keyed off `TrickWonEvent` or `BeloteAnnouncedEvent`
|
|
80
|
+
would silently fail — the log line makes the miss visible under
|
|
81
|
+
`BELOTE_LOG_LEVEL=DEBUG`.
|
|
82
|
+
- **(L3) `AIMemory.last_partner_hand_key` includes `hide_partner_hand`**
|
|
83
|
+
(`src/belote/ai.py`). Memo invalidates if the boss flag flips mid-round.
|
|
84
|
+
Defensive — no boss toggles it today, but a future voucher/joker that
|
|
85
|
+
changes visibility mid-round would otherwise see stale cached partner
|
|
86
|
+
cards.
|
|
87
|
+
|
|
88
|
+
### Tests
|
|
89
|
+
|
|
90
|
+
- **New file `tests/test_ai_sans_atout.py`** (3 tests) — pins B1 (off-suit
|
|
91
|
+
Ace not burned), B1 unit-level (no `win_bonus` for off-suit candidate),
|
|
92
|
+
B2 (2-ply penalty suppressed under SA).
|
|
93
|
+
- **New file `tests/test_ai_deluge_voids.py`** (4 tests) — pins void
|
|
94
|
+
inference under La Déluge alone, Le Républicain alone, and both
|
|
95
|
+
combined; plus the negative case (non-wild off-lead card still proves
|
|
96
|
+
void under Le Républicain).
|
|
97
|
+
- **New file `tests/belatro/test_handler_registry.py`** (3 tests) — B3
|
|
98
|
+
regression pins: `attach_jokers([])` + append, attach + replace, and the
|
|
99
|
+
no-drift no-rebuild sanity check.
|
|
100
|
+
- **New file `tests/belatro/test_competition_malediction.py`** (3 tests)
|
|
101
|
+
— documents the actual stacking behaviour of La Compétition + La
|
|
102
|
+
Malédiction under `score_round`.
|
|
103
|
+
- **Extended `tests/test_belote.py`** with B5 assertion test.
|
|
104
|
+
- **Extended `tests/belatro/test_phase1_plumbing.py`** with B4 alias
|
|
105
|
+
survival test.
|
|
106
|
+
- **Extended `tests/test_bidding_all_pass.py`** with end-to-end litige
|
|
107
|
+
pool consume after two all-pass redeals (T1).
|
|
108
|
+
- Test count baseline: **1044** (4.8.1 had 1028; +16 in 4.8.2).
|
|
109
|
+
|
|
110
|
+
### Speculative perf (measured, not implemented)
|
|
111
|
+
|
|
112
|
+
Two speculative items from the plan (P4 `legal_cards` key-build cost; P5
|
|
113
|
+
`ledger.transactional` wrapper overhead) were instrumented and closed.
|
|
114
|
+
Measurement: legal_cards is ~3 % of round time (below the 5 % threshold to
|
|
115
|
+
justify a hand-id-on-tuple refactor); BelAtro round mean is 13.69 ms /
|
|
116
|
+
p95 18.49 ms (73 rounds/sec) — well below any user-visible jank threshold.
|
|
117
|
+
|
|
118
|
+
## [4.8.1] - 2026-05-21
|
|
119
|
+
|
|
120
|
+
Maintenance release: outcome of a thorough multi-lane audit (logic / performance / state management, then four deeper passes). Audit headline: **zero critical or high-severity bugs** survived verification. The two micro-fixes below are pure consistency / micro-optimization. No rules, scoring, AI behavior, or UI behavior changes — `belatro` and `belote` play exactly the same.
|
|
121
|
+
|
|
122
|
+
### Changed
|
|
123
|
+
|
|
124
|
+
- **`BelAtroAnnounce.boss_reveal` harmonized onto the post-4.8.0
|
|
125
|
+
`reader.read_timeout(...)` pattern** (`src/belote/belatro/ui/announce.py`).
|
|
126
|
+
Pre-4.8.1 used `interruptible_sleep(N, reader)` at three sites, which
|
|
127
|
+
routes through a module-level `select.select` call that doesn't go
|
|
128
|
+
through the `KeyReader` interface. `banner()` (just below `boss_reveal`
|
|
129
|
+
in the same file) was already on the new pattern; this aligns
|
|
130
|
+
`boss_reveal` so test stubs can interpose at the reader interface
|
|
131
|
+
without monkey-patching `belote.input.interruptible_sleep`. Runtime
|
|
132
|
+
behavior is byte-identical — any key still skips the reveal.
|
|
133
|
+
- **`AIPlayer._medium_play` precomputes `trick_rank` once per decision**
|
|
134
|
+
(`src/belote/ai.py`). The pre-4.8.1 code recomputed `trick_rank(c,
|
|
135
|
+
trump, self._se)` inside each `min(...)`/`max(...)` walk plus inside
|
|
136
|
+
the void-trump "winners" filter — five+ recomputations per AI play.
|
|
137
|
+
Now a single `ranks = {c: trick_rank(c, trump, self._se) for c in
|
|
138
|
+
legal}` is built up-front and reused via `ranks.__getitem__` and
|
|
139
|
+
`ranks[c]` lookups. Saves roughly 1200 `trick_rank` calls per round
|
|
140
|
+
(~60 µs) and reads cleaner. Decision output is unchanged.
|
|
141
|
+
|
|
142
|
+
### Internal
|
|
143
|
+
|
|
144
|
+
- Audit found and documented six **verified-false** findings from agent
|
|
145
|
+
passes (LeNotaire/LeRebelle `is_rebelote` gate, ToutStreak streak
|
|
146
|
+
persistence, `pulse_winner_glow` banner persistence, Libra + auto_coinche
|
|
147
|
+
on chute, AI `_hard_play` SA-trump condition, `boss_reveal` print()
|
|
148
|
+
scrolling). All re-investigated against current code and confirmed
|
|
149
|
+
correct as designed. Plan file
|
|
150
|
+
`/home/mrrobot/.claude/plans/bug-hunt-code-performance-greedy-perlis.md`
|
|
151
|
+
has the full list so future audits don't re-raise them.
|
|
152
|
+
- Test count baseline unchanged: **1028**. No new tests; the changes are
|
|
153
|
+
behavior-preserving refactors and the existing suite covers them
|
|
154
|
+
(`test_ai.py` for the AI path, `test_phase2_content.py` for joker
|
|
155
|
+
flow; `boss_reveal` is exercised indirectly through ante setup).
|
|
156
|
+
|
|
8
157
|
## [4.8.0] - 2026-05-21
|
|
9
158
|
|
|
10
159
|
Feature release: a twelfth theme, a coordinated animation polish pass for
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: belote-cli
|
|
3
|
-
Version: 4.8.
|
|
3
|
+
Version: 4.8.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
|
|
@@ -203,7 +203,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
|
|
|
203
203
|
|
|
204
204
|
## Features
|
|
205
205
|
|
|
206
|
-
- **BelAtro Roguelite Mode:** A massive expansion featuring 42
|
|
206
|
+
- **BelAtro Roguelite Mode:** A massive expansion featuring 42 jokers, 12 tarots, 8 planets, 12 vouchers, 12 decks, 21 bosses, and permanent upgrades.
|
|
207
207
|
- **Dix de Der Heist (4.7.0):** Take a contract and declare a heist before trick 1 — win trick 8 for a `×(1 + interest_rate)` Mult multiplier on your round score; lose it and forfeit your card chips from tricks 1–7. Gated on owning **La Voûte** voucher (`interest_rate > 0`). AI never declares.
|
|
208
208
|
- **Slot-Machine Score Tally (4.7.0):** Per-trick odometer animation replaces the static popup. Chip-bucket fills, mult pulses, total ticks toward target, and a flame row crowns the odometer when you blow past 120% of the blind. Skippable on SPACE / ESC / ENTER. Final readout persists in the HUD between tricks (toggled by `I` alongside the top HUD). Suppressed under Le Brouillard (`hide_hud`) and La Compétition (`separate_scoring`).
|
|
209
209
|
- **Inventory Overlay on V (4.7.0):** Press `V` mid-game to inspect everything you own — jokers (with edition tags + per-edition bonus blurb), vouchers, consumables, permanent chip / mult bonuses, and per-contract planet levels. List view → ↑/↓ navigate, Enter for detail, Esc/V/Q close. Read-only counterpart to the `C` consumables-action tray.
|
|
@@ -264,7 +264,7 @@ belote/
|
|
|
264
264
|
│ ├── input.py # Platform-dispatched key reader and interruptible sleep
|
|
265
265
|
│ ├── stats.py # Global and session statistics tracking
|
|
266
266
|
│ └── rules.py # Game rules content
|
|
267
|
-
├── tests/ # Comprehensive test suite (
|
|
267
|
+
├── tests/ # Comprehensive test suite (1044 tests)
|
|
268
268
|
├── scripts/ # Performance benchmarks
|
|
269
269
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
270
270
|
├── LICENSE # MIT License
|
|
@@ -279,14 +279,14 @@ belote/
|
|
|
279
279
|
PYTHONPATH=src pytest
|
|
280
280
|
```
|
|
281
281
|
|
|
282
|
-
Currently **
|
|
282
|
+
Currently **1044 tests** passing with 100% coverage on game-logic modules (4.8.2).
|
|
283
283
|
|
|
284
284
|
## Technical Integrity
|
|
285
285
|
|
|
286
286
|
The codebase is strictly validated with the following tools:
|
|
287
287
|
- **mypy**: 0 errors (strict type safety)
|
|
288
288
|
- **ruff**: 0 violations (linting & formatting)
|
|
289
|
-
- **pytest**:
|
|
289
|
+
- **pytest**: 1044/1044 passed
|
|
290
290
|
- **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
|
|
291
291
|
- **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
|
|
292
292
|
|
|
@@ -160,7 +160,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
|
|
|
160
160
|
|
|
161
161
|
## Features
|
|
162
162
|
|
|
163
|
-
- **BelAtro Roguelite Mode:** A massive expansion featuring 42
|
|
163
|
+
- **BelAtro Roguelite Mode:** A massive expansion featuring 42 jokers, 12 tarots, 8 planets, 12 vouchers, 12 decks, 21 bosses, and permanent upgrades.
|
|
164
164
|
- **Dix de Der Heist (4.7.0):** Take a contract and declare a heist before trick 1 — win trick 8 for a `×(1 + interest_rate)` Mult multiplier on your round score; lose it and forfeit your card chips from tricks 1–7. Gated on owning **La Voûte** voucher (`interest_rate > 0`). AI never declares.
|
|
165
165
|
- **Slot-Machine Score Tally (4.7.0):** Per-trick odometer animation replaces the static popup. Chip-bucket fills, mult pulses, total ticks toward target, and a flame row crowns the odometer when you blow past 120% of the blind. Skippable on SPACE / ESC / ENTER. Final readout persists in the HUD between tricks (toggled by `I` alongside the top HUD). Suppressed under Le Brouillard (`hide_hud`) and La Compétition (`separate_scoring`).
|
|
166
166
|
- **Inventory Overlay on V (4.7.0):** Press `V` mid-game to inspect everything you own — jokers (with edition tags + per-edition bonus blurb), vouchers, consumables, permanent chip / mult bonuses, and per-contract planet levels. List view → ↑/↓ navigate, Enter for detail, Esc/V/Q close. Read-only counterpart to the `C` consumables-action tray.
|
|
@@ -221,7 +221,7 @@ belote/
|
|
|
221
221
|
│ ├── input.py # Platform-dispatched key reader and interruptible sleep
|
|
222
222
|
│ ├── stats.py # Global and session statistics tracking
|
|
223
223
|
│ └── rules.py # Game rules content
|
|
224
|
-
├── tests/ # Comprehensive test suite (
|
|
224
|
+
├── tests/ # Comprehensive test suite (1044 tests)
|
|
225
225
|
├── scripts/ # Performance benchmarks
|
|
226
226
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
227
227
|
├── LICENSE # MIT License
|
|
@@ -236,14 +236,14 @@ belote/
|
|
|
236
236
|
PYTHONPATH=src pytest
|
|
237
237
|
```
|
|
238
238
|
|
|
239
|
-
Currently **
|
|
239
|
+
Currently **1044 tests** passing with 100% coverage on game-logic modules (4.8.2).
|
|
240
240
|
|
|
241
241
|
## Technical Integrity
|
|
242
242
|
|
|
243
243
|
The codebase is strictly validated with the following tools:
|
|
244
244
|
- **mypy**: 0 errors (strict type safety)
|
|
245
245
|
- **ruff**: 0 violations (linting & formatting)
|
|
246
|
-
- **pytest**:
|
|
246
|
+
- **pytest**: 1044/1044 passed
|
|
247
247
|
- **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
|
|
248
248
|
- **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
|
|
249
249
|
|
|
@@ -39,10 +39,12 @@ class AIMemory:
|
|
|
39
39
|
# (completed_count, current_trick_len) of the last _update_voids call.
|
|
40
40
|
# Lets us skip re-scanning a stable transient trick on each decision.
|
|
41
41
|
self.last_voids_key: tuple[int, int] | None = None
|
|
42
|
-
# (completed_count, current_trick_len) of the
|
|
43
|
-
# rebuild. The partner's hand only changes when
|
|
44
|
-
# a new round starts; same memo pattern as
|
|
45
|
-
|
|
42
|
+
# (completed_count, current_trick_len, hide_partner_hand) of the
|
|
43
|
+
# last partner_hand rebuild. The partner's hand only changes when
|
|
44
|
+
# they play a card or a new round starts; same memo pattern as
|
|
45
|
+
# `last_voids_key`. The third element (`hide_partner_hand`) is the
|
|
46
|
+
# 4.8.2 L3 addition — invalidates the memo if the boss flag flips.
|
|
47
|
+
self.last_partner_hand_key: tuple[int, int, bool] | None = None
|
|
46
48
|
|
|
47
49
|
|
|
48
50
|
class AIPlayer:
|
|
@@ -111,14 +113,18 @@ class AIPlayer:
|
|
|
111
113
|
# It only changes when partner plays a card (i.e. when
|
|
112
114
|
# (completed_count, current_count) advances). Memo on the same key
|
|
113
115
|
# the void cache uses; skip the rebuild on a no-op repeat call.
|
|
114
|
-
|
|
116
|
+
#
|
|
117
|
+
# 4.8.2 (L3): include `hide_partner_hand` in the memo key so the
|
|
118
|
+
# memo invalidates if the boss flag flips mid-round. The flag is
|
|
119
|
+
# set at round start in current code, but the assumption is
|
|
120
|
+
# defensive — a future boss or voucher that toggles visibility
|
|
121
|
+
# mid-round would otherwise see stale cached partner cards.
|
|
122
|
+
hide = bool(state.boss_modifiers.hide_partner_hand)
|
|
123
|
+
partner_key: tuple[int, int, bool] = (completed_count, current_count, hide)
|
|
115
124
|
if self.memory.last_partner_hand_key != partner_key:
|
|
116
125
|
p = partner(self.seat)
|
|
117
126
|
self.memory.partner_hand.clear()
|
|
118
|
-
if (
|
|
119
|
-
state.phase in (Phase.PLAYING, Phase.SCORING)
|
|
120
|
-
and not state.boss_modifiers.hide_partner_hand
|
|
121
|
-
):
|
|
127
|
+
if state.phase in (Phase.PLAYING, Phase.SCORING) and not hide:
|
|
122
128
|
for card in state.hand_of(p):
|
|
123
129
|
self.memory.partner_hand.add(card)
|
|
124
130
|
self.memory.last_partner_hand_key = partner_key
|
|
@@ -399,6 +405,11 @@ class AIPlayer:
|
|
|
399
405
|
# Leading
|
|
400
406
|
return self._medium_lead(legal, trump, state)
|
|
401
407
|
|
|
408
|
+
# 4.8.1: cache trick_rank per card so each min/max walk is O(n) lookups
|
|
409
|
+
# rather than O(n) recomputations. Same for card_points_with_modifiers
|
|
410
|
+
# in the discard branches.
|
|
411
|
+
ranks = {c: trick_rank(c, trump, self._se) for c in legal}
|
|
412
|
+
|
|
402
413
|
lead_card = trick[0].card
|
|
403
414
|
lead_suit = lead_card.suit
|
|
404
415
|
p = partner(self.seat)
|
|
@@ -412,7 +423,7 @@ class AIPlayer:
|
|
|
412
423
|
|
|
413
424
|
if partner_winning and lead_suit != trump:
|
|
414
425
|
# Partner winning, discard low
|
|
415
|
-
return min(legal, key=
|
|
426
|
+
return min(legal, key=ranks.__getitem__)
|
|
416
427
|
|
|
417
428
|
if lead_suit == trump:
|
|
418
429
|
# Trump led
|
|
@@ -420,9 +431,9 @@ class AIPlayer:
|
|
|
420
431
|
if my_trumps:
|
|
421
432
|
if current_winner is not None and current_winner == p:
|
|
422
433
|
# Partner winning trump, play lowest trump
|
|
423
|
-
return min(my_trumps, key=
|
|
434
|
+
return min(my_trumps, key=ranks.__getitem__)
|
|
424
435
|
# Try to win if we can afford it
|
|
425
|
-
return max(my_trumps, key=
|
|
436
|
+
return max(my_trumps, key=ranks.__getitem__)
|
|
426
437
|
# No trumps, discard low non-trump
|
|
427
438
|
return min(legal, key=lambda c: card_points_with_modifiers(c, trump, bm))
|
|
428
439
|
|
|
@@ -431,8 +442,8 @@ class AIPlayer:
|
|
|
431
442
|
if my_suit:
|
|
432
443
|
# Must follow
|
|
433
444
|
if current_winner is not None and current_winner == p:
|
|
434
|
-
return min(my_suit, key=
|
|
435
|
-
return max(my_suit, key=
|
|
445
|
+
return min(my_suit, key=ranks.__getitem__)
|
|
446
|
+
return max(my_suit, key=ranks.__getitem__)
|
|
436
447
|
|
|
437
448
|
# Void - must trump or discard
|
|
438
449
|
my_trumps = [c for c in legal if c.suit == trump]
|
|
@@ -442,10 +453,10 @@ class AIPlayer:
|
|
|
442
453
|
highest_in_trick = max(
|
|
443
454
|
(trick_rank(tc.card, trump, self._se) for tc in trick), default=-1
|
|
444
455
|
)
|
|
445
|
-
winners = [c for c in my_trumps if
|
|
456
|
+
winners = [c for c in my_trumps if ranks[c] > highest_in_trick]
|
|
446
457
|
if winners:
|
|
447
|
-
return min(winners, key=
|
|
448
|
-
return min(my_trumps, key=
|
|
458
|
+
return min(winners, key=ranks.__getitem__)
|
|
459
|
+
return min(my_trumps, key=ranks.__getitem__)
|
|
449
460
|
|
|
450
461
|
return min(legal, key=lambda c: card_points_with_modifiers(c, trump, bm))
|
|
451
462
|
|
|
@@ -592,8 +603,8 @@ class AIPlayer:
|
|
|
592
603
|
# Precompute per-call counters used by every scoring branch — pre-3.1.0
|
|
593
604
|
# these were recomputed per candidate card (n×4 walks of the hand and
|
|
594
605
|
# memory.played for each legal card).
|
|
595
|
-
from collections import Counter
|
|
596
|
-
|
|
606
|
+
# 4.8.2 (P2): dropped redundant `from collections import Counter`
|
|
607
|
+
# local import — `Counter` is already imported at module top.
|
|
597
608
|
my_hand = state.hand_of(self.seat)
|
|
598
609
|
hand_suit_counts: dict[Suit, int] = Counter(c.suit for c in my_hand)
|
|
599
610
|
# Under Tout Atout every card is a trump; under a normal contract
|
|
@@ -643,6 +654,28 @@ class AIPlayer:
|
|
|
643
654
|
next_opp = self.seat.next_seat()
|
|
644
655
|
opp_voids = self.memory.known_voids.get(next_opp, set())
|
|
645
656
|
|
|
657
|
+
# 4.8.2 (P1): pre-compute per-candidate rank once. `_score_winning_strategy`
|
|
658
|
+
# called `trick_rank(card, trump, self._se)` once for the candidate and
|
|
659
|
+
# again for every visible partner card on every legal candidate
|
|
660
|
+
# iteration — O(legal × partner_hand) recomputations. Building dicts
|
|
661
|
+
# here collapses that to O(legal + partner_hand) lookups.
|
|
662
|
+
# Under SA, off-suit candidates get rank -1 (B1 short-circuit) so they
|
|
663
|
+
# cannot win the trick; lead-suit candidates use `_NONTRUMP_ORDER`.
|
|
664
|
+
lead_suit_for_ranks = trick[0].card.suit if trick else None
|
|
665
|
+
if is_sa:
|
|
666
|
+
candidate_ranks: dict[Card, int] = {
|
|
667
|
+
c: (_NONTRUMP_ORDER[c.rank] if c.suit == lead_suit_for_ranks else -1)
|
|
668
|
+
for c in legal
|
|
669
|
+
}
|
|
670
|
+
partner_ranks: dict[Card, int] = {
|
|
671
|
+
c: _NONTRUMP_ORDER[c.rank] for c in self.memory.partner_hand
|
|
672
|
+
}
|
|
673
|
+
else:
|
|
674
|
+
candidate_ranks = {c: trick_rank(c, trump, self._se) for c in legal}
|
|
675
|
+
partner_ranks = {
|
|
676
|
+
c: trick_rank(c, trump, self._se) for c in self.memory.partner_hand
|
|
677
|
+
}
|
|
678
|
+
|
|
646
679
|
# Score each legal card by expected outcome
|
|
647
680
|
best_card = legal[0]
|
|
648
681
|
best_score: float = -999.0
|
|
@@ -660,6 +693,8 @@ class AIPlayer:
|
|
|
660
693
|
is_sa=is_sa,
|
|
661
694
|
highest_rank=highest_rank,
|
|
662
695
|
opp_voids=opp_voids,
|
|
696
|
+
card_rank=candidate_ranks[card],
|
|
697
|
+
partner_ranks=partner_ranks,
|
|
663
698
|
)
|
|
664
699
|
if score > best_score:
|
|
665
700
|
best_score = score
|
|
@@ -680,8 +715,18 @@ class AIPlayer:
|
|
|
680
715
|
is_sa: bool = False,
|
|
681
716
|
highest_rank: int = -1,
|
|
682
717
|
opp_voids: set[Suit] | None = None,
|
|
718
|
+
*,
|
|
719
|
+
card_rank: int | None = None,
|
|
720
|
+
partner_ranks: dict[Card, int] | None = None,
|
|
683
721
|
) -> float:
|
|
684
|
-
"""Score a card play decision with advanced heuristics.
|
|
722
|
+
"""Score a card play decision with advanced heuristics.
|
|
723
|
+
|
|
724
|
+
4.8.2 (P1): `card_rank` and `partner_ranks` are pre-computed by
|
|
725
|
+
`_hard_play` once per call and threaded through so the per-candidate
|
|
726
|
+
recomputation of `trick_rank` / `_NONTRUMP_ORDER` is gone. Both
|
|
727
|
+
default to `None` for standalone callers (tests) — the downstream
|
|
728
|
+
helper falls back to deriving them itself.
|
|
729
|
+
"""
|
|
685
730
|
score = 0.0
|
|
686
731
|
# 4.1.1: per-card point value must honor zero-rank boss flags
|
|
687
732
|
# (kings_zero / aces_zero / jacks_zero / tens_zero / ban_clubs).
|
|
@@ -713,6 +758,8 @@ class AIPlayer:
|
|
|
713
758
|
is_sa=is_sa,
|
|
714
759
|
highest_rank=highest_rank,
|
|
715
760
|
opp_voids=opp_voids,
|
|
761
|
+
card_rank=card_rank,
|
|
762
|
+
partner_ranks=partner_ranks,
|
|
716
763
|
)
|
|
717
764
|
|
|
718
765
|
def _score_leading_strategy(
|
|
@@ -773,6 +820,8 @@ class AIPlayer:
|
|
|
773
820
|
*,
|
|
774
821
|
highest_rank: int | None = None,
|
|
775
822
|
opp_voids: set[Suit] | None = None,
|
|
823
|
+
card_rank: int | None = None,
|
|
824
|
+
partner_ranks: dict[Card, int] | None = None,
|
|
776
825
|
) -> float:
|
|
777
826
|
"""Heuristics for trying to win the trick or ducking.
|
|
778
827
|
|
|
@@ -783,9 +832,31 @@ class AIPlayer:
|
|
|
783
832
|
per card was pure waste. Both default to `None` so standalone
|
|
784
833
|
callers (tests, ad-hoc heuristics) can omit them and the function
|
|
785
834
|
will derive them itself.
|
|
835
|
+
|
|
836
|
+
4.8.2 (P1): `card_rank` and `partner_ranks` are similarly hoisted.
|
|
837
|
+
`card_rank` is the candidate's pre-computed rank under the active
|
|
838
|
+
scale (`trick_rank` or `_NONTRUMP_ORDER`, with the B1 off-suit-SA
|
|
839
|
+
short-circuit baked in). `partner_ranks` is the rank lookup for
|
|
840
|
+
every visible card in `self.memory.partner_hand`. Both default to
|
|
841
|
+
`None` so standalone callers (tests) can omit them; the function
|
|
842
|
+
derives them itself.
|
|
786
843
|
"""
|
|
787
844
|
score = 0.0
|
|
788
|
-
|
|
845
|
+
lead_suit = trick[0].card.suit
|
|
846
|
+
# B1 (4.8.2): under Sans Atout an off-suit card cannot win the trick
|
|
847
|
+
# (per `_card_beats` in game.py). Pin `rank = -1` for off-suit
|
|
848
|
+
# candidates so `rank > highest_rank` is unreachable — pre-4.8.2 an
|
|
849
|
+
# off-suit Ace got rank=7 against highest_rank≤7 (lead-suit only) and
|
|
850
|
+
# fired `win_bonus`, causing the AI to dump high off-suit cards under
|
|
851
|
+
# SA when forced to discard.
|
|
852
|
+
if card_rank is not None:
|
|
853
|
+
rank = card_rank
|
|
854
|
+
elif is_sa and card.suit != lead_suit:
|
|
855
|
+
rank = -1
|
|
856
|
+
elif is_sa:
|
|
857
|
+
rank = _NONTRUMP_ORDER[card.rank]
|
|
858
|
+
else:
|
|
859
|
+
rank = trick_rank(card, trump, self._se)
|
|
789
860
|
is_last_trick = len(state.completed_tricks) == 7
|
|
790
861
|
if highest_rank is None:
|
|
791
862
|
highest_rank = max(
|
|
@@ -794,13 +865,12 @@ class AIPlayer:
|
|
|
794
865
|
if not is_sa
|
|
795
866
|
else _NONTRUMP_ORDER[tc.card.rank]
|
|
796
867
|
for tc in trick
|
|
797
|
-
if not is_sa or tc.card.suit ==
|
|
868
|
+
if not is_sa or tc.card.suit == lead_suit
|
|
798
869
|
),
|
|
799
870
|
default=-1,
|
|
800
871
|
)
|
|
801
872
|
if opp_voids is None:
|
|
802
873
|
opp_voids = self.memory.known_voids.get(self.seat.next_seat(), set())
|
|
803
|
-
lead_suit = trick[0].card.suit
|
|
804
874
|
p = partner(self.seat)
|
|
805
875
|
|
|
806
876
|
if rank > highest_rank:
|
|
@@ -808,7 +878,12 @@ class AIPlayer:
|
|
|
808
878
|
score += win_bonus
|
|
809
879
|
|
|
810
880
|
# 2-PLY: If we are winning now, will the next player beat us?
|
|
811
|
-
|
|
881
|
+
# B2 (4.8.2): suppressed under Sans Atout — there is no trump,
|
|
882
|
+
# so opponents cannot trump our winner; the entire heuristic is
|
|
883
|
+
# moot. Pre-4.8.2 it fired (since `None not in opp_voids` is True
|
|
884
|
+
# and `card.suit != trump` is always True under SA), incorrectly
|
|
885
|
+
# penalizing winning plays by -8.
|
|
886
|
+
if not is_sa and len(trick) < 3:
|
|
812
887
|
next_opp = self.seat.next_seat()
|
|
813
888
|
if (
|
|
814
889
|
next_opp != p
|
|
@@ -841,14 +916,25 @@ class AIPlayer:
|
|
|
841
916
|
# happens to fall through to the same ordering today, but the SA
|
|
842
917
|
# path is the canonical one and any future divergence in trick_rank
|
|
843
918
|
# would silently break this heuristic.
|
|
919
|
+
#
|
|
920
|
+
# 4.8.2 (P1): rank lookups thread through `partner_ranks` (built
|
|
921
|
+
# once in `_hard_play`) and the candidate's own `card_rank` is now
|
|
922
|
+
# the canonical per-call rank. We compare against partner cards in
|
|
923
|
+
# the same suit using the SAME scale we used for the candidate — so
|
|
924
|
+
# for an off-suit-SA candidate (rank=-1) the comparison degrades to
|
|
925
|
+
# "any same-suit partner card strictly stronger" which is always
|
|
926
|
+
# true; harmless because the candidate doesn't win the trick.
|
|
844
927
|
if self.memory.partner_hand:
|
|
845
|
-
|
|
846
|
-
_NONTRUMP_ORDER[card.rank]
|
|
847
|
-
|
|
928
|
+
if is_sa:
|
|
929
|
+
my_rank = _NONTRUMP_ORDER[card.rank]
|
|
930
|
+
else:
|
|
931
|
+
my_rank = rank if card_rank is not None else trick_rank(card, trump, self._se)
|
|
848
932
|
for partner_card in self.memory.partner_hand:
|
|
849
933
|
if partner_card.suit != card.suit:
|
|
850
934
|
continue
|
|
851
|
-
if
|
|
935
|
+
if partner_ranks is not None and partner_card in partner_ranks:
|
|
936
|
+
partner_rank = partner_ranks[partner_card]
|
|
937
|
+
elif is_sa:
|
|
852
938
|
partner_rank = _NONTRUMP_ORDER[partner_card.rank]
|
|
853
939
|
else:
|
|
854
940
|
partner_rank = trick_rank(partner_card, trump, self._se)
|
|
@@ -94,6 +94,13 @@ class ScoreAccumulator:
|
|
|
94
94
|
# cascade in `_fire_jokers`. Saves ~50–80μs/round (5 jokers × ~25
|
|
95
95
|
# events × ~0.5μs per getattr).
|
|
96
96
|
_handler_index: dict[str, list[tuple[Joker, Any]]] = field(default_factory=dict)
|
|
97
|
+
# 4.8.2 (B3): identity-set of the jokers passed to the most recent
|
|
98
|
+
# `attach_jokers` call. Compared against `set(map(id, self._jokers))`
|
|
99
|
+
# in `_fire_jokers` to detect post-attach mutation. `None` until the
|
|
100
|
+
# first attach; `set()` after `attach_jokers([])` (which is truthy-
|
|
101
|
+
# distinguishable from None). Excluded from `compare/repr` so test
|
|
102
|
+
# state-equality assertions don't break.
|
|
103
|
+
_attached_joker_ids: set[int] | None = field(default=None, compare=False, repr=False)
|
|
97
104
|
# 4.6.2: RoundLedger lifetime is one round; created in
|
|
98
105
|
# trigger_round_start, sealed in seal_round / materialize. Optional
|
|
99
106
|
# because tests that construct an accumulator without calling
|
|
@@ -128,6 +135,12 @@ class ScoreAccumulator:
|
|
|
128
135
|
continue # joker didn't override this handler
|
|
129
136
|
index[method_name].append((joker, method))
|
|
130
137
|
self._handler_index = index
|
|
138
|
+
# 4.8.2 (B3): remember exactly which joker objects we registered for so
|
|
139
|
+
# `_fire_jokers` can detect post-attach mutation of `self._jokers` and
|
|
140
|
+
# rebuild before firing. A no-override joker still gets tracked here
|
|
141
|
+
# (so it doesn't trigger spurious rebuilds), even though it has no
|
|
142
|
+
# entries in `_handler_index`.
|
|
143
|
+
self._attached_joker_ids = {id(j) for j in jokers}
|
|
131
144
|
|
|
132
145
|
def _make_ledger(self, state: GameState) -> RoundLedger:
|
|
133
146
|
from .round_ledger import RoundLedger
|
|
@@ -283,7 +296,18 @@ class ScoreAccumulator:
|
|
|
283
296
|
# so the per-event getattr cascade is gone. Lazy build on first
|
|
284
297
|
# use if a caller forgot to call `attach_jokers` (this also
|
|
285
298
|
# protects tests that mutate `self._jokers` directly).
|
|
286
|
-
|
|
299
|
+
#
|
|
300
|
+
# 4.8.2 (B3): also rebuild if the current `_jokers` list differs
|
|
301
|
+
# from the identity-set captured at the most recent attach. Pre-
|
|
302
|
+
# 4.8.2 the gate was `not _handler_index and _jokers` — False once
|
|
303
|
+
# `attach_jokers([])` runs (the index has 5 truthy keys with empty
|
|
304
|
+
# lists), so tests that appended to `_jokers` after the empty
|
|
305
|
+
# attach were silently ignored. Identity-set comparison catches
|
|
306
|
+
# that case and any other post-attach mutation.
|
|
307
|
+
if self._attached_joker_ids is None:
|
|
308
|
+
if self._jokers:
|
|
309
|
+
self.attach_jokers(self._jokers)
|
|
310
|
+
elif self._attached_joker_ids != {id(j) for j in self._jokers}:
|
|
287
311
|
self.attach_jokers(self._jokers)
|
|
288
312
|
for joker, method in self._handler_index.get(method_name, ()):
|
|
289
313
|
# 4.6.4: per-joker transactional + EventBus-style isolation.
|
|
@@ -379,7 +403,11 @@ class ScoreAccumulator:
|
|
|
379
403
|
# The Moon (Sans Atout): honor bonus per honor won
|
|
380
404
|
if event.trump is None:
|
|
381
405
|
moon_reward = self.contract_levels.get("sans_atout", {})
|
|
382
|
-
|
|
406
|
+
# 4.8.2 (B6): default to `0` (int) so the type-narrowed
|
|
407
|
+
# path stays aligned with the `ContractReward.honor_bonus:
|
|
408
|
+
# int` TypedDict declaration. The 0.0 default worked by
|
|
409
|
+
# accident under Python's numeric duck-typing.
|
|
410
|
+
honor_bonus = moon_reward.get("honor_bonus", 0)
|
|
383
411
|
if honor_bonus:
|
|
384
412
|
honors = sum(
|
|
385
413
|
1 for c in event.cards
|
|
@@ -179,6 +179,13 @@ def drive_round(
|
|
|
179
179
|
# Sources: L'Agent Double boss (3 random tricks), BetrayalArc (tricks 4-8 via
|
|
180
180
|
# agent_double_late_only flag), traitre joker (already populated above).
|
|
181
181
|
# Reading the flag — not boss.id — keeps this site reusable.
|
|
182
|
+
#
|
|
183
|
+
# 4.8.2 (B4): mutate `state._joker_state` IN PLACE rather than via
|
|
184
|
+
# `replace(state, _joker_state={...})`. Post-`trigger_round_start` the
|
|
185
|
+
# dict IS `acc._ledger.joker_state`; preserving the alias means future
|
|
186
|
+
# ledger mutations remain visible to classic-Belote reads via
|
|
187
|
+
# `state._joker_state.get(...)`. Pre-4.8.2 this site broke the alias,
|
|
188
|
+
# forcing the heist code (below) into a defensive dual-write.
|
|
182
189
|
if state.boss_modifiers.agent_double_active and not state._joker_state.get(
|
|
183
190
|
"agent_double_tricks"
|
|
184
191
|
):
|
|
@@ -186,8 +193,7 @@ def drive_round(
|
|
|
186
193
|
sabotage_tricks = frozenset(range(4, 9))
|
|
187
194
|
else:
|
|
188
195
|
sabotage_tricks = frozenset(rng.sample(range(1, 9), 3))
|
|
189
|
-
|
|
190
|
-
state = replace(state, _joker_state=new_jstate)
|
|
196
|
+
state._joker_state["agent_double_tricks"] = sabotage_tricks
|
|
191
197
|
|
|
192
198
|
# B4: Use partner trust-based difficulty for the North (partner) AI seat
|
|
193
199
|
_north_diff_str = partner.difficulty_for(Seat.NORTH)
|
|
@@ -387,20 +393,17 @@ def drive_round(
|
|
|
387
393
|
# AI takers never get the prompt — the UICallbacks default returns False
|
|
388
394
|
# and the BelAtro override also gates on `state.taker == Seat.SOUTH`.
|
|
389
395
|
#
|
|
390
|
-
#
|
|
391
|
-
#
|
|
392
|
-
#
|
|
393
|
-
#
|
|
394
|
-
#
|
|
395
|
-
#
|
|
396
|
-
# the alias. Mutating both keeps both observers consistent regardless.
|
|
396
|
+
# 4.8.2 (B4): single-site mutation. `state._joker_state` IS
|
|
397
|
+
# `acc._ledger.joker_state` after `trigger_round_start` installs the
|
|
398
|
+
# alias — and the post-trigger sabotage block above now preserves the
|
|
399
|
+
# alias too (replaced its `replace(state, _joker_state=...)` with
|
|
400
|
+
# in-place mutation). The pre-4.8.2 dual-write was a workaround for
|
|
401
|
+
# the broken alias; with the alias hardened, single-write is enough.
|
|
397
402
|
if (
|
|
398
403
|
state.phase == Phase.PLAYING
|
|
399
404
|
and state.taker == Seat.SOUTH
|
|
400
405
|
and ui_callbacks.prompt_heist(state)
|
|
401
406
|
):
|
|
402
|
-
if acc is not None and acc._ledger is not None:
|
|
403
|
-
acc._ledger.joker_state["heist_declared"] = True
|
|
404
407
|
state._joker_state["heist_declared"] = True
|
|
405
408
|
|
|
406
409
|
# Phase: PLAYING
|