belote-cli 3.4.0__tar.gz → 3.4.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-3.4.0 → belote_cli-3.4.2}/CHANGELOG.md +82 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/DEVELOPMENT.md +5 -3
- {belote_cli-3.4.0 → belote_cli-3.4.2}/PKG-INFO +18 -4
- {belote_cli-3.4.0 → belote_cli-3.4.2}/README.md +17 -3
- {belote_cli-3.4.0 → belote_cli-3.4.2}/pyproject.toml +1 -1
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/__init__.py +1 -1
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/ai.py +20 -4
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/jokers/contract.py +6 -6
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/jokers/trick_timing.py +5 -5
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/main.py +3 -1
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/partner/partner_state.py +12 -1
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/progression/save.py +10 -1
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/run/ante_themes.py +12 -7
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/game.py +0 -5
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/gameflow.py +10 -5
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/main.py +1 -1
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/belatro/test_belatro.py +47 -7
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/belatro/test_boss_modifiers_integration.py +60 -0
- belote_cli-3.4.2/tests/belatro/test_collection_logic.py +77 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/belatro/test_dead_flag_fixes.py +28 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/belatro/test_partner_trust.py +69 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/belatro/test_phase0_coverage.py +100 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/belatro/test_phase3_meta.py +13 -3
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/test_ai.py +83 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/test_new_coverage.py +49 -0
- belote_cli-3.4.0/tests/belatro/test_collection_logic.py +0 -36
- {belote_cli-3.4.0 → belote_cli-3.4.2}/.claude/settings.local.json +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/.gitignore +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/.python-version +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/LICENSE +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/scripts/benchmark.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/__init__.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/a11y.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/achievements.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/ansi.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/__init__.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/core/__init__.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/core/economy.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/core/run_state.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/core/scoring.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/engine/__init__.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/engine/event_bus.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/engine/modifier_patch.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/engine/round_driver.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/ghost_run.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/__init__.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/base.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/jokers/__init__.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/jokers/annonces.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/jokers/coinche.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/jokers/corrupted.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/jokers/economy.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/planets.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/registry.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/tarots.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/items/vouchers.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/partner/__init__.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/partner/personality.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/partner/trust.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/progression/__init__.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/progression/unlocks.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/run/__init__.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/run/ante.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/run/boss.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/run/decks.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/run/shop.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/run_summary.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/ui/__init__.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/ui/announce.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/ui/collection.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/ui/history.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/ui/hud.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/ui/menu.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/ui/rules.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/ui/shop.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/belatro/ui/trust_bar.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/config.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/context.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/deck.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/input.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/replay.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/rules.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/scoring.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/stats.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/themes.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/ui/__init__.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/ui/announce.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/ui/layout.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/ui/menu.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/ui/prompts.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/src/belote/ui/render.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/__init__.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/belatro/__init__.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/belatro/test_contract_unlocks.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/belatro/test_deck_variants.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/belatro/test_ghost_run.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/belatro/test_history_overlay.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/belatro/test_hud_synergy.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/belatro/test_phase1_plumbing.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/belatro/test_phase2_content.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/belatro/test_progression.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/belatro/test_round_driver.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/test_a11y.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/test_achievements.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/test_ansi_helpers.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/test_belote.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/test_extended.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/test_game_logic.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/test_gameflow.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/test_layout.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/test_official_rules.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/test_properties.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/test_replay.py +0 -0
- {belote_cli-3.4.0 → belote_cli-3.4.2}/tests/test_undo.py +0 -0
|
@@ -5,6 +5,88 @@ 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.4.2] - 2026-05-11
|
|
9
|
+
|
|
10
|
+
Implements the deferred bug roadmap from 3.4.1's verification pass. **9 fixes land here** — 3 Critical (C1/C3/C4), 4 High (H1/H4/H5/H7), 1 architectural cleanup (H10), 1 dead-code deletion (M4). Adds 17 regression tests (551 → 568). The 3.4.1 entry catalogued these against the source; this entry implements them. Plan file at `/home/mrrobot/.claude/plans/wtf-these-were-verified-shiny-flute.md`.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`src/belote/ai.py:104-108` (C1) — AI no longer sees partner's hand under `hide_partner_hand`.** `AIMemory.update_memory()` now gates the `partner_hand` population on `not state.boss_modifiers.hide_partner_hand`. Under Le Fantôme Partenaire the human was blinded but the AI continued to play with perfect partner information — the boss's visibility cost was paid by one team only. The fix removes the perfect-information cheat; the AI still infers partner cards via `known_voids` and `played` like any real player. Regression test in `tests/belatro/test_dead_flag_fixes.py::test_ai_memory_respects_hide_partner_hand`.
|
|
15
|
+
- **`src/belote/gameflow.py:200-203` (C3) — Dix de Der announcement now uses the Rupture-aware winner.** Pre-3.4.2 the 8th-trick "Dix de Der (Team X)" line called raw `trick_winner_seat()` on `display_state.current_trick`, which ignores `boss_modifiers.no_consecutive_team_wins`. Under La Rupture the announcement could name a team that was *not* credited the +10 in scoring. Fix swaps to `compute_trick_winners(state, trump, is_sa, tricks=projected)[-1]` (same helper `scoring.py` uses), feeding `projected = completed_tricks + [display_state.current_trick]` since the 8th trick isn't yet pushed to `completed_tricks` when the announcement fires. Removes the now-unused `trick_winner_seat` import. Regression test in `tests/belatro/test_boss_modifiers_integration.py::test_dix_de_der_announcement_honors_rupture`.
|
|
16
|
+
- **`src/belote/ai.py:540-549` (C4) — `opp_trumps` no longer over-counts; TA total fixed.** The pre-3.4.2 formula `8 - sum(played trumps)` conflated "remaining trump anywhere" with "opponent trump", treating South's own hand and partner's visible cards as still in opponents' hands. The fix subtracts `my_trumps`, `played_trumps`, and `partner_trumps` from the total. Under Tout Atout every card is a trump and the total is `32`, not `8` — pre-3.4.2 the formula degraded to `8 - 0 = 8` always under TA because no card's `.suit` equals `Suit.TOUT_ATOUT`. Both regimes are now handled. Has the side effect of also fixing `my_trumps` under TA (used by `_score_leading_strategy` downstream). Regression tests in `tests/test_ai.py::test_opp_trumps_excludes_own_and_partner_hand` and `::test_opp_trumps_under_tout_atout_uses_32_total`.
|
|
17
|
+
- **`src/belote/belatro/items/jokers/contract.py` + `trick_timing.py` (H1) — 8 jokers now gate on team, not seat.** `LIdeologue`, `LeFanatique`, `LeDiplomate`, `LePatriote`, `LIllusionniste`, `LePremierSang` (both checks), `LeSergent`, `LExecuteur` were checking `event.winner == Seat.SOUTH` instead of `team_of(event.winner) == 0`. They silently no-opped when partner (North) took the relevant trick. Now consistent with the 3.2.0 fix that landed `LaSentinelle` and `LeDernierMot` on the same pattern. Five existing tests that asserted the broken behavior (`test_north_*_returns_none`) were rewritten to verify the new correct behavior — partner wins now fire the joker; opposing-team wins (EAST) are added as the new negative case. `LeSergent`'s "streak reset" semantics also shifted: an opposing-team trick now breaks the streak, not a partner-won one. Regression backstop in `tests/belatro/test_phase0_coverage.py::test_h1_team_aware_jokers_fire_on_north_partner_win`. **`LeRebelle` is intentionally not included** — the 3.4.1 catalogue flagged it as a probable audit hallucination; it is an `on_belote` joker, not on `on_trick_won`, and the seat-vs-team distinction there is a separate spec call deferred to a future cycle.
|
|
18
|
+
- **`src/belote/belatro/run/ante_themes.py` + `belatro/main.py:413-414` (H4) — TournoiAnte pays a real 50% of round payout.** `AnteTheme.on_blind_won` gains a `blind_payout: int` parameter (forwarded to base class and both subclasses). The call site at `belatro/main.py:412` snapshots `run.economy.money` immediately before `process_round_end` and computes `blind_payout = money_after - money_before` after all bonus paths (L'Avocat, `_bonus_money`, Le Puriste, L'Aristocrate) have run. TournoiAnte now does `add_money(max(1, blind_payout // 2))` — actually 50% of payout, with a $1 floor so blind payouts of 0 still pay something. Comment rewritten to match. Updated tests in `tests/belatro/test_phase3_meta.py` (CafeAnte test threads the new arg; TournoiAnte tests verify exact `payout // 2` math + the floor).
|
|
19
|
+
- **`src/belote/belatro/progression/save.py:97-101` (H5) — `load_profile` no longer loses starter unlocks.** Pre-3.4.2 the happy path read `data.get("unlocked_ids", [])`, defaulting to an empty list when the key was absent — wiping the Profile dataclass's `["le_classique", "le_courageux", "l_econome"]` starter unlocks for any save missing the key (legacy saves, manual edits, partial writes). The fix falls back to `Profile().unlocked_ids` when the key is missing; an explicitly empty `unlocked_ids: []` is honored unchanged (a player who has reset their unlocks stays reset). Two regression tests in `tests/belatro/test_collection_logic.py` lock both behaviors.
|
|
20
|
+
- **`src/belote/main.py:230-231` (H7) — stats line agrees with menu summary on ties.** Changed `won=(ns >= target and ns >= ew)` to `won=(ns >= target and ns > ew)` so the `update_stats_game` `won` flag aligns with `ui/menu.py:344`'s `winner = "NS" if ns > ew else "EW"`. On an exact tie at target, pre-3.4.2 the stats recorded a NS win while the visible summary said EW. Both regression tests in `tests/test_new_coverage.py` — one source-grep anti-pattern lock against `>=`, one semantic check on the formula.
|
|
21
|
+
|
|
22
|
+
### Internal
|
|
23
|
+
|
|
24
|
+
- **`src/belote/belatro/partner/partner_state.py:38-49` (H10, architectural)** — `equip_joker` signature widened to `equip_joker(self, joker: Joker, run: BelAtroRun | None = None) -> bool`. When `run` is provided, `joker.on_purchase(run)` fires after the slot append. No current partner joker defines `on_purchase`, so no behaviour changes today — this is forward-looking infrastructure. The catalogue called this "latent"; equipping through this path will now invoke purchase-time effects consistently with the main joker slot equip path. Note: `equip_joker` has zero callers in the current codebase (the shop path equips through a different surface), so this fix is doubly forward-looking. Three regression tests in `tests/belatro/test_partner_trust.py::TestEquipJokerOnPurchase`.
|
|
25
|
+
- **`src/belote/game.py::advance_turn` (M4) — dead code deleted.** Zero callers across `src/` and `tests/`. The function was a one-line `replace(state, turn=state.turn.next_seat())` helper that no live code used.
|
|
26
|
+
- **Tests**: 551 → 568 (+17). Five existing `test_north_*_returns_none` tests in `test_belatro.py` were flipped to assert the new team-aware behavior (these tests had encoded the H1 bug as a contract — they are not test regressions but contract updates).
|
|
27
|
+
- **Strict gates**: pytest 568/568, mypy 0 errors (76 source files), ruff 0 violations.
|
|
28
|
+
- **Version markers bumped**: `pyproject.toml`, `src/belote/__init__.py`.
|
|
29
|
+
- **Docs bumped**: `CHANGELOG.md` (this entry), `README.md` "What's new in 3.4.2", `DEVELOPMENT.md` baseline.
|
|
30
|
+
- **Still deferred**: **H2** (`LEgoiste` partner-trick nullification) was flagged as needing a spec decision in 3.4.1 and was not in any tier the user picked for 3.4.2 — the comment says "Partner's points are nullified" which reads as intent, so a fix would invert documented behavior. Stays on the deferred list pending a spec call. `LeRebelle` `on_belote` seat/team gating (noted above) is similarly deferred.
|
|
31
|
+
|
|
32
|
+
## [3.4.1] - 2026-05-11
|
|
33
|
+
|
|
34
|
+
Audit-verification-only release — **no code changes**. A fresh external LLM audit ("Comprehensive Audit Report — Belote CLI v3.4.0") produced 26 prioritized findings (4 Critical, 10 High, 12 Medium) plus a test-coverage section and a performance section. Direct verification against the source confirmed **7 real bugs** (3 Critical, 4 High), **1 architectural latent issue** (no current consumer but fragile for future work), and **1 disputed claim** that needs a spec call before any fix. **8 claims were false positives** under verification (intent inversion, defense-in-depth confused for bugs, or stale call-graph reading). Mediums spot-checked: 1 real (dead code), 4 false. The point of cutting a release for verification-only work is to (a) lock the false-positive catalogue against re-investigation next cycle and (b) record the confirmed-bug roadmap before any fixes land. Plan/verification file at `/home/mrrobot/.claude/plans/check-on-this-audit-polished-kite.md`. Test count, mypy, ruff results unchanged from 3.4.0 (no source code touched).
|
|
35
|
+
|
|
36
|
+
### Confirmed bugs — deferred to 3.4.2+
|
|
37
|
+
|
|
38
|
+
These were verified against current code and are real. None are fixed in 3.4.1; they are catalogued here so the next session has a vetted target list.
|
|
39
|
+
|
|
40
|
+
**Critical**
|
|
41
|
+
- **`src/belote/ai.py:104-108` (C1) — AI sees partner's full hand regardless of `hide_partner_hand`.** `AIMemory.update_memory()` unconditionally populates `self.memory.partner_hand` from `state.hand_of(partner)`. The boss flag `hide_partner_hand` (declared at `game.py:177`, set by Le Fantôme Partenaire at `belatro/run/boss.py:171`) is only read by display code at `belatro/main.py:291`, never by the AI memory path. Net effect: under Le Fantôme Partenaire the human is blinded to partner's hand but the AI continues to play with perfect partner information.
|
|
42
|
+
- **`src/belote/gameflow.py:196-198` (C3) — Dix de Der announcement uses non-Rupture-aware winner.** The announcement calls `trick_winner_seat()` directly; the La Rupture-aware helper `compute_trick_winners()` (defined at `game.py:756`, used by `scoring.py`) is the one that honours `boss_modifiers.no_consecutive_team_wins`. Under La Rupture the announced "Dix de Der goes to TEAM X" line can name a team that is not actually credited the +10 in scoring.
|
|
43
|
+
- **`src/belote/ai.py:533` (C4) — `opp_trumps` formula conflates "remaining trump" with "opponent trump".** Current line is `opp_trumps = 8 - sum(1 for c in self.memory.played if c.suit == trump)`. This counts trump still anywhere in unrevealed hands — including South's own hand (already computed as `my_trumps` on line 532) and partner's hand (visible at `self.memory.partner_hand`). The variable is then compared against `my_trumps` in `_score_leading_strategy`, so the over-count biases Hard AI's trump-coverage decisions when the AI itself is holding trump.
|
|
44
|
+
|
|
45
|
+
**High**
|
|
46
|
+
- **`src/belote/belatro/items/jokers/contract.py` + `trick_timing.py` (H1, partial) — 8 jokers still gate on `event.winner == Seat.SOUTH`.** 3.2.0 fixed La Sentinelle and Le Dernier Mot to use `team_of(event.winner) == 0`. The same anti-pattern survives in: `LIdeologue` (contract.py:21), `LeFanatique` (contract.py:45), `LeDiplomate` (contract.py:62), `LePatriote` (contract.py:81), `LIllusionniste` (contract.py:128), `LePremierSang` (trick_timing.py:26/30), `LeSergent` (trick_timing.py:46), `LExecuteur` (trick_timing.py:82). All silently no-op when North takes the relevant trick. (The audit also named `LeRebelle` but I could not find it among the South-only checks — likely a hallucinated joker name; verify before touching.)
|
|
47
|
+
- **`src/belote/belatro/run/ante_themes.py:73-76` (H4) — TournoiAnte bonus is not the advertised 50%.** Effect uses `run.economy.add_money(max(1, run.economy.bonus_per_round // 2 + 2))`. The comment claims "+50% bonus on top of whatever payout the round produced", but the formula is a flat function of `bonus_per_round` (the per-round flat-bonus economy field), not 50% of actual round payout. Either compute true 50% of the round delta or rewrite the comment.
|
|
48
|
+
- **`src/belote/belatro/progression/save.py:94` (H5) — `load_profile` loses default unlocks.** Line reads `unlocked_ids=data.get("unlocked_ids", [])`. The `Profile` dataclass default is `["le_classique", "le_courageux", "l_econome"]`. A saved profile missing the key (older saves, manual edits, partial writes) reloads with no unlocks. The exception branch correctly returns `Profile()` with defaults; only the happy path corrupts.
|
|
49
|
+
- **`src/belote/main.py:230-231` (H7) — Win operator mismatch on ties.** Main loop uses `won=(ns >= target and ns >= ew)`; menu summary at `ui/menu.py:344` uses `winner = "NS" if ns > ew else "EW"`. On an exact tie at target, main records a NS win while the visible summary attributes the round to EW.
|
|
50
|
+
|
|
51
|
+
### Architectural / latent
|
|
52
|
+
|
|
53
|
+
- **`src/belote/belatro/partner/partner_state.py:34-38` (H10) — `equip_joker` skips `on_purchase`.** The method simply appends to `self.jokers`; no `on_purchase()` hook is invoked. No current partner joker defines `on_purchase()`, so this is latent today — but any future partner joker with a purchase-time effect (a la `LeTraitre` in `corrupted.py`) will silently fail to fire when equipped through this path. Document or wire the hook before adding such a joker.
|
|
54
|
+
- **`src/belote/game.py:1007` (M4) — `advance_turn()` is dead code.** Defined, never called. Safe to delete.
|
|
55
|
+
|
|
56
|
+
### Needs spec decision before fixing
|
|
57
|
+
|
|
58
|
+
- **`src/belote/belatro/items/jokers/corrupted.py:56-62` (H2) — `LEgoiste` nullifies the entire partner trick.** On `event.winner == Seat.NORTH` the joker returns `JokerResult(add_chips=-event.card_points)`, where `event.card_points` is the FULL trick's card points (South's contribution included). The audit reads this as a bug (only partner's own contribution should be subtracted); the code comment reads "Partner's points are nullified", implying *intentional* full-trick nullification. Resolve which is canonical before changing the formula — and either way add a test that pins the intended behaviour.
|
|
59
|
+
|
|
60
|
+
### Verified clean — agent claims that did NOT survive source verification
|
|
61
|
+
|
|
62
|
+
Catalogued so they aren't re-investigated next cycle.
|
|
63
|
+
|
|
64
|
+
- **(C2) "No round-2 bid validation in `place_bid`"** — `place_bid` itself does not validate, but the human-input path filters the up-card suit out of the options menu at `ui/prompts.py:132-135`, and the AI path uses `exclude=forbidden` at `ai.py:137`. Defense-in-depth gap (a programmatic caller could pass a bad bid), not a live bug.
|
|
65
|
+
- **(H3) "`LeFou` fallback is dead code"** — `tarots.py:119` checks `if last_id and last_id != self.id`. `last_consumable_id` defaults to `None` and `getattr(item, "id", None)` can also set it to `None`, so the fallback fires on the first consumable of a run and on self-copy attempts. Live code.
|
|
66
|
+
- **(H6) "Signal handlers skip `finally` and lose stats"** — `main.py:127-129` handler does call `sys.exit(0)`, which bypasses the `finally`. But `flush_stats()` is also invoked at `main.py:160` (quit path) and `main.py:235` (game-over). The data-loss window is narrow (SIGINT before any flush in the same session). Cosmetic at best.
|
|
67
|
+
- **(H8) "`zip(..., strict=False)` causes silent data loss in scoring"** — `winners` at `scoring.py:461,498` is produced by `compute_trick_winners(state, ...)`, which is exactly one entry per completed trick by construction. The length invariant holds; `strict=False` is defensive noise, not a live bug.
|
|
68
|
+
- **(H9) "`_CARD_TO_ID[c]` will KeyError on BelAtro jokers"** — `_CARD_TO_ID` is built from `make_deck()` (32 standard cards). BelAtro jokers are `Joker` objects living in `state._joker_state`, never inserted into `hand`. No reachable code path produces the KeyError.
|
|
69
|
+
- **(M2) "Frozen `GameState` contains mutable `_rng`"** — `_rng` is declared with `field(default_factory=random.Random, compare=False, repr=False)` — the same documented pattern used for `_joker_state` (see comment at `game.py:214-217`). Intentional; the contract is "always rebuild via `dataclasses.replace`".
|
|
70
|
+
- **(M8) "`card in self.memory.partner_hand` is always False"** — `partner_hand` is populated at `ai.py:107-108` during PLAYING/SCORING phase. The check at line 660 is live and prevents double-scoring visible partner cards.
|
|
71
|
+
- **(M12) "`mult == float(int(mult))` float precision bug"** — Intentional optimisation in `belatro/core/scoring.py:254`: when the multiplier is exactly integral, take the lossless integer-multiplication path; else accept float multiplication. Correct logic.
|
|
72
|
+
|
|
73
|
+
### Audit calibration notes (for the next pass)
|
|
74
|
+
|
|
75
|
+
The audit's overall scaffolding (file:line citations, severity tiers, action plan) was well-presented but had a recurring failure mode: **flagging "suspicious-looking" patterns without verifying behaviour in context**. Concrete recurring misses worth feeding back to the auditing model:
|
|
76
|
+
|
|
77
|
+
1. **Defense-in-depth confused with bugs** (C2, H8, H9). When the audited line lacks an obvious check, the audit should trace one hop up the call graph before declaring "no validation". In all three cases the invariant is enforced at the caller.
|
|
78
|
+
2. **Intent inversion** (H2, M2, M12). When code is paired with a comment that describes the exact behaviour the audit flags as wrong, that's evidence of intent, not a bug. Reading the adjacent comment before flagging would have caught these.
|
|
79
|
+
3. **Self-cancelling dead-code claims** (H3). The audit claimed "`last_consumable_id` is always set before `use()` is called". True — but it can be set to `None`, and the consumer's guard is `if last_id and ...`. Surface-level static reasoning without tracing the guard.
|
|
80
|
+
4. **Headline metrics with no numbers**. The "Current Health" table lists Tests / Lint / Types / Version rows with no values. The version row is checkable; the others were left blank, which makes the table cosmetic.
|
|
81
|
+
5. **Joker name drift** (H1). `LeRebelle` appeared in the South-only list but is not among the actual offenders. Probable hallucination.
|
|
82
|
+
|
|
83
|
+
### Internal
|
|
84
|
+
|
|
85
|
+
- **No source code touched.** Test count, mypy strictness, ruff cleanliness all unchanged from 3.4.0: pytest 551/551, mypy 0 errors (76 files), ruff 0 violations.
|
|
86
|
+
- **Version markers bumped**: `pyproject.toml`, `src/belote/__init__.py`.
|
|
87
|
+
- **Docs bumped**: `README.md` "What's new in 3.4.1" section, `DEVELOPMENT.md` baseline.
|
|
88
|
+
- **Roadmap for 3.4.2**: the 7 confirmed bugs above (C1, C3, C4, H1×8, H4, H5, H7) plus the H10/M4 cleanups. Tier 1 (C1/C3/C4) closes the AI-fairness gap; Tier 2 (H1/H4/H5/H7) closes the remaining verified issues.
|
|
89
|
+
|
|
8
90
|
## [3.4.0] - 2026-05-10
|
|
9
91
|
|
|
10
92
|
Audit + endless-mode reliability + HUD polish release. A fresh three-agent codebase pass (classic engine / BelAtro layer / UI + I/O) produced ~80 candidate findings. Direct verification against the source rejected ~95% as false positives or by-design patterns. The five surviving issues plus two **new** bugs uncovered during follow-up verification of endless mode and classic game flow are fixed here. Two HUD features land alongside (joker pip strip with edition glow, synergy tooltip, polished trust bar). 551 tests passing (up from 549), ruff and mypy strict still clean. Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-fizzy-summit.md`.
|
|
@@ -84,14 +84,16 @@ PYTHONPATH=src mypy --strict src/
|
|
|
84
84
|
# Linting (0 violations expected)
|
|
85
85
|
ruff check src/ tests/
|
|
86
86
|
|
|
87
|
-
# Full test suite (
|
|
87
|
+
# Full test suite (568 tests expected)
|
|
88
88
|
PYTHONPATH=src pytest
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
Current baseline (3.4.
|
|
91
|
+
Current baseline (3.4.2):
|
|
92
92
|
- **mypy**: 0 errors (strict mode, 76 files)
|
|
93
93
|
- **ruff**: 0 violations
|
|
94
|
-
- **pytest**:
|
|
94
|
+
- **pytest**: 568 tests, 0 failures
|
|
95
|
+
- 3.4.2 closes the 3.4.1 catalogue. All 7 confirmed bugs (C1 AI cheat under `hide_partner_hand`, C3 Dix de Der under La Rupture, C4 `opp_trumps` formula + TA total, H1 8 jokers seat→team, H4 TournoiAnte true 50%, H5 `load_profile` default unlocks, H7 classic-mode tie operator) plus H10 (`equip_joker` wires `on_purchase`) and M4 (delete dead `advance_turn`) ship in 3.4.2. +17 regression tests (551 → 568). H2 (`LEgoiste` partner-trick nullification) remains deferred — needs a spec call between code-comment intent and the audit's reading.
|
|
96
|
+
- 3.4.1 was **documentation-only** — an external LLM audit was verified against the source. 7 confirmed bugs were catalogued in `CHANGELOG.md` as deferred to 3.4.2+; 8 audit claims were rejected as false positives and are listed in the "Verified clean" section to block re-investigation. No source code changed in 3.4.1.
|
|
95
97
|
- 3.4.0 covered: A1 `BidMadeEvent` double-fire on coinche paths (HIGH), E1 endless mode replaying Ante 8 Boss instead of advancing to the first scaled cycle (HIGH), E2 classic-mode tie-breaker overridden by main loop (HIGH), A2 termios raw-mode leak on SSH drop (MED), A3 shop selection index off-by-one after reroll (MED), A5 prompts.py dead return (LOW). Plus HUD additions: joker pip strip with edition glow (B.3), synergy tooltip (B.4), four-tier trust bar with tier glyph (B.5). Score gutter (B.2) and trick-lane compass (B.1) intentionally deferred — they touch `ui/render.py`'s vertical-centering logic and want a dedicated session.
|
|
96
98
|
|
|
97
99
|
Run all gates before committing:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: belote-cli
|
|
3
|
-
Version: 3.4.
|
|
3
|
+
Version: 3.4.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,6 +45,20 @@ 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
|
+
## What's new in 3.4.2
|
|
49
|
+
|
|
50
|
+
- **The 3.4.1 catalogue is closed.** All 7 confirmed bugs plus the H10 architectural cleanup and the M4 dead-code deletion ship here. **C1 — AI cheating under Le Fantôme Partenaire:** AI memory now respects `hide_partner_hand`; the boss flag's visibility cost is paid by both sides. **C3 — Dix de Der announcement under La Rupture:** the 8th-trick "Team X" line now uses the Rupture-aware `compute_trick_winners` helper, so the named team matches what scoring credits. **C4 — `opp_trumps` formula:** subtracts South's own trumps, played trumps, and partner-visible trumps; under Tout Atout the total switches to 32 (every card is a trump). **H1 — 8 BelAtro jokers:** `LIdeologue`, `LeFanatique`, `LeDiplomate`, `LePatriote`, `LIllusionniste`, `LePremierSang`, `LeSergent`, `LExecuteur` now gate on `team_of(event.winner) == 0` instead of `event.winner == Seat.SOUTH`, matching the 3.2.0 fix that landed `LaSentinelle` and `LeDernierMot`. **H4 — TournoiAnte:** `on_blind_won` now receives `blind_payout` and pays a true 50% (was a flat function of `bonus_per_round`). **H5 — `load_profile`:** saves missing the `unlocked_ids` key fall back to the Profile dataclass default starter unlocks. **H7 — classic win attribution on ties:** `update_stats_game` operator aligned to `ns > ew` so the stats line agrees with `menu.py`'s visible winner on an exact tie at target.
|
|
51
|
+
- **Architectural / cleanup.** `equip_joker` accepts an optional `run` and fires `on_purchase` when provided (forward-looking — no current partner joker has a purchase hook). Dead `advance_turn()` deleted from `game.py`.
|
|
52
|
+
- **Still deferred** (need spec calls, not implementation work): H2 `LEgoiste` partner-trick nullification (comment vs. audit reading); `LeRebelle` `on_belote` seat/team gating (separate code path from H1).
|
|
53
|
+
- **Tests + gates** — 568 tests (up from 551, +17 regressions). Five existing `test_north_*_returns_none` tests in `test_belatro.py` were flipped to assert the new team-aware behaviour (they had encoded the H1 bug as a contract). Strict gates still clean: pytest 568/568, mypy 0 errors (76 files), ruff 0 violations.
|
|
54
|
+
|
|
55
|
+
## What's new in 3.4.1
|
|
56
|
+
|
|
57
|
+
- **Audit verification pass — no code changes.** An external LLM audit ("Comprehensive Audit Report — Belote CLI v3.4.0") flagged 4 Critical, 10 High, and 12 Medium issues. Direct verification against the source confirmed **7 real bugs** (3 Critical, 4 High), **1 architectural latent issue**, and **1 disputed claim** needing a spec decision; **8 claims were false positives**. The roadmap and the false-positive catalogue are recorded in `CHANGELOG.md` so the next session has a vetted target list and the rejected claims aren't re-investigated. *Note (3.4.2): the catalogue is now closed — all 7 confirmed bugs plus H10 and M4 are fixed in 3.4.2. The 3.4.1 entry is preserved as a historical record of the verification-only release.*
|
|
58
|
+
- **Confirmed bugs deferred to 3.4.2+**: AI ignoring `hide_partner_hand` (C1), Dix de Der announcement not La Rupture-aware (C3), AI `opp_trumps` over-count (C4), 8 BelAtro jokers still gated on South instead of NS team (H1), TournoiAnte bonus not actually 50% (H4), `load_profile` losing default unlocks (H5), classic-mode tie-break operator mismatch with the menu summary (H7). Plus latent: `equip_joker` skipping `on_purchase` (H10), and a dead `advance_turn()` (M4).
|
|
59
|
+
- **Audit calibration findings** — recurring failure modes documented for the next pass: defense-in-depth confused with bugs (C2/H8/H9), intent inversion against adjacent comments (H2/M2/M12), self-cancelling dead-code claims (H3), and joker-name hallucination (H1's `LeRebelle`). Useful as feedback to the auditing model.
|
|
60
|
+
- **No behavioural changes.** Test count, mypy strict, ruff all unchanged from 3.4.0: pytest 551/551, mypy 0 errors (76 files), ruff 0 violations.
|
|
61
|
+
|
|
48
62
|
## What's new in 3.4.0
|
|
49
63
|
|
|
50
64
|
- **BelAtro joker correctness** — `BidMadeEvent` no longer double-fires `on_bid` joker handlers on coinche paths. Pre-3.4.0 the player-coinche, AI-partner-coinche, `auto_coinche` boss, and `start_coinched` deck-mod paths all re-emitted the same bid event a second time with the resolved `coinche_level`, so on_bid jokers (Le Passeur today, anything new tomorrow) were silently invoked twice for the same bid. Fixed via a `re_emit: bool` field on the event; refreshes update `joker_state["contract"]` but skip joker firing.
|
|
@@ -337,7 +351,7 @@ belote/
|
|
|
337
351
|
│ ├── input.py # Platform-dispatched key reader and interruptible sleep
|
|
338
352
|
│ ├── stats.py # Global and session statistics tracking
|
|
339
353
|
│ └── rules.py # Game rules content
|
|
340
|
-
├── tests/ # Comprehensive test suite (
|
|
354
|
+
├── tests/ # Comprehensive test suite (568 tests)
|
|
341
355
|
├── scripts/ # Performance benchmarks
|
|
342
356
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
343
357
|
├── LICENSE # MIT License
|
|
@@ -353,14 +367,14 @@ belote/
|
|
|
353
367
|
PYTHONPATH=src pytest
|
|
354
368
|
```
|
|
355
369
|
|
|
356
|
-
Currently **
|
|
370
|
+
Currently **568 tests** passing with 100% coverage on game-logic modules.
|
|
357
371
|
|
|
358
372
|
## Technical Integrity
|
|
359
373
|
|
|
360
374
|
The codebase is strictly validated with the following tools:
|
|
361
375
|
- **mypy**: 0 errors (strict type safety)
|
|
362
376
|
- **ruff**: 0 violations (linting & formatting)
|
|
363
|
-
- **pytest**:
|
|
377
|
+
- **pytest**: 568/568 passed
|
|
364
378
|
- **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
|
|
365
379
|
- **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
|
|
366
380
|
|
|
@@ -2,6 +2,20 @@
|
|
|
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
|
+
## What's new in 3.4.2
|
|
6
|
+
|
|
7
|
+
- **The 3.4.1 catalogue is closed.** All 7 confirmed bugs plus the H10 architectural cleanup and the M4 dead-code deletion ship here. **C1 — AI cheating under Le Fantôme Partenaire:** AI memory now respects `hide_partner_hand`; the boss flag's visibility cost is paid by both sides. **C3 — Dix de Der announcement under La Rupture:** the 8th-trick "Team X" line now uses the Rupture-aware `compute_trick_winners` helper, so the named team matches what scoring credits. **C4 — `opp_trumps` formula:** subtracts South's own trumps, played trumps, and partner-visible trumps; under Tout Atout the total switches to 32 (every card is a trump). **H1 — 8 BelAtro jokers:** `LIdeologue`, `LeFanatique`, `LeDiplomate`, `LePatriote`, `LIllusionniste`, `LePremierSang`, `LeSergent`, `LExecuteur` now gate on `team_of(event.winner) == 0` instead of `event.winner == Seat.SOUTH`, matching the 3.2.0 fix that landed `LaSentinelle` and `LeDernierMot`. **H4 — TournoiAnte:** `on_blind_won` now receives `blind_payout` and pays a true 50% (was a flat function of `bonus_per_round`). **H5 — `load_profile`:** saves missing the `unlocked_ids` key fall back to the Profile dataclass default starter unlocks. **H7 — classic win attribution on ties:** `update_stats_game` operator aligned to `ns > ew` so the stats line agrees with `menu.py`'s visible winner on an exact tie at target.
|
|
8
|
+
- **Architectural / cleanup.** `equip_joker` accepts an optional `run` and fires `on_purchase` when provided (forward-looking — no current partner joker has a purchase hook). Dead `advance_turn()` deleted from `game.py`.
|
|
9
|
+
- **Still deferred** (need spec calls, not implementation work): H2 `LEgoiste` partner-trick nullification (comment vs. audit reading); `LeRebelle` `on_belote` seat/team gating (separate code path from H1).
|
|
10
|
+
- **Tests + gates** — 568 tests (up from 551, +17 regressions). Five existing `test_north_*_returns_none` tests in `test_belatro.py` were flipped to assert the new team-aware behaviour (they had encoded the H1 bug as a contract). Strict gates still clean: pytest 568/568, mypy 0 errors (76 files), ruff 0 violations.
|
|
11
|
+
|
|
12
|
+
## What's new in 3.4.1
|
|
13
|
+
|
|
14
|
+
- **Audit verification pass — no code changes.** An external LLM audit ("Comprehensive Audit Report — Belote CLI v3.4.0") flagged 4 Critical, 10 High, and 12 Medium issues. Direct verification against the source confirmed **7 real bugs** (3 Critical, 4 High), **1 architectural latent issue**, and **1 disputed claim** needing a spec decision; **8 claims were false positives**. The roadmap and the false-positive catalogue are recorded in `CHANGELOG.md` so the next session has a vetted target list and the rejected claims aren't re-investigated. *Note (3.4.2): the catalogue is now closed — all 7 confirmed bugs plus H10 and M4 are fixed in 3.4.2. The 3.4.1 entry is preserved as a historical record of the verification-only release.*
|
|
15
|
+
- **Confirmed bugs deferred to 3.4.2+**: AI ignoring `hide_partner_hand` (C1), Dix de Der announcement not La Rupture-aware (C3), AI `opp_trumps` over-count (C4), 8 BelAtro jokers still gated on South instead of NS team (H1), TournoiAnte bonus not actually 50% (H4), `load_profile` losing default unlocks (H5), classic-mode tie-break operator mismatch with the menu summary (H7). Plus latent: `equip_joker` skipping `on_purchase` (H10), and a dead `advance_turn()` (M4).
|
|
16
|
+
- **Audit calibration findings** — recurring failure modes documented for the next pass: defense-in-depth confused with bugs (C2/H8/H9), intent inversion against adjacent comments (H2/M2/M12), self-cancelling dead-code claims (H3), and joker-name hallucination (H1's `LeRebelle`). Useful as feedback to the auditing model.
|
|
17
|
+
- **No behavioural changes.** Test count, mypy strict, ruff all unchanged from 3.4.0: pytest 551/551, mypy 0 errors (76 files), ruff 0 violations.
|
|
18
|
+
|
|
5
19
|
## What's new in 3.4.0
|
|
6
20
|
|
|
7
21
|
- **BelAtro joker correctness** — `BidMadeEvent` no longer double-fires `on_bid` joker handlers on coinche paths. Pre-3.4.0 the player-coinche, AI-partner-coinche, `auto_coinche` boss, and `start_coinched` deck-mod paths all re-emitted the same bid event a second time with the resolved `coinche_level`, so on_bid jokers (Le Passeur today, anything new tomorrow) were silently invoked twice for the same bid. Fixed via a `re_emit: bool` field on the event; refreshes update `joker_state["contract"]` but skip joker firing.
|
|
@@ -294,7 +308,7 @@ belote/
|
|
|
294
308
|
│ ├── input.py # Platform-dispatched key reader and interruptible sleep
|
|
295
309
|
│ ├── stats.py # Global and session statistics tracking
|
|
296
310
|
│ └── rules.py # Game rules content
|
|
297
|
-
├── tests/ # Comprehensive test suite (
|
|
311
|
+
├── tests/ # Comprehensive test suite (568 tests)
|
|
298
312
|
├── scripts/ # Performance benchmarks
|
|
299
313
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
300
314
|
├── LICENSE # MIT License
|
|
@@ -310,14 +324,14 @@ belote/
|
|
|
310
324
|
PYTHONPATH=src pytest
|
|
311
325
|
```
|
|
312
326
|
|
|
313
|
-
Currently **
|
|
327
|
+
Currently **568 tests** passing with 100% coverage on game-logic modules.
|
|
314
328
|
|
|
315
329
|
## Technical Integrity
|
|
316
330
|
|
|
317
331
|
The codebase is strictly validated with the following tools:
|
|
318
332
|
- **mypy**: 0 errors (strict type safety)
|
|
319
333
|
- **ruff**: 0 violations (linting & formatting)
|
|
320
|
-
- **pytest**:
|
|
334
|
+
- **pytest**: 568/568 passed
|
|
321
335
|
- **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
|
|
322
336
|
- **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
|
|
323
337
|
|
|
@@ -102,8 +102,10 @@ class AIPlayer:
|
|
|
102
102
|
# In this implementation, AI tracks what it can see
|
|
103
103
|
p = partner(self.seat)
|
|
104
104
|
self.memory.partner_hand.clear()
|
|
105
|
-
if
|
|
106
|
-
|
|
105
|
+
if (
|
|
106
|
+
state.phase in (Phase.PLAYING, Phase.SCORING)
|
|
107
|
+
and not state.boss_modifiers.hide_partner_hand
|
|
108
|
+
):
|
|
107
109
|
for card in state.hand_of(p):
|
|
108
110
|
self.memory.partner_hand.add(card)
|
|
109
111
|
|
|
@@ -529,8 +531,22 @@ class AIPlayer:
|
|
|
529
531
|
|
|
530
532
|
my_hand = state.hand_of(self.seat)
|
|
531
533
|
hand_suit_counts: dict[Suit, int] = Counter(c.suit for c in my_hand)
|
|
532
|
-
|
|
533
|
-
|
|
534
|
+
# Under Tout Atout every card is a trump; under a normal contract
|
|
535
|
+
# trump cards are those matching the trump suit. `opp_trumps` must
|
|
536
|
+
# subtract everything that is no longer in opponents' hands: my own
|
|
537
|
+
# trumps, trumps already played, and any of partner's visible
|
|
538
|
+
# trumps (empty under `hide_partner_hand`).
|
|
539
|
+
if trump is Suit.TOUT_ATOUT:
|
|
540
|
+
total_trumps = 32
|
|
541
|
+
my_trumps = len(my_hand)
|
|
542
|
+
played_trumps = len(self.memory.played)
|
|
543
|
+
partner_trumps = len(self.memory.partner_hand)
|
|
544
|
+
else:
|
|
545
|
+
total_trumps = 8
|
|
546
|
+
my_trumps = hand_suit_counts.get(trump, 0)
|
|
547
|
+
played_trumps = sum(1 for c in self.memory.played if c.suit == trump)
|
|
548
|
+
partner_trumps = sum(1 for c in self.memory.partner_hand if c.suit == trump)
|
|
549
|
+
opp_trumps = max(0, total_trumps - my_trumps - played_trumps - partner_trumps)
|
|
534
550
|
|
|
535
551
|
# Score each legal card by expected outcome
|
|
536
552
|
best_card = legal[0]
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
5
|
from belote.deck import Rank, Suit, card_points
|
|
6
|
-
from belote.game import Seat
|
|
6
|
+
from belote.game import Seat, team_of
|
|
7
7
|
|
|
8
8
|
from ...engine.event_bus import BeloteAnnouncedEvent, RoundEndEvent, TrickWonEvent
|
|
9
9
|
from ..base import Joker, JokerResult
|
|
@@ -18,7 +18,7 @@ class LIdeologue(Joker):
|
|
|
18
18
|
|
|
19
19
|
def on_trick_won(self, event: TrickWonEvent, state: dict[str, Any]) -> JokerResult | None:
|
|
20
20
|
# Sans Atout has event.trump as None
|
|
21
|
-
if event.winner ==
|
|
21
|
+
if team_of(event.winner) == 0 and event.trump is None:
|
|
22
22
|
jacks = sum(1 for c in event.cards if c.rank == Rank.JACK)
|
|
23
23
|
if jacks > 0:
|
|
24
24
|
# In Sans Atout, Jack is worth 2. We want it to be 20.
|
|
@@ -42,7 +42,7 @@ class LeFanatique(Joker):
|
|
|
42
42
|
if state.get("contract") != "tout_atout":
|
|
43
43
|
return None
|
|
44
44
|
|
|
45
|
-
if event.winner ==
|
|
45
|
+
if team_of(event.winner) == 0:
|
|
46
46
|
wins = state.get(f"{self.id}_wins", 0) + 1
|
|
47
47
|
state[f"{self.id}_wins"] = wins
|
|
48
48
|
if wins > 4:
|
|
@@ -59,7 +59,7 @@ class LeDiplomate(Joker):
|
|
|
59
59
|
cost = 7
|
|
60
60
|
|
|
61
61
|
def on_trick_won(self, event: TrickWonEvent, state: dict[str, Any]) -> JokerResult | None:
|
|
62
|
-
if event.winner ==
|
|
62
|
+
if team_of(event.winner) == 0:
|
|
63
63
|
suits: dict[Suit, set[Rank]] = {}
|
|
64
64
|
for c in event.cards:
|
|
65
65
|
if c.rank in (Rank.KING, Rank.QUEEN):
|
|
@@ -78,7 +78,7 @@ class LePatriote(Joker):
|
|
|
78
78
|
cost = 6
|
|
79
79
|
|
|
80
80
|
def on_trick_won(self, event: TrickWonEvent, state: dict[str, Any]) -> JokerResult | None:
|
|
81
|
-
if event.winner ==
|
|
81
|
+
if team_of(event.winner) == 0 and event.trump:
|
|
82
82
|
trump_pts = sum(
|
|
83
83
|
card_points(c, event.trump) for c in event.cards if c.suit == event.trump
|
|
84
84
|
)
|
|
@@ -125,7 +125,7 @@ class LIllusionniste(Joker):
|
|
|
125
125
|
cost = 9
|
|
126
126
|
|
|
127
127
|
def on_trick_won(self, event: TrickWonEvent, state: dict[str, Any]) -> JokerResult | None:
|
|
128
|
-
if event.winner ==
|
|
128
|
+
if team_of(event.winner) == 0 and event.trump:
|
|
129
129
|
extra_pts = sum(
|
|
130
130
|
18 for c in event.cards if c.rank == Rank.JACK and c.suit != event.trump
|
|
131
131
|
)
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
|
-
from belote.game import
|
|
5
|
+
from belote.game import team_of
|
|
6
6
|
|
|
7
7
|
from ...engine.event_bus import TrickWonEvent
|
|
8
8
|
from ..base import Joker, JokerResult
|
|
@@ -23,11 +23,11 @@ class LePremierSang(Joker):
|
|
|
23
23
|
# subsequent NS-won trick for the rest of the round.
|
|
24
24
|
active = state.get(f"{self.id}_active", False)
|
|
25
25
|
if event.trick_number == 1:
|
|
26
|
-
if event.winner ==
|
|
26
|
+
if team_of(event.winner) == 0:
|
|
27
27
|
state[f"{self.id}_active"] = True
|
|
28
28
|
return JokerResult(add_mult=2.0)
|
|
29
29
|
return None
|
|
30
|
-
if active and event.winner ==
|
|
30
|
+
if active and team_of(event.winner) == 0:
|
|
31
31
|
return JokerResult(add_mult=2.0)
|
|
32
32
|
return None
|
|
33
33
|
|
|
@@ -43,7 +43,7 @@ class LeSergent(Joker):
|
|
|
43
43
|
return None
|
|
44
44
|
|
|
45
45
|
def on_trick_won(self, event: TrickWonEvent, state: dict[str, Any]) -> JokerResult | None:
|
|
46
|
-
if event.winner ==
|
|
46
|
+
if team_of(event.winner) == 0:
|
|
47
47
|
streak = state.get(f"{self.id}_streak", 0) + 1
|
|
48
48
|
state[f"{self.id}_streak"] = streak
|
|
49
49
|
return JokerResult(add_mult=0.5)
|
|
@@ -79,7 +79,7 @@ class LExecuteur(Joker):
|
|
|
79
79
|
is_unlockable = True
|
|
80
80
|
|
|
81
81
|
def on_trick_won(self, event: TrickWonEvent, state: dict[str, Any]) -> JokerResult | None:
|
|
82
|
-
if event.is_last and event.winner ==
|
|
82
|
+
if event.is_last and team_of(event.winner) == 0:
|
|
83
83
|
return JokerResult(add_chips=40, times_mult=1.5)
|
|
84
84
|
return None
|
|
85
85
|
|
|
@@ -375,6 +375,7 @@ class BelAtroGame:
|
|
|
375
375
|
if not lock_trust:
|
|
376
376
|
trust.blind_failed()
|
|
377
377
|
else:
|
|
378
|
+
money_before = self.run.economy.money
|
|
378
379
|
payout = self.run.economy.process_round_end(total - self.run.target_score)
|
|
379
380
|
if auto_coinche_active:
|
|
380
381
|
self.run.economy.add_money(payout * 2) # L'Avocat: triple total payout
|
|
@@ -409,7 +410,8 @@ class BelAtroGame:
|
|
|
409
410
|
# awards bonus money, Café gives +1 trust on big-blind wins).
|
|
410
411
|
theme = self.run.get_ante_theme()
|
|
411
412
|
if theme is not None:
|
|
412
|
-
|
|
413
|
+
blind_payout = self.run.economy.money - money_before
|
|
414
|
+
theme.on_blind_won(self.run, self.run.blind_index, blind_payout)
|
|
413
415
|
|
|
414
416
|
# Partner-specific trust events (skipped under Le Divorce)
|
|
415
417
|
if not lock_trust:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
4
5
|
|
|
5
6
|
from belote.game import Seat
|
|
6
7
|
|
|
@@ -8,6 +9,9 @@ from ..items.base import Joker
|
|
|
8
9
|
from .personality import LeCourageux, PartnerPersonality
|
|
9
10
|
from .trust import TrustTrack
|
|
10
11
|
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ..core.run_state import BelAtroRun
|
|
14
|
+
|
|
11
15
|
|
|
12
16
|
@dataclass
|
|
13
17
|
class PartnerState:
|
|
@@ -31,8 +35,15 @@ class PartnerState:
|
|
|
31
35
|
return "hard"
|
|
32
36
|
return "medium"
|
|
33
37
|
|
|
34
|
-
def equip_joker(self, joker: Joker) -> bool:
|
|
38
|
+
def equip_joker(self, joker: Joker, run: BelAtroRun | None = None) -> bool:
|
|
35
39
|
if len(self.jokers) < self.joker_slots:
|
|
36
40
|
self.jokers.append(joker)
|
|
41
|
+
# Fire the joker's purchase hook when a run is provided. The
|
|
42
|
+
# shop path equips through this method; passing `run` lets
|
|
43
|
+
# purchase-time effects (e.g. permanent stat boosts) apply
|
|
44
|
+
# consistently with the main joker slot equip path. Latent
|
|
45
|
+
# until a partner joker actually defines on_purchase().
|
|
46
|
+
if run is not None:
|
|
47
|
+
joker.on_purchase(run)
|
|
37
48
|
return True
|
|
38
49
|
return False
|
|
@@ -90,8 +90,17 @@ class SaveManager:
|
|
|
90
90
|
_default_stats = dict.fromkeys(
|
|
91
91
|
("runs_won", "total_capots", "sans_atout_wins", "tout_atout_wins"), 0
|
|
92
92
|
)
|
|
93
|
+
# When a saved profile is missing `unlocked_ids` (older saves,
|
|
94
|
+
# manual edits, partial writes), fall back to the Profile
|
|
95
|
+
# dataclass default rather than an empty list — otherwise the
|
|
96
|
+
# player loses their starter unlocks on reload.
|
|
97
|
+
unlocked_ids = (
|
|
98
|
+
list(data["unlocked_ids"])
|
|
99
|
+
if "unlocked_ids" in data
|
|
100
|
+
else Profile().unlocked_ids
|
|
101
|
+
)
|
|
93
102
|
return Profile(
|
|
94
|
-
unlocked_ids=
|
|
103
|
+
unlocked_ids=unlocked_ids,
|
|
95
104
|
discovered_items=data.get("discovered_items", []),
|
|
96
105
|
stats={**_default_stats, **data.get("stats", {})},
|
|
97
106
|
)
|
|
@@ -27,8 +27,13 @@ class AnteTheme:
|
|
|
27
27
|
"""Per-blind multiplier on the target score. Default 1.0 (no change)."""
|
|
28
28
|
return 1.0
|
|
29
29
|
|
|
30
|
-
def on_blind_won(self, run: BelAtroRun, blind_index: int) -> None:
|
|
31
|
-
"""Hook fired after a blind is won under this theme.
|
|
30
|
+
def on_blind_won(self, run: BelAtroRun, blind_index: int, blind_payout: int) -> None:
|
|
31
|
+
"""Hook fired after a blind is won under this theme.
|
|
32
|
+
|
|
33
|
+
`blind_payout` is the net money awarded for the round (all sources:
|
|
34
|
+
base payout, L'Avocat doubling, bonus money, Le Puriste, L'Aristocrate).
|
|
35
|
+
Themes that want "X% of round payout" must derive it from this value.
|
|
36
|
+
"""
|
|
32
37
|
|
|
33
38
|
|
|
34
39
|
class CafeAnte(AnteTheme):
|
|
@@ -53,7 +58,7 @@ class CafeAnte(AnteTheme):
|
|
|
53
58
|
def target_multiplier(self, blind_index: int) -> float:
|
|
54
59
|
return 0.95 if blind_index == 2 else 1.0
|
|
55
60
|
|
|
56
|
-
def on_blind_won(self, run: BelAtroRun, blind_index: int) -> None:
|
|
61
|
+
def on_blind_won(self, run: BelAtroRun, blind_index: int, blind_payout: int) -> None:
|
|
57
62
|
if blind_index == 1:
|
|
58
63
|
run.partner.trust.blind_beaten()
|
|
59
64
|
|
|
@@ -70,10 +75,10 @@ class TournoiAnte(AnteTheme):
|
|
|
70
75
|
def on_ante_start(self, run: BelAtroRun) -> None:
|
|
71
76
|
run.card_enhancements["always_offer_coinche"] = True
|
|
72
77
|
|
|
73
|
-
def on_blind_won(self, run: BelAtroRun, blind_index: int) -> None:
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
run.economy.add_money(max(1,
|
|
78
|
+
def on_blind_won(self, run: BelAtroRun, blind_index: int, blind_payout: int) -> None:
|
|
79
|
+
# True 50% of the round's actual payout (all sources summed).
|
|
80
|
+
# `blind_payout` is computed by the caller from the economy delta.
|
|
81
|
+
run.economy.add_money(max(1, blind_payout // 2))
|
|
77
82
|
|
|
78
83
|
|
|
79
84
|
ALL_ANTE_THEMES: list[type[AnteTheme]] = [CafeAnte, TournoiAnte]
|
|
@@ -1004,11 +1004,6 @@ def play_card(state: GameState, card: Card) -> GameState:
|
|
|
1004
1004
|
)
|
|
1005
1005
|
|
|
1006
1006
|
|
|
1007
|
-
def advance_turn(state: GameState) -> GameState:
|
|
1008
|
-
"""Advance to the next player's turn (used after AI plays)."""
|
|
1009
|
-
return replace(state, turn=state.turn.next_seat())
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
1007
|
def set_announced(state: GameState, msg: str) -> GameState:
|
|
1013
1008
|
return replace(state, announced=msg)
|
|
1014
1009
|
|
|
@@ -25,11 +25,11 @@ from .game import (
|
|
|
25
25
|
bidding_turn,
|
|
26
26
|
clear_announced,
|
|
27
27
|
clear_legal_cards_cache,
|
|
28
|
+
compute_trick_winners,
|
|
28
29
|
play_card,
|
|
29
30
|
process_bid,
|
|
30
31
|
start_round,
|
|
31
32
|
team_of,
|
|
32
|
-
trick_winner_seat,
|
|
33
33
|
)
|
|
34
34
|
from .input import KeyReader, interruptible_sleep
|
|
35
35
|
from .scoring import (
|
|
@@ -191,11 +191,16 @@ def run_play(
|
|
|
191
191
|
# animations or is on the "instant" speed preset.
|
|
192
192
|
interruptible_sleep(MIN_TRICK_DWELL, None)
|
|
193
193
|
if len(current.completed_tricks) == 7: # This was the 8th trick
|
|
194
|
-
se_trump = current.boss_modifiers.seven_eight_trump
|
|
195
194
|
is_sa = current.contract == "sans_atout"
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
)
|
|
195
|
+
# Use the Rupture-aware helper so the announcement names the
|
|
196
|
+
# team that actually gets credited in scoring (see
|
|
197
|
+
# `compute_trick_winners` in game.py). Pass the projected
|
|
198
|
+
# 8-trick list because the 8th trick hasn't been pushed to
|
|
199
|
+
# `completed_tricks` yet.
|
|
200
|
+
projected = list(current.completed_tricks) + [display_state.current_trick]
|
|
201
|
+
winner = compute_trick_winners(
|
|
202
|
+
current, current.trump, is_sa, tricks=projected
|
|
203
|
+
)[-1]
|
|
199
204
|
if winner:
|
|
200
205
|
team = "NS" if team_of(winner) == 0 else "EW"
|
|
201
206
|
announce(
|