belote-cli 3.8.2__tar.gz → 3.9.0__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.8.2 → belote_cli-3.9.0}/CHANGELOG.md +34 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/DEVELOPMENT.md +11 -4
- {belote_cli-3.8.2 → belote_cli-3.9.0}/PKG-INFO +5 -5
- {belote_cli-3.8.2 → belote_cli-3.9.0}/README.md +4 -4
- {belote_cli-3.8.2 → belote_cli-3.9.0}/pyproject.toml +1 -1
- {belote_cli-3.8.2 → belote_cli-3.9.0}/scripts/benchmark.py +87 -19
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/__init__.py +1 -1
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/ansi.py +30 -2
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/ui/announce.py +2 -2
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/scoring.py +11 -6
- belote_cli-3.9.0/tests/test_benchmark_smoke.py +29 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/test_input_eof.py +14 -0
- belote_cli-3.9.0/tests/test_no_color.py +62 -0
- belote_cli-3.9.0/uv.lock +526 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/.claude/settings.local.json +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/.gitignore +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/.python-version +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/LICENSE +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/__init__.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/a11y.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/achievements.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/ai.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/__init__.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/core/__init__.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/core/economy.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/core/run_state.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/core/scoring.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/engine/__init__.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/engine/event_bus.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/engine/modifier_patch.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/engine/round_driver.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/ghost_run.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/__init__.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/base.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/jokers/__init__.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/jokers/annonces.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/jokers/coinche.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/jokers/contract.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/jokers/corrupted.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/jokers/economy.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/planets.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/registry.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/tarots.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/items/vouchers.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/main.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/partner/__init__.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/partner/partner_state.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/partner/personality.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/partner/trust.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/progression/__init__.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/progression/save.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/progression/unlocks.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/run/__init__.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/run/ante.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/run/ante_themes.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/run/boss.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/run/decks.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/run/shop.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/run_summary.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/ui/__init__.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/ui/collection.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/ui/consumables.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/ui/history.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/ui/hud.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/ui/menu.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/ui/rules.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/ui/shop.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/belatro/ui/trust_bar.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/config.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/context.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/deck.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/game.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/gameflow.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/input.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/main.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/replay.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/rules.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/stats.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/themes.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/ui/__init__.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/ui/announce.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/ui/fit_guard.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/ui/layout.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/ui/menu.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/ui/prompts.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/src/belote/ui/render.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/__init__.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/__init__.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_belatro.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_boss_modifiers_integration.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_collection_logic.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_consumables_ui.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_contract_unlocks.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_dead_flag_fixes.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_deck_variants.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_event_bus.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_ghost_run.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_history_overlay.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_hud_synergy.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_partner_jokers.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_partner_trust.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_phase0_coverage.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_phase1_plumbing.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_phase2_content.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_phase3_meta.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_progression.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_round_driver.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_run_summary.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_shop_empty_pools.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/belatro/test_voucher_idempotency.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/test_a11y.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/test_achievements.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/test_ai.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/test_ansi_helpers.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/test_belote.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/test_bidding_all_pass.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/test_declaration_tiebreak.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/test_extended.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/test_game_logic.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/test_gameflow.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/test_layout.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/test_new_coverage.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/test_official_rules.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/test_properties.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/test_replay.py +0 -0
- {belote_cli-3.8.2 → belote_cli-3.9.0}/tests/test_undo.py +0 -0
|
@@ -5,6 +5,40 @@ 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.9.0] - 2026-05-14
|
|
9
|
+
|
|
10
|
+
Comprehensive bug-hunt, logic, and performance audit pass. Three parallel Explore agents covered the classic engine (`game.py` / `scoring.py` / `ai.py` / `gameflow.py` / `deck.py`), the BelAtro engine (`round_driver.py` / `modifier_patch.py` / `event_bus.py` / `belatro/core/scoring.py` / `boss.py` / `run_state.py` / `shop.py` / `belatro/main.py`), and the items catalogue + UI hot paths (`registry.py` + every joker / planet / tarot / voucher + `render.py` / `hud.py` / shop UI). Every load-bearing claim was spot-checked against the current code. **One real bug** (LOW), **one feature gap** filled, and a defensive cosmetic cleanup. Performance verdict: no measured hotspot — prior 3.6 / 3.7.1 / 3.8.0 audits already addressed the obvious paths. All 21 boss modifiers, 36 jokers, 8 planets, 12 tarots, 12 vouchers verified wired end-to-end. **+6 regression tests** (655 → 661). Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-wise-puzzle.md`.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`src/belote/belatro/ui/announce.py:98` (LOW) — `BelAtroAnnounce.yes_no()` no longer hangs on `Key.EOF`.** Pre-3.9.0 the prompt loop exited on `Key.ENTER`, `Key.ESC`, `Key.QUIT`, or character `y`/`n`/`o`, but had no `Key.EOF` return path. Two call sites in `belatro/main.py` (the post-Ante-8 endless prompt at line 137 and the player-side surcoinche prompt at line 267) would hang the process on Ctrl-D / piped-empty stdin / closed terminal. Established inconsistency: sibling `banner()` (line 75) and `score_popup()` (line 126) in the same file already handled EOF. Regression test in `tests/test_input_eof.py::test_announce_yes_no_returns_false_on_eof`.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`src/belote/ansi.py` — `NO_COLOR` env-var support** per the [no-color.org](https://no-color.org/) spec. When `NO_COLOR` is set to any non-empty value, `fg()` / `bg()` return the empty string. SGR formatting (`BOLD`, `DIM`, `REVERSE`, `UNDERLINE`, `STRIKETHROUGH`, `RESET`) and cursor/clear sequences are unchanged — they aren't color. Read once at import (mirrors `a11y._ENABLED`); `_refresh_no_color_from_env()` exported for tests. 4 new tests in `tests/test_no_color.py`.
|
|
19
|
+
- **`scripts/benchmark.py::benchmark_belatro_round` (new)** — end-to-end `drive_round` rounds/sec probe under a deterministic seed (mean + p95 + rounds/sec). Regression sentinel for round-driver throughput. New `--smoke` flag runs every benchmark at iterations=2 for a fast CI-friendly check; pinned by `tests/test_benchmark_smoke.py`.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **`src/belote/scoring.py::resolve_declarations` (cosmetic)** — the 4-seat announce-order tuple (clockwise from taker) is now built once in the enclosing function and shared by `_resolve_tie_carre` and `_resolve_tie_seq` via closure. Pre-3.9.0 each helper rebuilt the same tuple inline. Behavior-preserving; the regression guard is the existing `tests/test_declaration_tiebreak.py` suite.
|
|
24
|
+
- **`scripts/benchmark.py` cleanup** — auto-fixed 17 long-standing ruff issues (W293/W291/I001/F401) in the file as part of the enhancement-pass scope.
|
|
25
|
+
|
|
26
|
+
### Audit verdict — verified clean, no fix needed
|
|
27
|
+
|
|
28
|
+
- **Classic engine** (game.py / scoring.py / ai.py / gameflow.py / deck.py): all 19 game mechanics wired end-to-end (bidding pass/normal/TA/SA/coinche/surcoinche, trump-play follow/trump/overtrump/partner-master exception, declarations + tie-break, belote/rebelote, contract-aware Capot 220/348/252, Dix-de-der, litige, taker-failed redistribution). AI memoization (`processed_tricks_count` / `last_voids_key` / `last_partner_hand_key`) resets correctly on both new-round and undo paths.
|
|
29
|
+
- **BelAtro engine**: all 21 boss modifiers patch → state → read end-to-end; no `boss.id == "…"` string branching anywhere; event bus correctly round-scoped; `ScoreAccumulator.update_state` already coalesces into one `replace()` per event; voucher idempotency guard live; endless mode (×2.2) scaling correct.
|
|
30
|
+
- **Items + UI**: all 36 jokers' `on_event` signatures match bus dispatch; team-not-seat convention applied correctly (jokers checking "did our team win" use `team_of(event.winner) == 0`, partner jokers deliberately key on `Seat.NORTH`). Theme cache invalidates on theme change; `_card_face` and `visible_len` both `lru_cache(4096)` with proper invalidation; HUD opt-in rebuild via `force_hud=False` since 3.8.0. Shop layout: card frame 16 cells, gap 2→1→0 degradation works.
|
|
31
|
+
|
|
32
|
+
### Performance verdict
|
|
33
|
+
|
|
34
|
+
No measured hotspot. `dataclasses.replace()` on the frozen `GameState` runs ~256 calls/round at sub-µs each — well under the 1 ms/round budget. Joker triggers linear-scan over ≤ 5 jokers (max slots). The "enhancement plan" here is the regression sentinel (`benchmark_belatro_round`), not a speculative rewrite.
|
|
35
|
+
|
|
36
|
+
### Internal
|
|
37
|
+
|
|
38
|
+
- **Tests**: 655 → 661 (+6). yes_no EOF ×1, NO_COLOR ×4, benchmark smoke ×1.
|
|
39
|
+
- **Strict gates**: pytest 661/661 green, mypy `--strict` 0 errors (77 files), ruff 0 violations.
|
|
40
|
+
- **Version markers bumped**: `pyproject.toml`, `src/belote/__init__.py`.
|
|
41
|
+
|
|
8
42
|
## [3.8.2] - 2026-05-14
|
|
9
43
|
|
|
10
44
|
Final logic audit and performance hardening. This release addresses the remaining edge cases identified during the deep-dive audit, focusing on BelAtro joker persistence, declaration scoring correctness, and test suite optimization. All 655 tests passing.
|
|
@@ -84,14 +84,17 @@ 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 (661 tests expected)
|
|
88
88
|
PYTHONPATH=src pytest
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
Current baseline (3.
|
|
92
|
-
|
|
91
|
+
Current baseline (3.9.0):
|
|
92
|
+
|
|
93
|
+
- **661 tests** passing (3.8.2 had 655; +6 in 3.9.0: yes_no EOF, NO_COLOR ×4, benchmark smoke).
|
|
94
|
+
- 3.9.0 ships a clean three-agent audit pass (classic engine / BelAtro engine / items+UI). Confirmed bugs: one (LOW) — `BelAtroAnnounce.yes_no()` hung on `Key.EOF`, fixed in `src/belote/belatro/ui/announce.py:98`. New feature: `NO_COLOR` env-var support in `src/belote/ansi.py`. Cosmetic: deduped seat-order computation in `scoring.py` tie-break helpers. Tooling: `scripts/benchmark.py` gains an end-to-end `drive_round` rounds/sec probe and a `--smoke` flag pinned by `tests/test_benchmark_smoke.py`. Performance verdict: no measured hotspot — prior L1/L2/L3/D-pass audits already addressed the obvious paths. All 21 boss modifiers, 36 jokers, 8 planets, 12 tarots, 12 vouchers wired end-to-end. Plan file at `/home/mrrobot/.claude/plans/bug-hunt-code-performance-wise-puzzle.md`.
|
|
95
|
+
|
|
96
|
+
Past baselines:
|
|
93
97
|
|
|
94
|
-
- **655 tests** passing.
|
|
95
98
|
- 3.8.2 completes the five-agent audit pass. Final hardening includes Tout Atout streak persistence in BelAtro, Quinte trigger refinement, belote-pair timing fixes for jokers, and declaration scoring correctness for carrés and long sequences.
|
|
96
99
|
- Performance: test suite speed increased by mocking `interruptible_sleep`.
|
|
97
100
|
- Regression coverage maintained at 100% for game-logic modules.
|
|
@@ -162,6 +165,10 @@ once at startup; toggling mid-run has no effect.
|
|
|
162
165
|
`~/.local/share/belote/ghosts/<label>-<seed>.json`. The file is written
|
|
163
166
|
once when the run ends. Useful for sharing or replaying interesting
|
|
164
167
|
runs. Backed by `src/belote/belatro/ghost_run.py`.
|
|
168
|
+
- `NO_COLOR=<any-non-empty>` — suppress truecolor SGR escapes from
|
|
169
|
+
`fg()` / `bg()` per the [no-color.org](https://no-color.org/) spec.
|
|
170
|
+
Bold/dim/underline/reverse/strikethrough and cursor sequences remain
|
|
171
|
+
(they aren't color). Added in 3.9.0. Backed by `src/belote/ansi.py`.
|
|
165
172
|
|
|
166
173
|
## Releasing a New Version
|
|
167
174
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: belote-cli
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.9.0
|
|
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
|
|
@@ -215,7 +215,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
|
|
|
215
215
|
- **Alternate Screen Buffer:** Both classic Belote and BelAtro run in a dedicated terminal buffer for a clean, non-overlapping interface — your shell scrollback stays untouched after you quit.
|
|
216
216
|
- **Declarations:** Automatic detection and announcement of sequences (Tierce, Quarte, etc.) and Carrés after the first trick.
|
|
217
217
|
- **Live HUD:** Real-time round scoring displays points won during the current round, with a smooth "rolling" numerical animation for total scores.
|
|
218
|
-
- **High Fidelity:** Implementation of French Belote rules according to the [official rules of the Fédération Française de Belote](https://www.ffbelote.org/regles-officielle-belote/), including a two-round bidding system, "Dix de Der",
|
|
218
|
+
- **High Fidelity:** Implementation of French Belote rules according to the [official rules of the Fédération Française de Belote](https://www.ffbelote.org/regles-officielle-belote/), including a two-round bidding system, "Dix de Der", contract-aware **Capot** (252 normal / 220 Sans Atout / 348 Tout Atout), and "Litige" (tie-break). All six contracts are bidable in round 2: the four card suits, **Tout Atout** (every suit acts as trump within its own led-suit group; press `a`), and **Sans Atout** (no trump, lead-suit highest wins; press `s`).
|
|
219
219
|
- **Rules & History Viewer:** A scrollable, bilingual (English/French) in-game reference for the game's heritage and mechanics.
|
|
220
220
|
|
|
221
221
|
## AI
|
|
@@ -254,7 +254,7 @@ belote/
|
|
|
254
254
|
│ ├── input.py # Platform-dispatched key reader and interruptible sleep
|
|
255
255
|
│ ├── stats.py # Global and session statistics tracking
|
|
256
256
|
│ └── rules.py # Game rules content
|
|
257
|
-
├── tests/ # Comprehensive test suite (
|
|
257
|
+
├── tests/ # Comprehensive test suite (661 tests)
|
|
258
258
|
├── scripts/ # Performance benchmarks
|
|
259
259
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
260
260
|
├── LICENSE # MIT License
|
|
@@ -270,14 +270,14 @@ belote/
|
|
|
270
270
|
PYTHONPATH=src pytest
|
|
271
271
|
```
|
|
272
272
|
|
|
273
|
-
Currently **
|
|
273
|
+
Currently **661 tests** passing with 100% coverage on game-logic modules (3.9.0).
|
|
274
274
|
|
|
275
275
|
## Technical Integrity
|
|
276
276
|
|
|
277
277
|
The codebase is strictly validated with the following tools:
|
|
278
278
|
- **mypy**: 0 errors (strict type safety)
|
|
279
279
|
- **ruff**: 0 violations (linting & formatting)
|
|
280
|
-
- **pytest**:
|
|
280
|
+
- **pytest**: 661/661 passed
|
|
281
281
|
- **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
|
|
282
282
|
- **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
|
|
283
283
|
|
|
@@ -172,7 +172,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
|
|
|
172
172
|
- **Alternate Screen Buffer:** Both classic Belote and BelAtro run in a dedicated terminal buffer for a clean, non-overlapping interface — your shell scrollback stays untouched after you quit.
|
|
173
173
|
- **Declarations:** Automatic detection and announcement of sequences (Tierce, Quarte, etc.) and Carrés after the first trick.
|
|
174
174
|
- **Live HUD:** Real-time round scoring displays points won during the current round, with a smooth "rolling" numerical animation for total scores.
|
|
175
|
-
- **High Fidelity:** Implementation of French Belote rules according to the [official rules of the Fédération Française de Belote](https://www.ffbelote.org/regles-officielle-belote/), including a two-round bidding system, "Dix de Der",
|
|
175
|
+
- **High Fidelity:** Implementation of French Belote rules according to the [official rules of the Fédération Française de Belote](https://www.ffbelote.org/regles-officielle-belote/), including a two-round bidding system, "Dix de Der", contract-aware **Capot** (252 normal / 220 Sans Atout / 348 Tout Atout), and "Litige" (tie-break). All six contracts are bidable in round 2: the four card suits, **Tout Atout** (every suit acts as trump within its own led-suit group; press `a`), and **Sans Atout** (no trump, lead-suit highest wins; press `s`).
|
|
176
176
|
- **Rules & History Viewer:** A scrollable, bilingual (English/French) in-game reference for the game's heritage and mechanics.
|
|
177
177
|
|
|
178
178
|
## AI
|
|
@@ -211,7 +211,7 @@ belote/
|
|
|
211
211
|
│ ├── input.py # Platform-dispatched key reader and interruptible sleep
|
|
212
212
|
│ ├── stats.py # Global and session statistics tracking
|
|
213
213
|
│ └── rules.py # Game rules content
|
|
214
|
-
├── tests/ # Comprehensive test suite (
|
|
214
|
+
├── tests/ # Comprehensive test suite (661 tests)
|
|
215
215
|
├── scripts/ # Performance benchmarks
|
|
216
216
|
├── pyproject.toml # Build system and dev dependencies (ruff/mypy)
|
|
217
217
|
├── LICENSE # MIT License
|
|
@@ -227,14 +227,14 @@ belote/
|
|
|
227
227
|
PYTHONPATH=src pytest
|
|
228
228
|
```
|
|
229
229
|
|
|
230
|
-
Currently **
|
|
230
|
+
Currently **661 tests** passing with 100% coverage on game-logic modules (3.9.0).
|
|
231
231
|
|
|
232
232
|
## Technical Integrity
|
|
233
233
|
|
|
234
234
|
The codebase is strictly validated with the following tools:
|
|
235
235
|
- **mypy**: 0 errors (strict type safety)
|
|
236
236
|
- **ruff**: 0 violations (linting & formatting)
|
|
237
|
-
- **pytest**:
|
|
237
|
+
- **pytest**: 661/661 passed
|
|
238
238
|
- **Functional Architecture**: Purely immutable state transitions using `dataclasses.replace`
|
|
239
239
|
- **Performance**: High-efficiency rendering and sub-millisecond AI decision times (see `scripts/benchmark.py`)
|
|
240
240
|
|
|
@@ -68,17 +68,16 @@ def benchmark_ai(difficulty: Difficulty, iterations: int = 50) -> float:
|
|
|
68
68
|
|
|
69
69
|
def benchmark_belatro_bus(num_jokers: int = 5, iterations: int = 1000) -> float:
|
|
70
70
|
from belote.belatro.core.scoring import ScoreAccumulator
|
|
71
|
-
from belote.belatro.engine.event_bus import
|
|
71
|
+
from belote.belatro.engine.event_bus import TrickWonEvent
|
|
72
72
|
from belote.belatro.items.jokers.contract import LeDiplomate
|
|
73
73
|
from belote.deck import Card, Rank
|
|
74
|
-
from belote.game import GameState
|
|
75
74
|
|
|
76
75
|
print(f"Benchmarking BelAtro State Update ({num_jokers} Jokers) over {iterations} iterations...")
|
|
77
76
|
|
|
78
77
|
jokers = [LeDiplomate() for _ in range(num_jokers)]
|
|
79
78
|
acc = ScoreAccumulator()
|
|
80
79
|
acc.attach_jokers(jokers)
|
|
81
|
-
|
|
80
|
+
|
|
82
81
|
state = new_game()
|
|
83
82
|
event = TrickWonEvent(
|
|
84
83
|
winner=Seat.SOUTH,
|
|
@@ -102,28 +101,28 @@ def benchmark_belatro_bus(num_jokers: int = 5, iterations: int = 1000) -> float:
|
|
|
102
101
|
|
|
103
102
|
|
|
104
103
|
def benchmark_scoring(iterations: int = 1000) -> float:
|
|
105
|
-
from belote.scoring import score_round
|
|
106
104
|
from belote.deck import Card, Rank
|
|
107
|
-
from belote.game import
|
|
108
|
-
|
|
105
|
+
from belote.game import Phase, TrickCard, replace
|
|
106
|
+
from belote.scoring import score_round
|
|
107
|
+
|
|
109
108
|
print(f"Benchmarking score_round() over {iterations} iterations...")
|
|
110
|
-
|
|
109
|
+
|
|
111
110
|
state = new_game()
|
|
112
111
|
state = replace(
|
|
113
|
-
state,
|
|
114
|
-
phase=Phase.SCORING,
|
|
115
|
-
trump=Suit.SPADES,
|
|
112
|
+
state,
|
|
113
|
+
phase=Phase.SCORING,
|
|
114
|
+
trump=Suit.SPADES,
|
|
116
115
|
taker=Seat.SOUTH,
|
|
117
116
|
completed_tricks=tuple([(TrickCard(Seat.SOUTH, Card(Suit.SPADES, Rank.ACE)),) * 4] * 8),
|
|
118
117
|
last_trick_winner=Seat.SOUTH
|
|
119
118
|
)
|
|
120
|
-
|
|
119
|
+
|
|
121
120
|
times = []
|
|
122
121
|
for _ in range(iterations):
|
|
123
122
|
start = time.perf_counter()
|
|
124
123
|
_ = score_round(state)
|
|
125
124
|
times.append(time.perf_counter() - start)
|
|
126
|
-
|
|
125
|
+
|
|
127
126
|
avg = statistics.mean(times) * 1000
|
|
128
127
|
print(f" Scoring Time: {avg:.3f}ms")
|
|
129
128
|
return avg
|
|
@@ -132,23 +131,23 @@ def benchmark_scoring(iterations: int = 1000) -> float:
|
|
|
132
131
|
def benchmark_deal(iterations: int = 1000) -> float:
|
|
133
132
|
from belote.game import start_round
|
|
134
133
|
print(f"Benchmarking start_round() (deal) over {iterations} iterations...")
|
|
135
|
-
|
|
134
|
+
|
|
136
135
|
state = new_game()
|
|
137
136
|
rng = random.Random(42)
|
|
138
|
-
|
|
137
|
+
|
|
139
138
|
times = []
|
|
140
139
|
for _ in range(iterations):
|
|
141
140
|
start = time.perf_counter()
|
|
142
141
|
_ = start_round(state, rng)
|
|
143
142
|
times.append(time.perf_counter() - start)
|
|
144
|
-
|
|
143
|
+
|
|
145
144
|
avg = statistics.mean(times) * 1000
|
|
146
145
|
print(f" Deal Time: {avg:.3f}ms")
|
|
147
146
|
return avg
|
|
148
147
|
|
|
149
148
|
|
|
150
149
|
def benchmark_legal_cards(iterations: int = 1000) -> float:
|
|
151
|
-
from belote.game import
|
|
150
|
+
from belote.game import clear_legal_cards_cache, legal_cards, replace
|
|
152
151
|
print(f"Benchmarking legal_cards() (cache cleared per call) over {iterations} iterations...")
|
|
153
152
|
|
|
154
153
|
state = new_game()
|
|
@@ -173,7 +172,7 @@ def benchmark_legal_cards_cached(iterations: int = 1000) -> float:
|
|
|
173
172
|
`benchmark_legal_cards` above invalidates every iteration and so reflects
|
|
174
173
|
worst-case-only time.
|
|
175
174
|
"""
|
|
176
|
-
from belote.game import
|
|
175
|
+
from belote.game import clear_legal_cards_cache, legal_cards, replace
|
|
177
176
|
print(f"Benchmarking legal_cards() (warm cache) over {iterations} iterations...")
|
|
178
177
|
|
|
179
178
|
state = new_game()
|
|
@@ -198,8 +197,8 @@ def benchmark_trick_scoring(iterations: int = 1000) -> float:
|
|
|
198
197
|
(HUD running total) and again from `scoring.py::_calculate_base_points`
|
|
199
198
|
(final round score). One of the hottest functions in a played round.
|
|
200
199
|
"""
|
|
201
|
-
from belote.game import TrickCard, replace
|
|
202
200
|
from belote.deck import Card, Rank
|
|
201
|
+
from belote.game import TrickCard, replace
|
|
203
202
|
from belote.scoring import trick_card_points
|
|
204
203
|
|
|
205
204
|
print(f"Benchmarking trick_card_points() over {iterations} iterations...")
|
|
@@ -249,6 +248,55 @@ def benchmark_ai_legality_filter(iterations: int = 500) -> float:
|
|
|
249
248
|
return avg
|
|
250
249
|
|
|
251
250
|
|
|
251
|
+
def benchmark_belatro_round(rounds: int = 30, seed: int = 42) -> float:
|
|
252
|
+
"""End-to-end BelAtro round throughput under a deterministic seed.
|
|
253
|
+
|
|
254
|
+
Drives a full round (bid → 8 tricks → score) headlessly via the same
|
|
255
|
+
round_driver path the game uses, with AI on every seat. The seed is
|
|
256
|
+
threaded into drive_round so the per-round work is reproducible — a
|
|
257
|
+
regression sentinel, not a wall-clock target.
|
|
258
|
+
"""
|
|
259
|
+
from belote.belatro.core.scoring import ScoreAccumulator
|
|
260
|
+
from belote.belatro.engine.event_bus import EventBus
|
|
261
|
+
from belote.belatro.engine.round_driver import RoundUICallbacks, drive_round
|
|
262
|
+
from belote.belatro.partner.partner_state import PartnerState
|
|
263
|
+
from belote.deck import Card
|
|
264
|
+
from belote.game import GameState, legal_cards
|
|
265
|
+
|
|
266
|
+
class _HeadlessUI(RoundUICallbacks):
|
|
267
|
+
def prompt_bid(self, state: GameState) -> Suit | None:
|
|
268
|
+
return None # pass; AI seats may take
|
|
269
|
+
|
|
270
|
+
def prompt_card(self, state: GameState) -> tuple[Card, GameState]:
|
|
271
|
+
return legal_cards(state, Seat.SOUTH)[0], state
|
|
272
|
+
|
|
273
|
+
def on_card_played(self, state: GameState, seat: Seat, card: Card) -> None:
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
def on_trick_end(self, state: GameState, winner: Seat, points: int) -> None:
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
def on_round_end(self, breakdown: object) -> None:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
print(f"Benchmarking drive_round() E2E over {rounds} rounds (seed={seed})...")
|
|
283
|
+
|
|
284
|
+
times = []
|
|
285
|
+
for i in range(rounds):
|
|
286
|
+
bus = EventBus()
|
|
287
|
+
partner = PartnerState()
|
|
288
|
+
acc = ScoreAccumulator()
|
|
289
|
+
start = time.perf_counter()
|
|
290
|
+
drive_round(bus=bus, partner=partner, ui_callbacks=_HeadlessUI(), acc=acc, seed=seed + i)
|
|
291
|
+
times.append(time.perf_counter() - start)
|
|
292
|
+
|
|
293
|
+
mean = statistics.mean(times) * 1000
|
|
294
|
+
p95 = sorted(times)[int(len(times) * 0.95) - 1] * 1000 if len(times) > 1 else mean
|
|
295
|
+
rps = 1.0 / statistics.mean(times) if times else 0.0
|
|
296
|
+
print(f" E2E Round Time: mean {mean:.2f}ms, p95 {p95:.2f}ms ({rps:.1f} rounds/sec)")
|
|
297
|
+
return mean
|
|
298
|
+
|
|
299
|
+
|
|
252
300
|
def run_benchmarks() -> None:
|
|
253
301
|
print("=== Belote-CLI Performance Benchmark ===")
|
|
254
302
|
benchmark_render()
|
|
@@ -265,9 +313,29 @@ def run_benchmarks() -> None:
|
|
|
265
313
|
benchmark_legal_cards_cached()
|
|
266
314
|
benchmark_trick_scoring()
|
|
267
315
|
benchmark_ai_legality_filter()
|
|
316
|
+
print()
|
|
317
|
+
benchmark_belatro_round()
|
|
268
318
|
print("========================================")
|
|
269
319
|
|
|
270
320
|
|
|
321
|
+
def run_smoke() -> None:
|
|
322
|
+
"""Tiny smoke pass: every benchmark runs once at minimum iteration count.
|
|
323
|
+
Used by the test suite to keep the script from rotting.
|
|
324
|
+
"""
|
|
325
|
+
benchmark_render(iterations=2)
|
|
326
|
+
benchmark_ai(Difficulty.EASY, iterations=2)
|
|
327
|
+
benchmark_belatro_bus(iterations=2)
|
|
328
|
+
benchmark_scoring(iterations=2)
|
|
329
|
+
benchmark_deal(iterations=2)
|
|
330
|
+
benchmark_legal_cards(iterations=2)
|
|
331
|
+
benchmark_legal_cards_cached(iterations=2)
|
|
332
|
+
benchmark_trick_scoring(iterations=2)
|
|
333
|
+
benchmark_ai_legality_filter(iterations=2)
|
|
334
|
+
benchmark_belatro_round(rounds=2)
|
|
335
|
+
|
|
271
336
|
|
|
272
337
|
if __name__ == "__main__":
|
|
273
|
-
|
|
338
|
+
if "--smoke" in sys.argv:
|
|
339
|
+
run_smoke()
|
|
340
|
+
else:
|
|
341
|
+
run_benchmarks()
|
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import re
|
|
4
5
|
from functools import lru_cache
|
|
5
6
|
|
|
6
7
|
from .themes import Theme, theme_manager
|
|
7
8
|
|
|
9
|
+
# Respect https://no-color.org/. Read once at import; tests use
|
|
10
|
+
# _refresh_no_color_from_env() after monkeypatch.setenv. Only color escapes
|
|
11
|
+
# are suppressed; SGR formatting (BOLD/DIM/etc.) and cursor sequences remain,
|
|
12
|
+
# per the spec.
|
|
13
|
+
_NO_COLOR: bool = bool(os.environ.get("NO_COLOR", ""))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _refresh_no_color_from_env() -> None:
|
|
17
|
+
global _NO_COLOR
|
|
18
|
+
_NO_COLOR = bool(os.environ.get("NO_COLOR", ""))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def no_color_active() -> bool:
|
|
22
|
+
return _NO_COLOR
|
|
23
|
+
|
|
8
24
|
# ── Theme cache ────────────────────────────────────────────────────────────
|
|
9
25
|
# Each color flavor (felt_bg, red_fg, etc.) is hit dozens of times per render.
|
|
10
26
|
# Pre-3.0.0 each call walked into theme_manager.get_current() (a dict lookup);
|
|
@@ -58,15 +74,27 @@ def ansi_ljust(s: str, width: int) -> str:
|
|
|
58
74
|
|
|
59
75
|
|
|
60
76
|
@lru_cache(maxsize=512)
|
|
61
|
-
def
|
|
77
|
+
def _fg_seq(r: int, g: int, b: int) -> str:
|
|
62
78
|
return f"\x1b[38;2;{r};{g};{b}m"
|
|
63
79
|
|
|
64
80
|
|
|
65
81
|
@lru_cache(maxsize=512)
|
|
66
|
-
def
|
|
82
|
+
def _bg_seq(r: int, g: int, b: int) -> str:
|
|
67
83
|
return f"\x1b[48;2;{r};{g};{b}m"
|
|
68
84
|
|
|
69
85
|
|
|
86
|
+
def fg(r: int, g: int, b: int) -> str:
|
|
87
|
+
if _NO_COLOR:
|
|
88
|
+
return ""
|
|
89
|
+
return _fg_seq(r, g, b)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def bg(r: int, g: int, b: int) -> str:
|
|
93
|
+
if _NO_COLOR:
|
|
94
|
+
return ""
|
|
95
|
+
return _bg_seq(r, g, b)
|
|
96
|
+
|
|
97
|
+
|
|
70
98
|
BOLD = "\x1b[1m"
|
|
71
99
|
DIM = "\x1b[2m"
|
|
72
100
|
REVERSE = "\x1b[7m"
|
|
@@ -78,7 +78,7 @@ class BelAtroAnnounce:
|
|
|
78
78
|
|
|
79
79
|
@staticmethod
|
|
80
80
|
def yes_no(prompt: str, reader: KeyReader) -> bool:
|
|
81
|
-
"""Centered Y/N prompt. Returns True on Y/Enter, False on N/Esc/Q.
|
|
81
|
+
"""Centered Y/N prompt. Returns True on Y/Enter, False on N/Esc/Q/EOF.
|
|
82
82
|
|
|
83
83
|
Repaints in-place — no scroll on alt-screen-strict terminals. Used by
|
|
84
84
|
the post-Ante-8 endless-mode offer.
|
|
@@ -95,7 +95,7 @@ class BelAtroAnnounce:
|
|
|
95
95
|
event = reader.read()
|
|
96
96
|
if event.key in (Key.ENTER,):
|
|
97
97
|
return True
|
|
98
|
-
if event.key in (Key.ESC, Key.QUIT):
|
|
98
|
+
if event.key in (Key.ESC, Key.QUIT, Key.EOF):
|
|
99
99
|
return False
|
|
100
100
|
if event.key == Key.CHAR and event.char:
|
|
101
101
|
ch = event.char.lower()
|
|
@@ -288,15 +288,21 @@ def resolve_declarations(
|
|
|
288
288
|
ns_best_carre = _best_carre(ns_carres)
|
|
289
289
|
ew_best_carre = _best_carre(ew_carres)
|
|
290
290
|
|
|
291
|
+
# Announce-order walk: clockwise from taker. Used by both tie-break
|
|
292
|
+
# resolvers below — compute once.
|
|
293
|
+
seat_order: tuple[Seat, ...] = ()
|
|
294
|
+
if taker is not None:
|
|
295
|
+
s1 = taker.next_seat()
|
|
296
|
+
s2 = s1.next_seat()
|
|
297
|
+
s3 = s2.next_seat()
|
|
298
|
+
seat_order = (taker, s1, s2, s3)
|
|
299
|
+
|
|
291
300
|
def _resolve_tie_carre() -> int | None:
|
|
292
301
|
if taker is None:
|
|
293
302
|
return None # legacy cancel behaviour
|
|
294
|
-
# Walk seats in announce order starting at the taker; the first seat
|
|
295
|
-
# holding a matching-rank carré wins the tie.
|
|
296
303
|
assert ns_best_carre is not None and ew_best_carre is not None
|
|
297
304
|
tied_rank = ns_best_carre.rank
|
|
298
|
-
|
|
299
|
-
for s in order:
|
|
305
|
+
for s in seat_order:
|
|
300
306
|
for c, cs in zip(ns_carres, ns_carre_seats, strict=True):
|
|
301
307
|
if cs == s and c.rank == tied_rank:
|
|
302
308
|
return 0
|
|
@@ -310,8 +316,7 @@ def resolve_declarations(
|
|
|
310
316
|
return None
|
|
311
317
|
assert ns_best_seq is not None and ew_best_seq is not None
|
|
312
318
|
tied_strength = _sequence_strength(ns_best_seq)
|
|
313
|
-
|
|
314
|
-
for s in order:
|
|
319
|
+
for s in seat_order:
|
|
315
320
|
for seq, ss in zip(ns_seqs, ns_seq_seats, strict=True):
|
|
316
321
|
if ss == s and _sequence_strength(seq) == tied_strength:
|
|
317
322
|
return 0
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Keep scripts/benchmark.py from rotting.
|
|
2
|
+
|
|
3
|
+
Runs the smoke pass (iterations=2 each) and asserts the script exits cleanly.
|
|
4
|
+
Not a perf gate — just a "does it import + execute end-to-end" guard. Useful
|
|
5
|
+
because the script is referenced from the audit-plan as the canonical
|
|
6
|
+
regression sentinel for round-driver throughput.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_benchmark_smoke_runs() -> None:
|
|
17
|
+
script = Path(__file__).parent.parent / "scripts" / "benchmark.py"
|
|
18
|
+
result = subprocess.run(
|
|
19
|
+
[sys.executable, str(script), "--smoke"],
|
|
20
|
+
capture_output=True,
|
|
21
|
+
text=True,
|
|
22
|
+
timeout=120,
|
|
23
|
+
check=False,
|
|
24
|
+
)
|
|
25
|
+
assert result.returncode == 0, (
|
|
26
|
+
f"benchmark.py --smoke exited {result.returncode}\n"
|
|
27
|
+
f"stdout:\n{result.stdout}\n"
|
|
28
|
+
f"stderr:\n{result.stderr}"
|
|
29
|
+
)
|
|
@@ -99,3 +99,17 @@ def test_eof_in_consumables_overlay_returns_false() -> None:
|
|
|
99
99
|
overlay = ConsumablesOverlay(run, reader)
|
|
100
100
|
assert overlay.open() is False
|
|
101
101
|
assert reader.read.call_count == 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_announce_yes_no_returns_false_on_eof() -> None:
|
|
105
|
+
"""BelAtroAnnounce.yes_no must exit on EOF — pre-3.9.0 it spun forever
|
|
106
|
+
when stdin was closed during the post-Ante-8 or surcoinche prompt.
|
|
107
|
+
|
|
108
|
+
Sibling methods banner() and score_popup() in the same file already
|
|
109
|
+
handle EOF; this test pins the inconsistency closed."""
|
|
110
|
+
from belote.belatro.ui.announce import BelAtroAnnounce
|
|
111
|
+
|
|
112
|
+
reader = MagicMock()
|
|
113
|
+
reader.read.return_value = KeyEvent(Key.EOF)
|
|
114
|
+
assert BelAtroAnnounce.yes_no("Continue?", reader) is False
|
|
115
|
+
assert reader.read.call_count == 1
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""3.9.0: NO_COLOR env-var support (https://no-color.org/).
|
|
2
|
+
|
|
3
|
+
When `NO_COLOR` is set to any non-empty value, `fg()` and `bg()` return the
|
|
4
|
+
empty string. SGR formatting (BOLD/DIM/REVERSE/UNDERLINE/STRIKETHROUGH) and
|
|
5
|
+
cursor/clear sequences are not affected — only color, per the spec.
|
|
6
|
+
|
|
7
|
+
Mirror the `BELOTE_A11Y` test pattern: monkeypatch the env, call
|
|
8
|
+
`_refresh_no_color_from_env()` to re-read the cached flag, restore in teardown
|
|
9
|
+
via the fixture so module state doesn't leak.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from belote import ansi
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture(autouse=True)
|
|
22
|
+
def _restore_no_color() -> Iterator[None]:
|
|
23
|
+
"""Snapshot _NO_COLOR before each test, restore after — keeps module
|
|
24
|
+
state from leaking across tests in this file or to the broader suite."""
|
|
25
|
+
saved = ansi._NO_COLOR
|
|
26
|
+
yield
|
|
27
|
+
ansi._NO_COLOR = saved
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_fg_returns_empty_when_no_color_set(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
31
|
+
monkeypatch.setenv("NO_COLOR", "1")
|
|
32
|
+
ansi._refresh_no_color_from_env()
|
|
33
|
+
assert ansi.fg(255, 0, 0) == ""
|
|
34
|
+
assert ansi.bg(0, 255, 0) == ""
|
|
35
|
+
assert ansi.no_color_active() is True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_fg_emits_sgr_when_no_color_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
39
|
+
monkeypatch.delenv("NO_COLOR", raising=False)
|
|
40
|
+
ansi._refresh_no_color_from_env()
|
|
41
|
+
assert ansi.fg(255, 0, 0) == "\x1b[38;2;255;0;0m"
|
|
42
|
+
assert ansi.bg(0, 255, 0) == "\x1b[48;2;0;255;0m"
|
|
43
|
+
assert ansi.no_color_active() is False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_no_color_empty_string_means_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
47
|
+
"""Per the no-color.org spec: NO_COLOR="" is treated as unset."""
|
|
48
|
+
monkeypatch.setenv("NO_COLOR", "")
|
|
49
|
+
ansi._refresh_no_color_from_env()
|
|
50
|
+
assert ansi.no_color_active() is False
|
|
51
|
+
assert ansi.fg(10, 20, 30) == "\x1b[38;2;10;20;30m"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_sgr_constants_unaffected_by_no_color() -> None:
|
|
55
|
+
"""BOLD/DIM/REVERSE/UNDERLINE/STRIKETHROUGH are SGR formatting, not color —
|
|
56
|
+
must remain emittable under NO_COLOR per the spec."""
|
|
57
|
+
assert ansi.BOLD == "\x1b[1m"
|
|
58
|
+
assert ansi.DIM == "\x1b[2m"
|
|
59
|
+
assert ansi.REVERSE == "\x1b[7m"
|
|
60
|
+
assert ansi.UNDERLINE == "\x1b[4m"
|
|
61
|
+
assert ansi.STRIKETHROUGH == "\x1b[9m"
|
|
62
|
+
assert ansi.RESET == "\x1b[0m"
|