belote-cli 2.9.2__tar.gz → 2.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.
Files changed (106) hide show
  1. {belote_cli-2.9.2 → belote_cli-2.9.5}/CHANGELOG.md +37 -0
  2. {belote_cli-2.9.2 → belote_cli-2.9.5}/PKG-INFO +7 -7
  3. {belote_cli-2.9.2 → belote_cli-2.9.5}/README.md +6 -6
  4. {belote_cli-2.9.2 → belote_cli-2.9.5}/pyproject.toml +1 -1
  5. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/__init__.py +1 -1
  6. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/game.py +8 -0
  7. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/gameflow.py +10 -0
  8. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/input.py +12 -8
  9. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/main.py +2 -2
  10. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/scoring.py +56 -8
  11. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/ui/menu.py +5 -0
  12. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/ui/prompts.py +115 -31
  13. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/ui/render.py +200 -56
  14. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/test_extended.py +44 -0
  15. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/test_layout.py +2 -2
  16. {belote_cli-2.9.2 → belote_cli-2.9.5}/.claude/settings.local.json +0 -0
  17. {belote_cli-2.9.2 → belote_cli-2.9.5}/.gitignore +0 -0
  18. {belote_cli-2.9.2 → belote_cli-2.9.5}/.python-version +0 -0
  19. {belote_cli-2.9.2 → belote_cli-2.9.5}/DEVELOPMENT.md +0 -0
  20. {belote_cli-2.9.2 → belote_cli-2.9.5}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
  21. {belote_cli-2.9.2 → belote_cli-2.9.5}/LICENSE +0 -0
  22. {belote_cli-2.9.2 → belote_cli-2.9.5}/scripts/benchmark.py +0 -0
  23. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/__init__.py +0 -0
  24. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/ai.py +0 -0
  25. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/ansi.py +0 -0
  26. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/__init__.py +0 -0
  27. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/core/__init__.py +0 -0
  28. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/core/economy.py +0 -0
  29. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/core/run_state.py +0 -0
  30. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/core/scoring.py +0 -0
  31. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/engine/__init__.py +0 -0
  32. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/engine/event_bus.py +0 -0
  33. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/engine/modifier_patch.py +0 -0
  34. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/engine/round_driver.py +0 -0
  35. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/__init__.py +0 -0
  36. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/base.py +0 -0
  37. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/jokers/__init__.py +0 -0
  38. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/jokers/annonces.py +0 -0
  39. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/jokers/coinche.py +0 -0
  40. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/jokers/contract.py +0 -0
  41. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  42. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/jokers/economy.py +0 -0
  43. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  44. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
  45. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  46. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  47. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  48. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  49. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/planets.py +0 -0
  50. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/registry.py +0 -0
  51. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/tarots.py +0 -0
  52. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/items/vouchers.py +0 -0
  53. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/main.py +0 -0
  54. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/partner/__init__.py +0 -0
  55. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/partner/partner_state.py +0 -0
  56. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/partner/personality.py +0 -0
  57. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/partner/trust.py +0 -0
  58. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/progression/__init__.py +0 -0
  59. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/progression/save.py +0 -0
  60. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/progression/unlocks.py +0 -0
  61. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/run/__init__.py +0 -0
  62. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/run/ante.py +0 -0
  63. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/run/ante_themes.py +0 -0
  64. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/run/boss.py +0 -0
  65. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/run/decks.py +0 -0
  66. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/run/shop.py +0 -0
  67. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/ui/__init__.py +0 -0
  68. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/ui/announce.py +0 -0
  69. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/ui/collection.py +0 -0
  70. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/ui/hud.py +0 -0
  71. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/ui/menu.py +0 -0
  72. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/ui/rules.py +0 -0
  73. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/ui/shop.py +0 -0
  74. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/belatro/ui/trust_bar.py +0 -0
  75. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/config.py +0 -0
  76. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/context.py +0 -0
  77. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/deck.py +0 -0
  78. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/rules.py +0 -0
  79. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/stats.py +0 -0
  80. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/themes.py +0 -0
  81. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/ui/__init__.py +0 -0
  82. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/ui/announce.py +0 -0
  83. {belote_cli-2.9.2 → belote_cli-2.9.5}/src/belote/ui/layout.py +0 -0
  84. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/__init__.py +0 -0
  85. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/belatro/__init__.py +0 -0
  86. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/belatro/test_belatro.py +0 -0
  87. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/belatro/test_boss_modifiers_integration.py +0 -0
  88. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/belatro/test_collection_logic.py +0 -0
  89. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/belatro/test_contract_unlocks.py +0 -0
  90. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/belatro/test_dead_flag_fixes.py +0 -0
  91. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/belatro/test_deck_variants.py +0 -0
  92. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/belatro/test_partner_trust.py +0 -0
  93. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/belatro/test_phase0_coverage.py +0 -0
  94. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/belatro/test_phase1_plumbing.py +0 -0
  95. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/belatro/test_phase2_content.py +0 -0
  96. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/belatro/test_phase3_meta.py +0 -0
  97. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/belatro/test_progression.py +0 -0
  98. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/belatro/test_round_driver.py +0 -0
  99. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/test_ai.py +0 -0
  100. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/test_belote.py +0 -0
  101. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/test_game_logic.py +0 -0
  102. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/test_gameflow.py +0 -0
  103. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/test_new_coverage.py +0 -0
  104. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/test_official_rules.py +0 -0
  105. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/test_properties.py +0 -0
  106. {belote_cli-2.9.2 → belote_cli-2.9.5}/tests/test_undo.py +0 -0
