belote-cli 4.9.4__tar.gz → 4.9.5__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.9.4 → belote_cli-4.9.5}/CHANGELOG.md +57 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/DEVELOPMENT.md +6 -4
- {belote_cli-4.9.4 → belote_cli-4.9.5}/PKG-INFO +3 -3
- {belote_cli-4.9.4 → belote_cli-4.9.5}/README.md +2 -2
- {belote_cli-4.9.4 → belote_cli-4.9.5}/pyproject.toml +1 -1
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/__init__.py +1 -1
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ai.py +28 -19
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ansi.py +14 -3
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/partner_jokers/passive.py +4 -1
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/main.py +23 -10
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/partner/trust.py +10 -6
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/history.py +93 -74
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/hud.py +11 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/trust_bar.py +4 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/config.py +6 -1
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/game.py +19 -17
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/gameflow.py +11 -5
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/scoring.py +25 -15
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ui/render.py +4 -1
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_jokers_4_5.py +26 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_partner_jokers.py +11 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_alt_screen_scroll.py +6 -0
- belote_cli-4.9.5/tests/test_deluge_legal_cards.py +68 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_g1_coinche_classic.py +30 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_g2_ai_signals.py +49 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_new_coverage.py +3 -2
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_official_rules.py +21 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/.claude/settings.local.json +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/.gitignore +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/.python-version +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/LICENSE +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/scripts/benchmark.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/__init__.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/a11y.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/achievements.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/__init__.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/core/__init__.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/core/economy.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/core/round_ledger.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/core/run_state.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/core/scoring.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/engine/__init__.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/engine/event_bus.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/engine/modifier_patch.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/engine/round_driver.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ghost_run.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/__init__.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/base.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/jokers/__init__.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/jokers/annonces.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/jokers/coinche.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/jokers/contract.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/jokers/corrupted.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/jokers/economy.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/planets.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/registry.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/tarots.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/items/vouchers.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/partner/__init__.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/partner/partner_state.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/partner/personality.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/progression/__init__.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/progression/save.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/progression/unlocks.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/run/__init__.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/run/ante.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/run/ante_themes.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/run/boss.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/run/decks.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/run/shop.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/run_summary.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/__init__.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/announce.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/collection.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/consumables.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/inventory.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/menu.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/rules.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/belatro/ui/shop.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/context.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/deck.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/input.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/main.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/replay.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/rules.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/stats.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/themes.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ui/__init__.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ui/anim.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ui/announce.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ui/fit_guard.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ui/layout.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ui/menu.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/src/belote/ui/prompts.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/__init__.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/__init__.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_belatro.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_boss_contracts.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_boss_modifiers_integration.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_collection_logic.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_competition_malediction.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_consumables_ui.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_contract_unlocks.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_dead_flag_fixes.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_deck_variants.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_decks_4_5.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_endless.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_event_bus.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_ghost_run.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_handler_registry.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_heist.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_history_overlay.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_hud_synergy.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_hud_toggle.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_inventory_overlay.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_joker_callouts.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_joker_contracts.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_partner_trust.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_phase0_coverage.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_phase1_plumbing.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_phase2_content.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_phase3_meta.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_progression.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_round_driver.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_run_summary.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_shop_animations.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_shop_empty_pools.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_slot_machine_tally.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/belatro/test_voucher_idempotency.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/perf_baselines.json +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_a11y.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_achievements.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_ai.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_ai_deluge_voids.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_ai_sans_atout.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_anim_helpers.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_announce_stats.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_ansi_helpers.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_belote.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_belote_stinger.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_benchmark_smoke.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_bidding_all_pass.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_declaration_tiebreak.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_extended.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_game_logic.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_gameflow.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_hand_auto_sort.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_history_overlay_cache.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_input_eof.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_input_wasd.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_invariants.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_layout.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_no_color.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_perf_regression.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_properties.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_render_diff.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_render_felt_polish.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_render_hand_lift.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_replay.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_undo.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/tests/test_winner_glow.py +0 -0
- {belote_cli-4.9.4 → belote_cli-4.9.5}/uv.lock +0 -0
|
@@ -5,6 +5,63 @@ 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.9.5] - 2026-05-28
|
|
9
|
+
|
|
10
|
+
Bug-hunt, logic, and performance audit pass. Three parallel Explore agents
|
|
11
|
+
swept the classic engine, BelAtro mechanics, and the UI/render layer; every
|
|
12
|
+
finding below was traced to exact code and the high-severity ones reproduced at
|
|
13
|
+
runtime before fixing. Test count 1066 → 1079.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Crash: `legal_cards` returned an empty set under La Déluge**
|
|
18
|
+
(`src/belote/game.py`). With the `seven_eight_trump` boss active, every 7/8
|
|
19
|
+
is a trump — but the trump-follow set was built from `c.suit == lead_suit`,
|
|
20
|
+
which dropped off-suit 7/8s. A player void of the trump *suit* but holding
|
|
21
|
+
only off-suit 7/8 (which ARE trumps) got zero legal cards on a trump lead,
|
|
22
|
+
tripping `AIPlayer.decide_card`'s empty-legal assertion. A single
|
|
23
|
+
`is_trump_card` predicate now drives both the trump-led and non-trump-led
|
|
24
|
+
follow/overtrump paths. Regression tests in `tests/test_deluge_legal_cards.py`.
|
|
25
|
+
- **AI partner-signal double-count after a mid-round undo** (`src/belote/ai.py`).
|
|
26
|
+
The undo-regression branch reset the void cache and `processed_tricks_count`
|
|
27
|
+
but not the partner-signal tally; because `_update_voids` re-walks surviving
|
|
28
|
+
tricks from zero, their signals were counted twice. Both reset paths now share
|
|
29
|
+
a single `_reset_round_memory()` helper so they can't drift again.
|
|
30
|
+
- **Belote + Rebelote scored 40 instead of the official 20** (`src/belote/config.py`).
|
|
31
|
+
Holding the King and Queen of trump is worth 20 points *total* in official
|
|
32
|
+
French Belote — and the in-game Rules screen already said 20. `REBELOTE_POINTS`
|
|
33
|
+
is now 20, matching both.
|
|
34
|
+
- **Coinche round-end animation showed the pre-multiplier score**
|
|
35
|
+
(`src/belote/gameflow.py`). The score counter animated to the raw breakdown
|
|
36
|
+
total while the stored score applied `2 ** coinche_level`. Both now use the
|
|
37
|
+
shared `scoring.coinche_adjusted_credits()` helper, so the animation lands on
|
|
38
|
+
the real total.
|
|
39
|
+
- **Joker money not reconciled on a failed-but-survived round**
|
|
40
|
+
(`src/belote/belatro/main.py`). Signed `JokerResult.add_money` (e.g.
|
|
41
|
+
LePrêteur's −$5 skim, baked into the round's ×Mult) was only applied on the
|
|
42
|
+
success path, handing a free multiplier when a chute was survived via Capot
|
|
43
|
+
Insurance. A shared `_reconcile_bonus_money()` helper now runs on both paths.
|
|
44
|
+
- **La Symbiose fired ×1.2 Mult on zero-point declarations under Le Mime**
|
|
45
|
+
(`src/belote/belatro/items/partner_jokers/passive.py`). Now gated on
|
|
46
|
+
`event.points > 0`, mirroring Le Mathématicien.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- **BelAtro HUD / TrustBar now invalidate the render diff** after their direct
|
|
51
|
+
stdout writes (`belatro/ui/hud.py`, `trust_bar.py`), matching the 4.0.0
|
|
52
|
+
overlay convention so a static-state `display()` can't leave a stale top bar.
|
|
53
|
+
The convention is now enforced for both files by
|
|
54
|
+
`tests/test_alt_screen_scroll.py`.
|
|
55
|
+
- **Performance**: `_pip_at`'s LRU cache raised 4096 → 8192 so wide-terminal
|
|
56
|
+
felt mats (>5000 cells/frame) stop thrashing it (`ui/render.py`);
|
|
57
|
+
`visible_len` no longer spends cache slots on plain (no-ANSI) strings
|
|
58
|
+
(`ansi.py`); the BelAtro [H] history overlay caches its line list on
|
|
59
|
+
`(term_w, entry_count)` instead of rebuilding every keystroke
|
|
60
|
+
(`belatro/ui/history.py`).
|
|
61
|
+
- **Docs**: corrected the trust tier-0 docstring (it claimed "effects halved";
|
|
62
|
+
the implementation applies the baseline once with no penalty) and the tier
|
|
63
|
+
bonus percentages to match the `(0, 0, 1, 1, 2)` extra-applies model.
|
|
64
|
+
|
|
8
65
|
## [4.9.4] - 2026-05-26
|
|
9
66
|
|
|
10
67
|
Audit + maintenance pass. Three parallel Explore agents reviewed game-logic
|
|
@@ -84,7 +84,7 @@ PYTHONPATH=src mypy --strict src/
|
|
|
84
84
|
# Linting (0 violations expected)
|
|
85
85
|
ruff check src/ tests/
|
|
86
86
|
|
|
87
|
-
# Full test suite (
|
|
87
|
+
# Full test suite (1079 tests expected)
|
|
88
88
|
PYTHONPATH=src pytest
|
|
89
89
|
```
|
|
90
90
|
|
|
@@ -221,6 +221,8 @@ PYTHONPATH=src python scripts/benchmark.py
|
|
|
221
221
|
If you suspect a per-frame allocation regression, the canonical hot paths to
|
|
222
222
|
profile live (e.g. with `py-spy record --rate 100`) are `render()` in
|
|
223
223
|
`src/belote/ui/render.py` and `_calculate_legal_cards_impl` in
|
|
224
|
-
`src/belote/game.py`. The 4.9.4 audit
|
|
225
|
-
already landed in the 4.6.x / 4.7.x perf cleanups
|
|
226
|
-
|
|
224
|
+
`src/belote/game.py`. The 4.9.4 / 4.9.5 audit passes found only minor wins
|
|
225
|
+
beyond those already landed in the 4.6.x / 4.7.x perf cleanups (4.9.5 bumped
|
|
226
|
+
the `_pip_at` cache to 8192, trimmed `visible_len`'s cache to ANSI strings,
|
|
227
|
+
and memoised the BelAtro history overlay) — start from a profiler trace, not
|
|
228
|
+
from guesswork, before adding new caches.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: belote-cli
|
|
3
|
-
Version: 4.9.
|
|
3
|
+
Version: 4.9.5
|
|
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
|
|
@@ -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 (1079 tests)
|
|
268
268
|
├── scripts/ # Performance benchmarks
|
|
269
269
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
270
270
|
├── LICENSE # MIT License
|
|
@@ -279,7 +279,7 @@ belote/
|
|
|
279
279
|
PYTHONPATH=src pytest
|
|
280
280
|
```
|
|
281
281
|
|
|
282
|
-
Currently **
|
|
282
|
+
Currently **1079 tests** passing with 100% coverage on game-logic modules (4.9.5).
|
|
283
283
|
|
|
284
284
|
## Technical Integrity
|
|
285
285
|
|
|
@@ -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 (1079 tests)
|
|
225
225
|
├── scripts/ # Performance benchmarks
|
|
226
226
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
227
227
|
├── LICENSE # MIT License
|
|
@@ -236,7 +236,7 @@ belote/
|
|
|
236
236
|
PYTHONPATH=src pytest
|
|
237
237
|
```
|
|
238
238
|
|
|
239
|
-
Currently **
|
|
239
|
+
Currently **1079 tests** passing with 100% coverage on game-logic modules (4.9.5).
|
|
240
240
|
|
|
241
241
|
## Technical Integrity
|
|
242
242
|
|
|
@@ -74,6 +74,27 @@ class AIPlayer:
|
|
|
74
74
|
# All ranking helpers in this class read it via self._se.
|
|
75
75
|
self._se = False
|
|
76
76
|
|
|
77
|
+
def _reset_round_memory(self) -> None:
|
|
78
|
+
"""Clear all per-round derived memory back to a clean slate.
|
|
79
|
+
|
|
80
|
+
Shared by the new-round and mid-round-undo paths in ``update_memory`` so
|
|
81
|
+
the two can't drift: a partial reset (e.g. clearing the void cache but
|
|
82
|
+
not the partner-signal tally) would let ``_update_voids`` re-walk the
|
|
83
|
+
surviving tricks and double-count their signals.
|
|
84
|
+
"""
|
|
85
|
+
self.memory.played.clear()
|
|
86
|
+
for s in Seat:
|
|
87
|
+
self.memory.known_voids[s].clear()
|
|
88
|
+
self.memory.partner_hand.clear()
|
|
89
|
+
self.memory.processed_tricks_count = 0
|
|
90
|
+
self.memory.last_voids_key = None
|
|
91
|
+
self.memory.last_partner_hand_key = None
|
|
92
|
+
# 4.9.0 / G2: partner-signal tally and our emit-counter follow
|
|
93
|
+
# the same reset pattern as the void cache.
|
|
94
|
+
for suit in Suit:
|
|
95
|
+
self.memory.signals[suit] = 0
|
|
96
|
+
self.memory.signals_emitted = 0
|
|
97
|
+
|
|
77
98
|
def update_memory(self, state: GameState) -> None:
|
|
78
99
|
"""Update memory with currently visible information."""
|
|
79
100
|
completed_count = len(state.completed_tricks)
|
|
@@ -84,18 +105,7 @@ class AIPlayer:
|
|
|
84
105
|
# this a (0, 0) / (0, 1) key from the first decision of *this* round
|
|
85
106
|
# could coincidentally match a leftover from the previous round and
|
|
86
107
|
# cause _update_voids to skip processing entirely.
|
|
87
|
-
self.
|
|
88
|
-
for s in Seat:
|
|
89
|
-
self.memory.known_voids[s].clear()
|
|
90
|
-
self.memory.partner_hand.clear()
|
|
91
|
-
self.memory.processed_tricks_count = 0
|
|
92
|
-
self.memory.last_voids_key = None
|
|
93
|
-
self.memory.last_partner_hand_key = None
|
|
94
|
-
# 4.9.0 / G2: partner-signal tally and our emit-counter follow
|
|
95
|
-
# the same triple-reset pattern as the void cache.
|
|
96
|
-
for suit in Suit:
|
|
97
|
-
self.memory.signals[suit] = 0
|
|
98
|
-
self.memory.signals_emitted = 0
|
|
108
|
+
self._reset_round_memory()
|
|
99
109
|
elif (
|
|
100
110
|
self.memory.last_voids_key is not None
|
|
101
111
|
and (completed_count, current_count) < self.memory.last_voids_key
|
|
@@ -104,13 +114,12 @@ class AIPlayer:
|
|
|
104
114
|
# we've processed. `known_voids` and `processed_tricks_count`
|
|
105
115
|
# are monotonic and would carry stale inferences forward
|
|
106
116
|
# (a void inferred from a now-rolled-back trick). Rebuild from
|
|
107
|
-
# the current state instead of trying to subtract.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
self.
|
|
113
|
-
self.memory.last_partner_hand_key = None
|
|
117
|
+
# the current state instead of trying to subtract. The signal
|
|
118
|
+
# tally is rebuilt by _update_voids' re-walk (processed count is
|
|
119
|
+
# reset to 0), so it MUST be cleared here too — otherwise the
|
|
120
|
+
# surviving tricks' signals are double-counted. Same full reset
|
|
121
|
+
# as a new round.
|
|
122
|
+
self._reset_round_memory()
|
|
114
123
|
|
|
115
124
|
# Track all cards in completed tricks. N is small (max 32 cards), so
|
|
116
125
|
# the O(N) re-walk is fine and keeps this in sync with the transient
|
|
@@ -101,12 +101,23 @@ _RESET_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
|
|
|
101
101
|
RESET = "\x1b[0m"
|
|
102
102
|
|
|
103
103
|
|
|
104
|
-
@lru_cache(maxsize=
|
|
104
|
+
@lru_cache(maxsize=2048)
|
|
105
|
+
def _visible_len_ansi(s: str) -> int:
|
|
106
|
+
return len(_RESET_RE.sub("", s))
|
|
107
|
+
|
|
108
|
+
|
|
105
109
|
def visible_len(s: str) -> int:
|
|
106
|
-
"""Return length of string with ANSI escape codes stripped.
|
|
110
|
+
"""Return length of string with ANSI escape codes stripped.
|
|
111
|
+
|
|
112
|
+
4.9.5 (P3): only the regex-stripping path is cached. Plain (no-ESC) strings
|
|
113
|
+
return ``len(s)`` directly instead of occupying cache slots, so the bounded
|
|
114
|
+
cache is reserved for the ANSI strings that actually benefit from memoising
|
|
115
|
+
the regex sub (static glyphs, frames). Keeps the cache small and the
|
|
116
|
+
hit-rate high.
|
|
117
|
+
"""
|
|
107
118
|
if "\x1b" not in s:
|
|
108
119
|
return len(s)
|
|
109
|
-
return
|
|
120
|
+
return _visible_len_ansi(s)
|
|
110
121
|
|
|
111
122
|
|
|
112
123
|
def ansi_center(s: str, width: int) -> str:
|
|
@@ -29,7 +29,10 @@ class LaSymbiose(Joker):
|
|
|
29
29
|
is_partner_joker = True
|
|
30
30
|
|
|
31
31
|
def on_declaration(self, event: DeclarationScoredEvent, state: dict[str, Any]) -> JokerResult | None:
|
|
32
|
-
|
|
32
|
+
# Gate on points so Le Mime (declarations_zero) — which emits
|
|
33
|
+
# declarations worth 0 — doesn't grant the multiplier for a
|
|
34
|
+
# declaration that scored nothing. Mirrors LeMathematicien's guard.
|
|
35
|
+
if event.seat == Seat.NORTH and event.points > 0:
|
|
33
36
|
return JokerResult(times_mult=1.2)
|
|
34
37
|
return None
|
|
35
38
|
|
|
@@ -326,6 +326,22 @@ class BelAtroGame:
|
|
|
326
326
|
for msg in self.unlock_tracker.drain_announcements():
|
|
327
327
|
BelAtroAnnounce.banner(msg, self.reader, hold=1.5)
|
|
328
328
|
|
|
329
|
+
def _reconcile_bonus_money(self, bonus_money: int) -> None:
|
|
330
|
+
"""Apply a round's signed joker money to the wallet.
|
|
331
|
+
|
|
332
|
+
``final_state._bonus_money`` accumulates ``JokerResult.add_money`` over
|
|
333
|
+
the round (positive = credit, negative = debit such as LePreteur's -$5
|
|
334
|
+
skim). Positive amounts credit; negative amounts route through
|
|
335
|
+
``spend_money`` (which no-ops if the wallet can't cover it) so the
|
|
336
|
+
Economy's non-negative guard on ``add_money`` is respected.
|
|
337
|
+
"""
|
|
338
|
+
if self.run is None:
|
|
339
|
+
return
|
|
340
|
+
if bonus_money > 0:
|
|
341
|
+
self.run.economy.add_money(bonus_money)
|
|
342
|
+
elif bonus_money < 0:
|
|
343
|
+
self.run.economy.spend_money(-bonus_money)
|
|
344
|
+
|
|
329
345
|
def _run_loop(self) -> None:
|
|
330
346
|
"""Main game loop: Blind -> Shop -> Next."""
|
|
331
347
|
if self.run is None:
|
|
@@ -617,6 +633,12 @@ class BelAtroGame:
|
|
|
617
633
|
color="red",
|
|
618
634
|
hold=2.5,
|
|
619
635
|
)
|
|
636
|
+
else:
|
|
637
|
+
# The run continues past this chute. Reconcile signed joker
|
|
638
|
+
# money (e.g. LePreteur's -$5 skim, which was baked into the
|
|
639
|
+
# round's ×Mult) so the player isn't handed a free multiplier
|
|
640
|
+
# for the surviving round. spend_money no-ops if broke.
|
|
641
|
+
self._reconcile_bonus_money(final_state._bonus_money)
|
|
620
642
|
if not lock_trust:
|
|
621
643
|
trust.blind_failed()
|
|
622
644
|
else:
|
|
@@ -624,16 +646,7 @@ class BelAtroGame:
|
|
|
624
646
|
payout = self.run.economy.process_round_end(total - self.run.target_score)
|
|
625
647
|
if auto_coinche_active:
|
|
626
648
|
self.run.economy.add_money(payout * 2) # L'Avocat: triple total payout
|
|
627
|
-
|
|
628
|
-
# Pre-4.6.5 this branch was `> 0`, which silently dropped LePreteur's
|
|
629
|
-
# `-5` cost (and any other negative-add_money joker) while still
|
|
630
|
-
# applying the multiplier — free ×1.2 Mult on every $50+ round.
|
|
631
|
-
if final_state._bonus_money > 0:
|
|
632
|
-
self.run.economy.add_money(final_state._bonus_money)
|
|
633
|
-
elif final_state._bonus_money < 0:
|
|
634
|
-
# Route through spend_money so the Economy negative guard fires
|
|
635
|
-
# if accumulated debit somehow exceeds wallet (caller's bug).
|
|
636
|
-
self.run.economy.spend_money(-final_state._bonus_money)
|
|
649
|
+
self._reconcile_bonus_money(final_state._bonus_money)
|
|
637
650
|
if final_state._joker_state.get("puriste_triggered"):
|
|
638
651
|
extra = max(0, (total - self.run.target_score) // 10)
|
|
639
652
|
self.run.economy.add_money(extra) # Le Puriste: double base payout
|
|
@@ -73,15 +73,19 @@ class TrustTrack:
|
|
|
73
73
|
def tier(self) -> int:
|
|
74
74
|
"""Five-tier bucketing for partner-joker effect scaling.
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
Scaling is implemented as N *extra* applications of the joker's
|
|
77
|
+
JokerResult on top of the baseline one (see
|
|
78
|
+
``ScoreAccumulator._fire_jokers``; ``tier_extras = (0, 0, 1, 1, 2)``):
|
|
79
|
+
|
|
80
|
+
0 (degraded, value 0–2) — baseline only (no bonus, no penalty)
|
|
81
|
+
1 (base, value 3–4) — baseline only
|
|
82
|
+
2 (boost, value 5–6) — +1 apply (≈ ×2 effect)
|
|
83
|
+
3 (strong, value 7–8) — +1 apply (≈ ×2 effect)
|
|
84
|
+
4 (elite, value 9–10) — +2 applies (≈ ×3 effect)
|
|
81
85
|
|
|
82
86
|
Used by partner_jokers/* to scale their JokerResult payloads. The
|
|
83
87
|
legacy `partner_jokers_double` flag (value≥7) still exists for backward
|
|
84
|
-
compatibility but
|
|
88
|
+
compatibility but tier is the new path.
|
|
85
89
|
"""
|
|
86
90
|
if self.value <= 2:
|
|
87
91
|
return 0
|
|
@@ -90,90 +90,109 @@ def _money_cell(delta: int) -> str:
|
|
|
90
90
|
return f"{DIM}$0{RESET}"
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
def _build_history_lines(entries: list[BelAtroHistoryEntry], term_w: int) -> list[str]:
|
|
94
|
+
"""Build the full (pre-scroll) line list for the overlay.
|
|
95
|
+
|
|
96
|
+
Pure function of ``(entries, term_w)`` — the scroll position doesn't affect
|
|
97
|
+
it — so ``show_belatro_history`` memoises the result across keystrokes
|
|
98
|
+
instead of rebuilding ~3 lines per entry on every ↑/↓ (mirrors the classic
|
|
99
|
+
``belote.ui.prompts._build_history_lines`` caching).
|
|
100
|
+
"""
|
|
101
|
+
lines: list[str] = []
|
|
102
|
+
lines.append(f"{BOLD}{gold_fg()}BELATRO RUN HISTORY{RESET}")
|
|
103
|
+
lines.append("=" * 19)
|
|
104
|
+
lines.append("")
|
|
105
|
+
|
|
106
|
+
if not entries:
|
|
107
|
+
lines.append(f"{DIM}No blinds completed yet.{RESET}")
|
|
108
|
+
else:
|
|
109
|
+
# Wide layout: single-row record. ~84 visible chars at minimum.
|
|
110
|
+
wide = term_w >= 90
|
|
111
|
+
if wide:
|
|
112
|
+
w_no, w_ante, w_bl, w_tgt, w_boss, w_tkr, w_con, w_trk, w_score, w_st, w_money = (
|
|
113
|
+
3, 4, 5, 5, 14, 7, 4, 7, 7, 8, 6,
|
|
114
|
+
)
|
|
115
|
+
header_cells = [
|
|
116
|
+
_ljust_visible("#", w_no),
|
|
117
|
+
_ljust_visible("ANTE", w_ante),
|
|
118
|
+
_ljust_visible("BLIND", w_bl),
|
|
119
|
+
_ljust_visible("TGT", w_tgt),
|
|
120
|
+
_ljust_visible("BOSS", w_boss),
|
|
121
|
+
_ljust_visible("TAKER", w_tkr),
|
|
122
|
+
_ljust_visible("CON", w_con),
|
|
123
|
+
_ljust_visible("TRICKS", w_trk),
|
|
124
|
+
_ljust_visible("SCORE", w_score),
|
|
125
|
+
_ljust_visible("STATUS", w_st),
|
|
126
|
+
_ljust_visible("$Δ", w_money),
|
|
127
|
+
]
|
|
128
|
+
header = " │ ".join(header_cells)
|
|
129
|
+
lines.append(f"{BOLD}{white_fg()}{header}{RESET}")
|
|
130
|
+
lines.append("─" * visible_len(header))
|
|
131
|
+
|
|
132
|
+
for i, e in enumerate(entries):
|
|
133
|
+
boss = e.boss_name or "─"
|
|
134
|
+
if visible_len(boss) > w_boss:
|
|
135
|
+
boss = boss[: w_boss - 1] + "…"
|
|
136
|
+
row_cells = [
|
|
137
|
+
_ljust_visible(f"{i + 1:02d}", w_no),
|
|
138
|
+
_ljust_visible(str(e.ante), w_ante),
|
|
139
|
+
_ljust_visible(e.blind_label, w_bl),
|
|
140
|
+
_ljust_visible(str(e.target), w_tgt),
|
|
141
|
+
_ljust_visible(boss, w_boss),
|
|
142
|
+
_ljust_visible(e.taker_label, w_tkr),
|
|
143
|
+
_ljust_visible(e.contract, w_con),
|
|
144
|
+
_ljust_visible(f"{e.tricks_ns}/{e.tricks_ew}", w_trk),
|
|
145
|
+
_ljust_visible(f"{BOLD}{e.score}{RESET}", w_score),
|
|
146
|
+
_ljust_visible(_status_cell(e.status), w_st),
|
|
147
|
+
_ljust_visible(_money_cell(e.money_delta), w_money),
|
|
148
|
+
]
|
|
149
|
+
lines.append(" │ ".join(row_cells))
|
|
150
|
+
else:
|
|
151
|
+
# Compact three-line-per-row layout for narrow terminals.
|
|
152
|
+
lines.append(
|
|
153
|
+
f"{BOLD}{white_fg()}{'#':<3} {'A.B':<4} {'TGT':<5} "
|
|
154
|
+
f"{'SCORE':<7} STATUS{RESET}"
|
|
155
|
+
)
|
|
156
|
+
lines.append("─" * 40)
|
|
157
|
+
for i, e in enumerate(entries):
|
|
158
|
+
a_b = f"{e.ante}.{e.blind_label[0]}"
|
|
159
|
+
lines.append(
|
|
160
|
+
f"{i + 1:02d} {a_b:<4} {e.target:<5} "
|
|
161
|
+
f"{BOLD}{e.score:<7}{RESET}{_status_cell(e.status)}"
|
|
162
|
+
)
|
|
163
|
+
boss = e.boss_name or "─"
|
|
164
|
+
lines.append(
|
|
165
|
+
f" boss: {boss} taker: {e.taker_label} con: {e.contract} "
|
|
166
|
+
f"tricks: {e.tricks_ns}/{e.tricks_ew} {_money_cell(e.money_delta)}"
|
|
167
|
+
)
|
|
168
|
+
decl_n = _decl_str(e.decl_summary_ns, 14)
|
|
169
|
+
decl_e = _decl_str(e.decl_summary_ew, 14)
|
|
170
|
+
lines.append(f" decl: {decl_n} / {decl_e}")
|
|
171
|
+
lines.append("")
|
|
172
|
+
|
|
173
|
+
lines.append("")
|
|
174
|
+
lines.append(f"{DIM}[↑↓] Scroll [Any Key] Return{RESET}")
|
|
175
|
+
return lines
|
|
176
|
+
|
|
177
|
+
|
|
93
178
|
def show_belatro_history(reader: KeyReader, entries: list[BelAtroHistoryEntry]) -> None:
|
|
94
179
|
"""Scrollable BelAtro round-by-round overlay; called via the [H] hook."""
|
|
95
180
|
from belote.ui.fit_guard import require_minimum
|
|
96
181
|
|
|
97
182
|
scroll = 0
|
|
183
|
+
cached_key: tuple[int, int] | None = None
|
|
184
|
+
lines: list[str] = []
|
|
98
185
|
|
|
99
186
|
while True:
|
|
100
187
|
require_minimum(reader)
|
|
101
188
|
term_w, term_h = get_term_size()
|
|
102
189
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
lines.append(f"{DIM}No blinds completed yet.{RESET}")
|
|
110
|
-
else:
|
|
111
|
-
# Wide layout: single-row record. ~84 visible chars at minimum.
|
|
112
|
-
wide = term_w >= 90
|
|
113
|
-
if wide:
|
|
114
|
-
w_no, w_ante, w_bl, w_tgt, w_boss, w_tkr, w_con, w_trk, w_score, w_st, w_money = (
|
|
115
|
-
3, 4, 5, 5, 14, 7, 4, 7, 7, 8, 6,
|
|
116
|
-
)
|
|
117
|
-
header_cells = [
|
|
118
|
-
_ljust_visible("#", w_no),
|
|
119
|
-
_ljust_visible("ANTE", w_ante),
|
|
120
|
-
_ljust_visible("BLIND", w_bl),
|
|
121
|
-
_ljust_visible("TGT", w_tgt),
|
|
122
|
-
_ljust_visible("BOSS", w_boss),
|
|
123
|
-
_ljust_visible("TAKER", w_tkr),
|
|
124
|
-
_ljust_visible("CON", w_con),
|
|
125
|
-
_ljust_visible("TRICKS", w_trk),
|
|
126
|
-
_ljust_visible("SCORE", w_score),
|
|
127
|
-
_ljust_visible("STATUS", w_st),
|
|
128
|
-
_ljust_visible("$Δ", w_money),
|
|
129
|
-
]
|
|
130
|
-
header = " │ ".join(header_cells)
|
|
131
|
-
lines.append(f"{BOLD}{white_fg()}{header}{RESET}")
|
|
132
|
-
lines.append("─" * visible_len(header))
|
|
133
|
-
|
|
134
|
-
for i, e in enumerate(entries):
|
|
135
|
-
boss = e.boss_name or "─"
|
|
136
|
-
if visible_len(boss) > w_boss:
|
|
137
|
-
boss = boss[: w_boss - 1] + "…"
|
|
138
|
-
row_cells = [
|
|
139
|
-
_ljust_visible(f"{i + 1:02d}", w_no),
|
|
140
|
-
_ljust_visible(str(e.ante), w_ante),
|
|
141
|
-
_ljust_visible(e.blind_label, w_bl),
|
|
142
|
-
_ljust_visible(str(e.target), w_tgt),
|
|
143
|
-
_ljust_visible(boss, w_boss),
|
|
144
|
-
_ljust_visible(e.taker_label, w_tkr),
|
|
145
|
-
_ljust_visible(e.contract, w_con),
|
|
146
|
-
_ljust_visible(f"{e.tricks_ns}/{e.tricks_ew}", w_trk),
|
|
147
|
-
_ljust_visible(f"{BOLD}{e.score}{RESET}", w_score),
|
|
148
|
-
_ljust_visible(_status_cell(e.status), w_st),
|
|
149
|
-
_ljust_visible(_money_cell(e.money_delta), w_money),
|
|
150
|
-
]
|
|
151
|
-
lines.append(" │ ".join(row_cells))
|
|
152
|
-
else:
|
|
153
|
-
# Compact three-line-per-row layout for narrow terminals.
|
|
154
|
-
lines.append(
|
|
155
|
-
f"{BOLD}{white_fg()}{'#':<3} {'A.B':<4} {'TGT':<5} "
|
|
156
|
-
f"{'SCORE':<7} STATUS{RESET}"
|
|
157
|
-
)
|
|
158
|
-
lines.append("─" * 40)
|
|
159
|
-
for i, e in enumerate(entries):
|
|
160
|
-
a_b = f"{e.ante}.{e.blind_label[0]}"
|
|
161
|
-
lines.append(
|
|
162
|
-
f"{i + 1:02d} {a_b:<4} {e.target:<5} "
|
|
163
|
-
f"{BOLD}{e.score:<7}{RESET}{_status_cell(e.status)}"
|
|
164
|
-
)
|
|
165
|
-
boss = e.boss_name or "─"
|
|
166
|
-
lines.append(
|
|
167
|
-
f" boss: {boss} taker: {e.taker_label} con: {e.contract} "
|
|
168
|
-
f"tricks: {e.tricks_ns}/{e.tricks_ew} {_money_cell(e.money_delta)}"
|
|
169
|
-
)
|
|
170
|
-
decl_n = _decl_str(e.decl_summary_ns, 14)
|
|
171
|
-
decl_e = _decl_str(e.decl_summary_ew, 14)
|
|
172
|
-
lines.append(f" decl: {decl_n} / {decl_e}")
|
|
173
|
-
lines.append("")
|
|
174
|
-
|
|
175
|
-
lines.append("")
|
|
176
|
-
lines.append(f"{DIM}[↑↓] Scroll [Any Key] Return{RESET}")
|
|
190
|
+
# The line list only depends on (term_w, entry count); rebuild it only
|
|
191
|
+
# when one of those changes, not on every scroll keystroke.
|
|
192
|
+
key = (term_w, len(entries))
|
|
193
|
+
if key != cached_key:
|
|
194
|
+
lines = _build_history_lines(entries, term_w)
|
|
195
|
+
cached_key = key
|
|
177
196
|
|
|
178
197
|
view_h = max(1, term_h - 4)
|
|
179
198
|
max_scroll = max(0, len(lines) - view_h)
|
|
@@ -287,6 +287,10 @@ class BelAtroHUD:
|
|
|
287
287
|
|
|
288
288
|
sys.stdout.write("".join(parts))
|
|
289
289
|
sys.stdout.flush()
|
|
290
|
+
# This write bypasses display(); invalidate the render-diff baseline so
|
|
291
|
+
# the next display() repaints rows the HUD overwrote (4.0.0 convention).
|
|
292
|
+
from belote.ui.render import invalidate_diff
|
|
293
|
+
invalidate_diff()
|
|
290
294
|
|
|
291
295
|
def _render_compact(
|
|
292
296
|
self,
|
|
@@ -362,6 +366,9 @@ class BelAtroHUD:
|
|
|
362
366
|
|
|
363
367
|
sys.stdout.write("".join(parts))
|
|
364
368
|
sys.stdout.flush()
|
|
369
|
+
# Bypasses display(); invalidate the render-diff baseline (4.0.0 conv).
|
|
370
|
+
from belote.ui.render import invalidate_diff
|
|
371
|
+
invalidate_diff()
|
|
365
372
|
|
|
366
373
|
|
|
367
374
|
# ── 3.4.0: joker pip strip + synergy tooltip ────────────────────────────────
|
|
@@ -450,6 +457,8 @@ def render_joker_pip_strip(run: BelAtroRun, term_w: int, row: int = 1) -> None:
|
|
|
450
457
|
return
|
|
451
458
|
sys.stdout.write(strip)
|
|
452
459
|
sys.stdout.flush()
|
|
460
|
+
from belote.ui.render import invalidate_diff
|
|
461
|
+
invalidate_diff()
|
|
453
462
|
|
|
454
463
|
|
|
455
464
|
def build_synergy_tooltip(jokers: Sequence[object], term_w: int, row: int = 5) -> str:
|
|
@@ -492,3 +501,5 @@ def render_synergy_tooltip(jokers: Sequence[object], term_w: int, row: int = 5)
|
|
|
492
501
|
return
|
|
493
502
|
sys.stdout.write(out)
|
|
494
503
|
sys.stdout.flush()
|
|
504
|
+
from belote.ui.render import invalidate_diff
|
|
505
|
+
invalidate_diff()
|
|
@@ -74,6 +74,10 @@ class TrustBar:
|
|
|
74
74
|
+ white_fg() + "] " + RESET
|
|
75
75
|
+ status
|
|
76
76
|
)
|
|
77
|
+
# Bypasses display(); invalidate the render-diff baseline so the next
|
|
78
|
+
# display() repaints row 4 (4.0.0 convention).
|
|
79
|
+
from belote.ui.render import invalidate_diff
|
|
80
|
+
invalidate_diff()
|
|
77
81
|
|
|
78
82
|
# 4.8.0 / B4: tick-up / tick-down animation between two trust values.
|
|
79
83
|
# ──────────────────────────────────────────────────────────────────
|
|
@@ -10,8 +10,13 @@ class Config:
|
|
|
10
10
|
# Game rules
|
|
11
11
|
TARGET_SCORE: int = 1000
|
|
12
12
|
LAST_TRICK_BONUS: int = 10
|
|
13
|
+
# Official French Belote: holding the King AND Queen of trump (announced
|
|
14
|
+
# "Belote" on the first played, "Rebelote" on the second) is worth 20
|
|
15
|
+
# points TOTAL — not 20 each. BELOTE_POINTS is the provisional credit once
|
|
16
|
+
# the first card is announced; REBELOTE_POINTS is the final value once the
|
|
17
|
+
# pair is complete. Both are 20 so the completed pair scores the official 20.
|
|
13
18
|
BELOTE_POINTS: int = 20
|
|
14
|
-
REBELOTE_POINTS: int =
|
|
19
|
+
REBELOTE_POINTS: int = 20
|
|
15
20
|
CAPOT_BASE: int = 252
|
|
16
21
|
# Capot bases for special contracts. Standard FFB practice scales the
|
|
17
22
|
# Capot reward to the contract's total card points: Capot pays the deck
|
|
@@ -606,35 +606,41 @@ def _calculate_legal_cards_impl(
|
|
|
606
606
|
res_cards = hand
|
|
607
607
|
return tuple(_CARD_TO_ID[c] for c in res_cards)
|
|
608
608
|
|
|
609
|
+
# A card is trump if it's the trump suit, or — under La Déluge
|
|
610
|
+
# (seven_eight_trump) — any 7 or 8 regardless of suit. Following a trump
|
|
611
|
+
# lead means following with ANY trump, not just lead-suit cards: an off-suit
|
|
612
|
+
# 7/8 is a trump too. Using `c.suit == lead_suit` here would drop those and
|
|
613
|
+
# wrongly report the player as void (empty legal set → crash).
|
|
614
|
+
def is_trump_card(c: Card) -> bool:
|
|
615
|
+
return c.suit == trump or (
|
|
616
|
+
seven_eight_trump and c.rank in (Rank.SEVEN, Rank.EIGHT)
|
|
617
|
+
)
|
|
618
|
+
|
|
609
619
|
is_trump_lead = (lead_suit == trump) or (
|
|
610
620
|
seven_eight_trump and lead_card.rank in (Rank.SEVEN, Rank.EIGHT)
|
|
611
621
|
)
|
|
612
622
|
|
|
613
623
|
if is_trump_lead:
|
|
614
|
-
# Trump led: must follow if possible, must overtrump if possible
|
|
615
|
-
if
|
|
624
|
+
# Trump led: must follow with a trump if possible, must overtrump if possible
|
|
625
|
+
my_trumps = [c for c in hand if is_trump_card(c)]
|
|
626
|
+
if my_trumps:
|
|
616
627
|
highest_in_trick = max(
|
|
617
628
|
(trick_rank(tc.card, trump, seven_eight_trump) for tc in played_cards), default=-1
|
|
618
629
|
)
|
|
619
630
|
must_overtrump = any(
|
|
620
|
-
trick_rank(c, trump, seven_eight_trump) > highest_in_trick for c in
|
|
631
|
+
trick_rank(c, trump, seven_eight_trump) > highest_in_trick for c in my_trumps
|
|
621
632
|
)
|
|
622
633
|
if must_overtrump:
|
|
623
634
|
res_cards = tuple(
|
|
624
635
|
c
|
|
625
|
-
for c in
|
|
636
|
+
for c in my_trumps
|
|
626
637
|
if trick_rank(c, trump, seven_eight_trump) > highest_in_trick
|
|
627
638
|
)
|
|
628
639
|
else:
|
|
629
|
-
res_cards = tuple(
|
|
640
|
+
res_cards = tuple(my_trumps)
|
|
630
641
|
else:
|
|
631
|
-
#
|
|
632
|
-
res_cards =
|
|
633
|
-
c
|
|
634
|
-
for c in hand
|
|
635
|
-
if c.suit != trump
|
|
636
|
-
and not (seven_eight_trump and c.rank in (Rank.SEVEN, Rank.EIGHT))
|
|
637
|
-
)
|
|
642
|
+
# No trumps at all - can play anything (every card is non-trump)
|
|
643
|
+
res_cards = hand
|
|
638
644
|
else:
|
|
639
645
|
# Non-trump led
|
|
640
646
|
if my_suit_cards:
|
|
@@ -647,11 +653,7 @@ def _calculate_legal_cards_impl(
|
|
|
647
653
|
res_cards = hand
|
|
648
654
|
else:
|
|
649
655
|
# Must trump if possible
|
|
650
|
-
my_trumps = [
|
|
651
|
-
c
|
|
652
|
-
for c in hand
|
|
653
|
-
if c.suit == trump or (seven_eight_trump and c.rank in (Rank.SEVEN, Rank.EIGHT))
|
|
654
|
-
]
|
|
656
|
+
my_trumps = [c for c in hand if is_trump_card(c)]
|
|
655
657
|
if my_trumps:
|
|
656
658
|
# Must trump; also must overtrump if possible
|
|
657
659
|
highest_trump_in_trick = max(
|