@@ -5,6 +5,43 @@ 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
+ ## [2.9.5] - 2026-05-07
9
+
10
+ In-game keyboard shortcuts cleaned up, the trick mat now anchors every played card inside a visible per-seat slot, the round history overlay carries the full per-round picture, and the cards have been redrawn in a GRIMAUD-1898 style with both-corner indices and patterned pip layouts.
11
+
12
+ ### Fixed
13
+
14
+ - **`src/belote/input.py`** — `Key.THEME` was defined but never mapped to a keystroke; the help text falsely advertised `Shift+T` (which terminals can't generally distinguish in raw mode). Theme cycling during gameplay was unreachable. New mapping: `t/T → Key.THEME`, `h/H → Key.HIST`, `?` → `Key.HELP`, `o/O → Key.SORT`. Both the Unix and Windows readers updated.
15
+ - **`src/belote/input.py`** — `s` was previously stolen by `Key.SORT`, so pressing `S` in round-2 bidding triggered a sort instead of the **Sans Atout** quick-bid the help text promised. Sort now lives on `O` (matching what the help screen always claimed) and `s` falls through to `Key.CHAR` so SA bidding works.
16
+ - **`src/belote/gameflow.py::run_play`** — when the user had already pressed any key earlier in the round (which sets `skip_anims`), the post-trick pause was skipped entirely and the 4th card vanished before it could be read. New `MIN_TRICK_DWELL = 0.5s` non-skippable hold runs after every completed trick (even on the `instant` speed preset) so the player always sees all four cards before the mat clears.
17
+
18
+ ### Added
19
+
20
+ - **`src/belote/ui/render.py`** — visible **slot frames** drawn around each compass position on the trick mat. Implemented via three new helpers (`_slot_anchors`, `_slot_frame_row`, `_felt_pad_ns`, `_we_row`) that paint thin `─`/`│` borders on the felt cells immediately surrounding each card slot, in the felt-placeholder dim colour. Total mat dimensions (`6 + 3*card_h`) are unchanged, so `_calculate_base_row` and `patch_trick_card` continue to work without any coordinate adjustments — patched cards land exactly inside the existing frame.
21
+ - **`src/belote/game.py::RoundScore`** — eight new optional fields (`contract`, `trump`, `taker_seat`, `tricks_ns`, `tricks_ew`, `last_trick_winner`, `decl_summary_ns`, `decl_summary_ew`) populated from `state` and `state.completed_tricks` at scoring time. All fields default so existing test fixtures and any historical `RoundScore` constructions remain valid.
22
+ - **`src/belote/scoring.py::apply_round_score`** — now computes per-team trick counts via `trick_winner_seat`/`team_of`, builds short declaration labels (`"100♥"`, `"Belote"`, `"Carré-J"`, …) gated on the team's `*_decl_pts > 0` so only the *scored* declarations appear, and threads everything into `RoundScore`. New helper `_decl_short_label` covers belote/rebelote/sequence/carre.
23
+ - **`src/belote/ui/prompts.py::show_history`** — rewritten as an 8-column table (`RD | TAKER | CONTRACT | TRICKS | DECLARATIONS | NS | EW | STATUS`) for terminals ≥78 cols, with a 2-line-per-round fallback for narrower terminals. Status colouring: gold `CAPOT`, red `CHUTE`, dim `LITIGE`. Existing scrolling, view-height clamp, and exit-on-any-key behaviour preserved.
24
+ - **`src/belote/ui/render.py::_card_face_internal`** — full GRIMAUD-1898-inspired redraw:
25
+ - Both corners now carry a 3-cell `rank+suit` index (`A♠` top-left, `♠A` bottom-right). The index padding scales with `inner_w`.
26
+ - Pip cards (7-10) at `card_h ≥ 7` get a recognisable pip arrangement instead of a single centred suit symbol.
27
+ - Court cards J/Q/K each get a distinct multi-row motif (sword, jewelled headdress, crown).
28
+ - Aces get a decorative `╭─◆─╮` / `╰─◆─╯` wreath around the central suit.
29
+ - Compact 6×5 layout keeps the single inner row but still benefits from both-corner indices.
30
+ - All variants honour the active theme (`face_card_bg`, `card_face_bg`, `highlight_bg`, `red_fg`/`black_fg`) and the `DIM` prefix for illegal cards. ASCII fallback paths preserved.
31
+ - **`tests/test_extended.py::test_round_score_history_extra_fields`** — pins the new `RoundScore` fields end-to-end through `apply_round_score`.
32
+
33
+ ### Changed
34
+
35
+ - **`src/belote/ui/menu.py`** — main-menu loop now handles `Key.THEME` (cycles forward through `THEMES`), so the new in-game `T` shortcut works at the menu too.
36
+ - **`src/belote/main.py`**, **`src/belote/ui/render.py`** — game-over hint and HUD compact hint updated to advertise the new `[H] History` / `[T] Theme` shortcuts.
37
+ - **`src/belote/ui/prompts.py::show_help`** — help-screen text rewritten to match the new bindings.
38
+ - **`README.md`** — Controls section rewritten; theme section now lists all six themes by name.
39
+ - **`tests/test_layout.py::test_hud_compact_omits_help_hints_and_theme`** — assertion updated for the new compact-HUD hint substring.
40
+
41
+ ### Notes
42
+
43
+ 436/436 tests pass. No gameplay, scoring, or AI-decision changes — all updates are UX (keys, slot framing, dwell, history depth, card glyphs).
44
+
8
45
  ## [2.9.2] - 2026-05-07
9
46
 
10
47
  Render-pipeline fix for Konsole (KDE/Kubuntu) and other strict ANSI terminals where UI elements visibly stacked on top of each other — the top HUD repeating ~6 times, "Theme: Sepia Vintage" duplicating in the right column, "Partner" doubling, the bid prompt repainting below itself, and bid history accumulating between frames. The bug existed in the code on every terminal but VTE-based emulators (LXTerminal, GNOME Terminal, xterm) auto-blanked the leaking cells, masking it. Konsole's Vt102Emulation does not, so the leakage was visible.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: belote-cli
3
- Version: 2.9.2
3
+ Version: 2.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
@@ -170,12 +170,12 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
170
170
  ## Controls
171
171
 
172
172
  **General:**
173
- - `?` or `H`: Show keyboard shortcut help
173
+ - `?`: Show keyboard shortcut help
174
174
  - `M`: Toggle sound effects on/off
175
- - `I`: Toggle BelAtro score overlay (per-trick breakdown popup)
175
+ - `I` or `V`: Toggle BelAtro score overlay (per-trick breakdown popup)
176
176
  - `Q`: Quit to main menu or exit
177
- - `t`: View Game History (Round-by-round)
178
- - `T`: Switch UI Theme
177
+ - `H`: View Game History (round-by-round, with contract / taker / tricks / declarations)
178
+ - `T`: Cycle UI Theme
179
179
 
180
180
  **Classic Belote:**
181
181
  - `↑` `↓`: Navigate options
@@ -183,7 +183,6 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
183
183
  - `Enter`: Select option / Enter submenu
184
184
 
185
185
  **BelAtro (Roguelite):**
186
- - `S`: View current Run State and Jokers
187
186
  - `1`-`5`: Inspect specific Jokers in the Shop
188
187
  - `U`: Use a consumable (Tarot/Planet) during gameplay
189
188
 
@@ -194,6 +193,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
194
193
  - `O`: Sort hand by suit and rank
195
194
  - `Z`: Undo last move
196
195
  - `Space` or `Esc`: Skip animations
196
+ - During bidding round 2: `P` = Pass, `A` = Tout Atout, `S` = Sans Atout
197
197
 
198
198
  ## Features
199
199
 
@@ -204,7 +204,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
204
204
  - **Partner Trust:** Build a relationship with your AI partner to unlock synergies.
205
205
  - **Rich Terminal UI:** Full-screen green felt table with detailed card graphics and "You" vs "Partner" terminology.
206
206
  - **Enhanced Hard AI**: Advanced void inference and 2-ply lookahead for critical tricks (Dix de Der).
207
- - **Customizable Themes:** Switch between different color palettes (e.g., Classic Green, Dark Blue, Royal Purple) using the `T` key during gameplay.
207
+ - **Customizable Themes:** Switch between six color palettes (Classic Green, Dark Mode, Blue Velvet, Red Casino, Sepia Vintage, High Contrast) using the `T` key during gameplay.
208
208
  - **Incremental Rendering:** High-performance cursor-based updates for zero-flicker gameplay even at high speeds.
209
209
  - **Hand Sorting:** Strategic "play value" organization (honors grouped together) for better tactical awareness.
210
210
  - **Main Menu:** Simple single-player entry point with configurable AI difficulty, Target Score, and Speed.
@@ -127,12 +127,12 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
127
127
  ## Controls
128
128
 
129
129
  **General:**
130
- - `?` or `H`: Show keyboard shortcut help
130
+ - `?`: Show keyboard shortcut help
131
131
  - `M`: Toggle sound effects on/off
132
- - `I`: Toggle BelAtro score overlay (per-trick breakdown popup)
132
+ - `I` or `V`: Toggle BelAtro score overlay (per-trick breakdown popup)
133
133
  - `Q`: Quit to main menu or exit
134
- - `t`: View Game History (Round-by-round)
135
- - `T`: Switch UI Theme
134
+ - `H`: View Game History (round-by-round, with contract / taker / tricks / declarations)
135
+ - `T`: Cycle UI Theme
136
136
 
137
137
  **Classic Belote:**
138
138
  - `↑` `↓`: Navigate options
@@ -140,7 +140,6 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
140
140
  - `Enter`: Select option / Enter submenu
141
141
 
142
142
  **BelAtro (Roguelite):**
143
- - `S`: View current Run State and Jokers
144
143
  - `1`-`5`: Inspect specific Jokers in the Shop
145
144
  - `U`: Use a consumable (Tarot/Planet) during gameplay
146
145
 
@@ -151,6 +150,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
151
150
  - `O`: Sort hand by suit and rank
152
151
  - `Z`: Undo last move
153
152
  - `Space` or `Esc`: Skip animations
153
+ - During bidding round 2: `P` = Pass, `A` = Tout Atout, `S` = Sans Atout
154
154
 
155
155
  ## Features
156
156
 
@@ -161,7 +161,7 @@ belote --difficulty hard --target 500 --seed 123 --speed fast
161
161
  - **Partner Trust:** Build a relationship with your AI partner to unlock synergies.
162
162
  - **Rich Terminal UI:** Full-screen green felt table with detailed card graphics and "You" vs "Partner" terminology.
163
163
  - **Enhanced Hard AI**: Advanced void inference and 2-ply lookahead for critical tricks (Dix de Der).
164
- - **Customizable Themes:** Switch between different color palettes (e.g., Classic Green, Dark Blue, Royal Purple) using the `T` key during gameplay.
164
+ - **Customizable Themes:** Switch between six color palettes (Classic Green, Dark Mode, Blue Velvet, Red Casino, Sepia Vintage, High Contrast) using the `T` key during gameplay.
165
165
  - **Incremental Rendering:** High-performance cursor-based updates for zero-flicker gameplay even at high speeds.
166
166
  - **Hand Sorting:** Strategic "play value" organization (honors grouped together) for better tactical awareness.
167
167
  - **Main Menu:** Simple single-player entry point with configurable AI difficulty, Target Score, and Speed.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "belote-cli"
7
- version = "2.9.2"
7
+ version = "2.9.5"
8
8
  description = "A 4-player terminal card game"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
- __version__ = "2.9.2"
1
+ __version__ = "2.9.5"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -149,6 +149,14 @@ class RoundScore:
149
149
  is_capot: bool
150
150
  is_litige: bool = False
151
151
  litige_points: int = 0
152
+ contract: str | None = None
153
+ trump: Suit | None = None
154
+ taker_seat: Seat | None = None
155
+ tricks_ns: int = 0
156
+ tricks_ew: int = 0
157
+ last_trick_winner: Seat | None = None
158
+ decl_summary_ns: tuple[str, ...] = ()
159
+ decl_summary_ew: tuple[str, ...] = ()
152
160
 
153
161
 
154
162
  @dataclass(frozen=True, slots=True)
@@ -50,6 +50,12 @@ from .ui import (
50
50
  )
51
51
 
52
52
 
53
+ # Minimum time the four cards stay on the mat before a trick clears. This
54
+ # applies even when the user has skipped earlier animations (so a fast-paced
55
+ # session still lets the player read every completed trick).
56
+ MIN_TRICK_DWELL: float = 0.5
57
+
58
+
53
59
  def create_ai_players(diffs_map: dict[Seat, str]) -> dict[Seat, AIPlayer]:
54
60
  """Create AI players for seats not occupied by humans."""
55
61
  ai_seats = {Seat.EAST, Seat.NORTH, Seat.WEST}
@@ -177,6 +183,10 @@ def run_play(
177
183
  # 3. If this completes a trick, pause longer and show announcements
178
184
  if len(display_state.current_trick) == 4:
179
185
  play_sound("trick")
186
+ # Non-skippable minimum dwell so all four cards are always visible
187
+ # before the trick clears, even when the user has skipped earlier
188
+ # animations or is on the "instant" speed preset.
189
+ interruptible_sleep(MIN_TRICK_DWELL, None)
180
190
  if len(current.completed_tricks) == 7: # This was the 8th trick
181
191
  se_trump = current.boss_modifiers.seven_eight_trump
182
192
  is_sa = current.contract == "sans_atout"
@@ -143,14 +143,16 @@ class _UnixKeyReader:
143
143
  ch = chr(byte)
144
144
  if ch.lower() == "q":
145
145
  return KeyEvent(Key.QUIT)
146
- if ch.lower() == "h":
146
+ if ch == "?":
147
147
  return KeyEvent(Key.HELP)
148
- if ch.lower() == "s":
148
+ if ch.lower() == "h":
149
+ return KeyEvent(Key.HIST)
150
+ if ch.lower() == "t":
151
+ return KeyEvent(Key.THEME)
152
+ if ch.lower() == "o":
149
153
  return KeyEvent(Key.SORT)
150
154
  if ch.lower() == "m":
151
155
  return KeyEvent(Key.MUTE)
152
- if ch.lower() == "t":
153
- return KeyEvent(Key.HIST)
154
156
  if ch.lower() == "i" or ch.lower() == "v":
155
157
  return KeyEvent(Key.OVERLAY)
156
158
 
@@ -262,14 +264,16 @@ if os.name == "nt":
262
264
  return KeyEvent(Key.SPACE)
263
265
  if ch.lower() == b"q":
264
266
  return KeyEvent(Key.QUIT)
265
- if ch.lower() == b"h":
267
+ if ch == b"?":
266
268
  return KeyEvent(Key.HELP)
267
- if ch.lower() == b"s":
269
+ if ch.lower() == b"h":
270
+ return KeyEvent(Key.HIST)
271
+ if ch.lower() == b"t":
272
+ return KeyEvent(Key.THEME)
273
+ if ch.lower() == b"o":
268
274
  return KeyEvent(Key.SORT)
269
275
  if ch.lower() == b"m":
270
276
  return KeyEvent(Key.MUTE)
271
- if ch.lower() == b"t":
272
- return KeyEvent(Key.HIST)
273
277
  if ch.lower() in (b"i", b"v"):
274
278
  return KeyEvent(Key.OVERLAY)
275
279
 
@@ -240,7 +240,7 @@ def main() -> None:
240
240
  # Wait for Enter/R/Q/T
241
241
  sys.stdout.write(f"\n {BOLD}{gold_fg()}GAME OVER{RESET}")
242
242
  sys.stdout.write(
243
- f"\n {white_fg()}[Enter/Q] Menu [R] Rematch [T] History{RESET} "
243
+ f"\n {white_fg()}[Enter/Q] Menu [R] Rematch [H] History{RESET} "
244
244
  )
245
245
  sys.stdout.flush()
246
246
 
@@ -254,7 +254,7 @@ def main() -> None:
254
254
  show_final_screen(state)
255
255
  sys.stdout.write(f"\n {BOLD}{gold_fg()}GAME OVER{RESET}")
256
256
  sys.stdout.write(
257
- f"\n {white_fg()}[Enter/Q] Menu [R] Rematch [t] History{RESET} "
257
+ f"\n {white_fg()}[Enter/Q] Menu [R] Rematch [H] History{RESET} "
258
258
  )
259
259
  sys.stdout.flush()
260
260
  if ev.key in (Key.ENTER, Key.QUIT):
@@ -719,6 +719,20 @@ def score_round(state: GameState) -> ScoringBreakdown:
719
719
  )
720
720
 
721
721
 
722
+ def _decl_short_label(d: Declaration) -> str:
723
+ if d.kind == "belote":
724
+ return "Belote"
725
+ if d.kind == "rebelote":
726
+ return "Rebelote"
727
+ if d.kind == "carre" and isinstance(d.detail, Carre):
728
+ rank = _VALUE_TO_RANK.get(d.detail.rank)
729
+ return f"Carré-{rank.value}" if rank else "Carré"
730
+ if d.kind == "sequence" and isinstance(d.detail, Sequence):
731
+ pts = _SEQUENCE_POINTS.get(d.detail.length, 0)
732
+ return f"{pts}{d.detail.suit.symbol}"
733
+ return d.kind
734
+
735
+
722
736
  def apply_round_score(state: GameState, breakdown: ScoringBreakdown) -> GameState:
723
737
  """Apply round scoring result to team scores and advance state."""
724
738
  ns, ew = state.team_scores
@@ -731,6 +745,46 @@ def apply_round_score(state: GameState, breakdown: ScoringBreakdown) -> GameStat
731
745
 
732
746
  new_scores = (ns, ew)
733
747
 
748
+ # Trick counts per team across the played round.
749
+ se_trump = state.boss_modifiers.seven_eight_trump
750
+ is_sa = state.contract == "sans_atout"
751
+ tricks_ns = 0
752
+ tricks_ew = 0
753
+ for trick in state.completed_tricks:
754
+ winner = trick_winner_seat(trick, state.trump, se_trump, is_sa)
755
+ if winner is None:
756
+ continue
757
+ if team_of(winner) == 0:
758
+ tricks_ns += 1
759
+ else:
760
+ tricks_ew += 1
761
+
762
+ # Declaration summaries — only show the team(s) that actually scored decls,
763
+ # which matches Belote's "best team takes all decls" rule.
764
+ ns_decl_total = breakdown.taker_declarations if breakdown.taker_team == 0 else breakdown.defender_declarations
765
+ ew_decl_total = breakdown.defender_declarations if breakdown.taker_team == 0 else breakdown.taker_declarations
766
+ ns_decls = tuple(
767
+ _decl_short_label(d) for d in state.declarations if team_of(d.seat) == 0
768
+ ) if ns_decl_total > 0 else ()
769
+ ew_decls = tuple(
770
+ _decl_short_label(d) for d in state.declarations if team_of(d.seat) == 1
771
+ ) if ew_decl_total > 0 else ()
772
+
773
+ common_kwargs = dict(
774
+ is_failed=breakdown.is_failed,
775
+ is_capot=breakdown.is_capot,
776
+ is_litige=breakdown.is_litige,
777
+ litige_points=breakdown.litige_points_awarded,
778
+ contract=state.contract,
779
+ trump=state.trump,
780
+ taker_seat=state.taker,
781
+ tricks_ns=tricks_ns,
782
+ tricks_ew=tricks_ew,
783
+ last_trick_winner=state.last_trick_winner,
784
+ decl_summary_ns=ns_decls,
785
+ decl_summary_ew=ew_decls,
786
+ )
787
+
734
788
  # Create RoundScore for history
735
789
  if breakdown.taker_team == 0:
736
790
  round_score = RoundScore(
@@ -745,10 +799,7 @@ def apply_round_score(state: GameState, breakdown: ScoringBreakdown) -> GameStat
745
799
  ew_rebelote=breakdown.defender_rebelote,
746
800
  ns_total=breakdown.taker_total,
747
801
  ew_total=breakdown.defender_total,
748
- is_failed=breakdown.is_failed,
749
- is_capot=breakdown.is_capot,
750
- is_litige=breakdown.is_litige,
751
- litige_points=breakdown.litige_points_awarded,
802
+ **common_kwargs,
752
803
  )
753
804
  else:
754
805
  round_score = RoundScore(
@@ -763,10 +814,7 @@ def apply_round_score(state: GameState, breakdown: ScoringBreakdown) -> GameStat
763
814
  ew_rebelote=breakdown.taker_rebelote,
764
815
  ns_total=breakdown.defender_total,
765
816
  ew_total=breakdown.taker_total,
766
- is_failed=breakdown.is_failed,
767
- is_capot=breakdown.is_capot,
768
- is_litige=breakdown.is_litige,
769
- litige_points=breakdown.litige_points_awarded,
817
+ **common_kwargs,
770
818
  )
771
819
 
772
820
  new_history = state.score_history + (round_score,)
@@ -281,6 +281,11 @@ def show_main_menu(
281
281
  show_help(reader)
282
282
  case Key.MUTE:
283
283
  toggle_mute()
284
+ case Key.THEME:
285
+ themes_list = list(THEMES.keys())
286
+ curr_theme = theme_manager._current_theme_name
287
+ new_idx = (themes_list.index(curr_theme) + 1) % len(themes_list)
288
+ theme_manager.set_current(themes_list[new_idx])
284
289
  case Key.UP:
285
290
  sel = (sel - 1) % len(options_labels)
286
291
  case Key.DOWN:
@@ -200,11 +200,11 @@ def show_help(reader: KeyReader) -> None:
200
200
  "=" * 20,
201
201
  "",
202
202
  f"{white_fg()}General:{RESET}",
203
- " [?] or [H] Show this help screen",
203
+ " [?] Show this help screen",
204
204
  " [Q] Quit to menu / Exit",
205
205
  " [M] Toggle Sound Effects",
206
206
  f" (Currently: {sound_status})",
207
- " [Shift+T] Cycle Theme",
207
+ " [T] Cycle Theme",
208
208
  " [Esc] Cancel / Back",
209
209
  "",
210
210
  f"{white_fg()}Gameplay:{RESET}",
@@ -213,7 +213,7 @@ def show_help(reader: KeyReader) -> None:
213
213
  " [1-8] Quick card select",
214
214
  " [O] Sort hand by suit/rank",
215
215
  " [Space] Skip animations",
216
- " [t] View Game History",
216
+ " [H] View Game History",
217
217
  " [Z] Undo last move",
218
218
  "",
219
219
  f"{white_fg()}Bidding:{RESET}",
@@ -222,7 +222,7 @@ def show_help(reader: KeyReader) -> None:
222
222
  "",
223
223
  f"{white_fg()}Menus:{RESET}",
224
224
  " [R] Rematch (Game Over)",
225
- " [t] View Game History",
225
+ " [H] View Game History",
226
226
  "",
227
227
  f"{DIM}Press [Any Key] to Return{RESET}",
228
228
  ]
@@ -305,6 +305,46 @@ def show_rules(reader: KeyReader) -> None:
305
305
  scroll = 0
306
306
 
307
307
 
308
+ def _hist_taker_label(rs) -> str:
309
+ team = "NS" if rs.taker_team == 0 else "EW"
310
+ if rs.taker_seat is None:
311
+ return team
312
+ return f"{rs.taker_seat.name[0]} ({team})"
313
+
314
+
315
+ def _hist_contract_label(rs) -> str:
316
+ if rs.contract == "sans_atout":
317
+ return "SA"
318
+ if rs.contract == "tout_atout":
319
+ return "TA"
320
+ sym = rs.trump.symbol if rs.trump is not None and hasattr(rs.trump, "symbol") else "?"
321
+ return f"NORM {sym}"
322
+
323
+
324
+ def _hist_status(rs) -> str:
325
+ if rs.is_capot:
326
+ return f"{gold_fg()}CAPOT{RESET}"
327
+ if rs.is_failed:
328
+ return f"{red_fg()}CHUTE{RESET}"
329
+ if rs.is_litige:
330
+ return f"{DIM}LITIGE{RESET}"
331
+ return "─"
332
+
333
+
334
+ def _hist_decl_str(items: tuple[str, ...], width: int) -> str:
335
+ if not items:
336
+ return "─"
337
+ s = " ".join(items)
338
+ if len(s) > width:
339
+ s = s[: max(0, width - 1)] + "…"
340
+ return s
341
+
342
+
343
+ def _ljust_visible(s: str, width: int) -> str:
344
+ pad = max(0, width - visible_len(s))
345
+ return s + " " * pad
346
+
347
+
308
348
  def show_history(state: GameState, reader: KeyReader) -> None:
309
349
  """Display a scrollable overlay of round-by-round scores."""
310
350
  scroll = 0
@@ -312,7 +352,7 @@ def show_history(state: GameState, reader: KeyReader) -> None:
312
352
  while True:
313
353
  term_w, term_h = get_term_size()
314
354
 
315
- lines = []
355
+ lines: list[str] = []
316
356
  lines.append(f"{BOLD}{gold_fg()}GAME HISTORY{RESET}")
317
357
  lines.append("=" * 12)
318
358
  lines.append("")
@@ -320,32 +360,76 @@ def show_history(state: GameState, reader: KeyReader) -> None:
320
360
  if not state.score_history:
321
361
  lines.append(f"{DIM}No rounds completed yet.{RESET}")
322
362
  else:
323
- # Table Header
324
- header = f"{'RD':<3} | {'TAKER':<5} | {'NS':^15} | {'EW':^15}"
325
- lines.append(f"{BOLD}{white_fg()}{header}{RESET}")
326
- lines.append("-" * len(header))
327
-
328
- for i, rs in enumerate(state.score_history):
329
- rd = i + 1
330
- taker = "NS" if rs.taker_team == 0 else "EW"
331
-
332
- # Format: "Card+Decl+Bel"
333
- ns_break = f"{rs.ns_card_pts}+{rs.ns_decl_pts}+{rs.ns_belote_pts}"
334
- ew_break = f"{rs.ew_card_pts}+{rs.ew_decl_pts}+{rs.ew_belote_pts}"
335
-
336
- ns_total = f"{BOLD}{rs.ns_total}{RESET}"
337
- ew_total = f"{BOLD}{rs.ew_total}{RESET}"
338
-
339
- row = f"{rd:<3} | {taker:<5} | {ns_total:>3} ({ns_break:<9}) | {ew_total:>3} ({ew_break:<9})"
340
-
341
- if rs.is_capot:
342
- status = f" {gold_fg()}CAPOT!{RESET}"
343
- elif rs.is_failed:
344
- status = f" {red_fg()}CHUTE!{RESET}"
345
- else:
346
- status = ""
347
-
348
- lines.append(row + status)
363
+ wide = term_w >= 78
364
+ if wide:
365
+ # Single-row layout. Column widths sum to ~76 with separators.
366
+ W_RD, W_TKR, W_CON, W_TRK, W_DECL, W_NS, W_EW, W_ST = 3, 7, 8, 7, 16, 5, 5, 7
367
+ header_cells = [
368
+ _ljust_visible("RD", W_RD),
369
+ _ljust_visible("TAKER", W_TKR),
370
+ _ljust_visible("CONTRACT", W_CON),
371
+ _ljust_visible("TRICKS", W_TRK),
372
+ _ljust_visible("DECLARATIONS", W_DECL),
373
+ _ljust_visible("NS", W_NS),
374
+ _ljust_visible("EW", W_EW),
375
+ _ljust_visible("STATUS", W_ST),
376
+ ]
377
+ header = " │ ".join(header_cells)
378
+ lines.append(f"{BOLD}{white_fg()}{header}{RESET}")
379
+ lines.append("─" * visible_len(header))
380
+
381
+ for i, rs in enumerate(state.score_history):
382
+ rd = f"{i + 1:02d}"
383
+ taker = _hist_taker_label(rs)
384
+ contract = _hist_contract_label(rs)
385
+ tricks = f"{rs.tricks_ns} / {rs.tricks_ew}"
386
+ decl_ns = _hist_decl_str(rs.decl_summary_ns, W_DECL // 2 - 1)
387
+ decl_ew = _hist_decl_str(rs.decl_summary_ew, W_DECL // 2 - 1)
388
+ if rs.decl_summary_ns and rs.decl_summary_ew:
389
+ decls = f"{decl_ns} / {decl_ew}"
390
+ elif rs.decl_summary_ns:
391
+ decls = decl_ns
392
+ elif rs.decl_summary_ew:
393
+ decls = decl_ew
394
+ else:
395
+ decls = "─"
396
+ if visible_len(decls) > W_DECL:
397
+ decls = decls[: W_DECL - 1] + "…"
398
+ ns = f"{BOLD}{rs.ns_total}{RESET}"
399
+ ew = f"{BOLD}{rs.ew_total}{RESET}"
400
+ status = _hist_status(rs)
401
+
402
+ row_cells = [
403
+ _ljust_visible(rd, W_RD),
404
+ _ljust_visible(taker, W_TKR),
405
+ _ljust_visible(contract, W_CON),
406
+ _ljust_visible(tricks, W_TRK),
407
+ _ljust_visible(decls, W_DECL),
408
+ _ljust_visible(ns, W_NS),
409
+ _ljust_visible(ew, W_EW),
410
+ _ljust_visible(status, W_ST),
411
+ ]
412
+ lines.append(" │ ".join(row_cells))
413
+ else:
414
+ # Compact two-line-per-round layout for narrow terminals.
415
+ lines.append(f"{BOLD}{white_fg()}{'RD':<3} {'TAKER':<7} {'CON':<8} {'TRICKS':<7} STATUS{RESET}")
416
+ lines.append("─" * 40)
417
+ for i, rs in enumerate(state.score_history):
418
+ rd = f"{i + 1:02d}"
419
+ taker = _hist_taker_label(rs)
420
+ contract = _hist_contract_label(rs)
421
+ tricks = f"{rs.tricks_ns}/{rs.tricks_ew}"
422
+ status = _hist_status(rs)
423
+ lines.append(
424
+ f"{rd:<3} {_ljust_visible(taker, 7)} {contract:<8} {tricks:<7} {status}"
425
+ )
426
+ decl_n = _hist_decl_str(rs.decl_summary_ns, 14)
427
+ decl_e = _hist_decl_str(rs.decl_summary_ew, 14)
428
+ lines.append(
429
+ f" NS:{BOLD}{rs.ns_total:>4}{RESET} EW:{BOLD}{rs.ew_total:>4}{RESET} "
430
+ f"decl: {decl_n} / {decl_e}"
431
+ )
432
+ lines.append("")
349
433
 
350
434
  lines.append("")
351
435
  lines.append(f"{DIM}[↑↓] Scroll [Any Key] Return{RESET}")