belote-cli 0.9.0__py3-none-any.whl

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/input.py ADDED
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ import termios
6
+ import tty
7
+ import select
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+
11
+
12
+ class Key(Enum):
13
+ LEFT = "LEFT"
14
+ RIGHT = "RIGHT"
15
+ UP = "UP"
16
+ DOWN = "DOWN"
17
+ ENTER = "ENTER"
18
+ ESC = "ESC"
19
+ CHAR = "CHAR"
20
+ QUIT = "QUIT"
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class KeyEvent:
25
+ key: Key
26
+ char: str | None = None
27
+
28
+
29
+ class KeyReader:
30
+ """Context manager that sets up raw input and provides blocking read."""
31
+
32
+ def __init__(self) -> None:
33
+ self._old_termios: termios.termios | None = None
34
+ self._stdin_fd: int = sys.stdin.fileno()
35
+ self._restored: bool = False
36
+
37
+ def __enter__(self) -> KeyReader:
38
+ try:
39
+ self._old_termios = termios.tcgetattr(self._stdin_fd)
40
+ tty.setraw(self._stdin_fd)
41
+ except termios.error:
42
+ # Fallback if not a TTY (e.g. testing)
43
+ self._old_termios = None
44
+ return self
45
+
46
+ def __exit__(self, *exc: object) -> None:
47
+ self.restore()
48
+
49
+ def restore(self) -> None:
50
+ if not self._restored and self._old_termios is not None:
51
+ self._restored = True
52
+ try:
53
+ termios.tcsetattr(
54
+ self._stdin_fd,
55
+ termios.TCSAFLUSH,
56
+ self._old_termios,
57
+ )
58
+ except Exception:
59
+ pass
60
+
61
+ def read(self) -> KeyEvent:
62
+ """Blocking read of a single key event."""
63
+ return self.read_timeout(None)
64
+
65
+ def read_timeout(self, timeout: float | None = None) -> KeyEvent | None:
66
+ """Read a single key event with an optional timeout."""
67
+ ready = select.select([self._stdin_fd], [], [], timeout)
68
+ if not ready[0]:
69
+ return None
70
+
71
+ # Read a byte
72
+ try:
73
+ buf = os.read(self._stdin_fd, 1)
74
+ except (EOFError, OSError):
75
+ return KeyEvent(Key.QUIT)
76
+
77
+ if not buf:
78
+ return KeyEvent(Key.QUIT)
79
+
80
+ byte = buf[0]
81
+
82
+ # ESC sequence
83
+ if byte == 0x1B:
84
+ # Peek with timeout to distinguish ESC alone vs ESC sequence
85
+ ready = select.select([self._stdin_fd], [], [], 0.1)
86
+ if ready[0]:
87
+ second = os.read(self._stdin_fd, 1)
88
+ if second and second[0] == 0x5B: # '['
89
+ ready = select.select([self._stdin_fd], [], [], 0.1)
90
+ if ready[0]:
91
+ third = os.read(self._stdin_fd, 1)
92
+ if third:
93
+ code = third[0]
94
+ if code == 0x41: # 'A'
95
+ return KeyEvent(Key.UP)
96
+ elif code == 0x42: # 'B'
97
+ return KeyEvent(Key.DOWN)
98
+ elif code == 0x43: # 'C'
99
+ return KeyEvent(Key.RIGHT)
100
+ elif code == 0x44: # 'D'
101
+ return KeyEvent(Key.LEFT)
102
+ return KeyEvent(Key.ESC)
103
+
104
+ # Enter
105
+ if byte in (0x0A, 0x0D):
106
+ return KeyEvent(Key.ENTER)
107
+
108
+ # Tab
109
+ if byte == 0x09:
110
+ return KeyEvent(Key.ENTER)
111
+
112
+ # Printable character
113
+ try:
114
+ ch = buf.decode("utf-8", errors="replace")
115
+ if ch.lower() == 'q':
116
+ return KeyEvent(Key.QUIT)
117
+ return KeyEvent(Key.CHAR, ch)
118
+ except Exception:
119
+ return KeyEvent(Key.CHAR, chr(byte))
120
+
121
+
122
+ # Windows fallback (shouldn't be reached on Linux, but keeping for completeness)
123
+ if sys.platform == "win32":
124
+ import msvcrt # type: ignore[import-not-found]
125
+
126
+ class _WindowsKeyReader: # noqa: N801
127
+ def __enter__(self) -> _WindowsKeyReader:
128
+ return self
129
+
130
+ def __exit__(self, *exc: object) -> None:
131
+ pass
132
+
133
+ def read(self) -> KeyEvent:
134
+ ch = msvcrt.getwch()
135
+ if ch in ("\x00", "\xe0"):
136
+ ch2 = msvcrt.getwch()
137
+ match ch2:
138
+ case "H":
139
+ return KeyEvent(Key.UP)
140
+ case "P":
141
+ return KeyEvent(Key.DOWN)
142
+ case "K":
143
+ return KeyEvent(Key.LEFT)
144
+ case "M":
145
+ return KeyEvent(Key.RIGHT)
146
+ return KeyEvent(Key.CHAR, ch2)
147
+ if ch == "\r":
148
+ return KeyEvent(Key.ENTER)
149
+ if ch == "\x1b":
150
+ return KeyEvent(Key.ESC)
151
+ return KeyEvent(Key.CHAR, ch)
belote/main.py ADDED
@@ -0,0 +1,352 @@
1
+ from __future__ import annotations
2
+
3
+ """Belote – 4-player terminal card game.
4
+
5
+ Usage:
6
+ python -m belote.main [--target 1000] [--difficulty easy|medium|hard] [--seed 42]
7
+ """
8
+
9
+ import argparse
10
+ import atexit
11
+ import random
12
+ import signal
13
+ import sys
14
+ import time
15
+
16
+ from .ansi import (
17
+ RESET, clear_screen, hide_cursor, show_cursor,
18
+ alt_screen_on, alt_screen_off, BOLD, gold_fg, white_fg,
19
+ )
20
+ from .input import KeyReader
21
+ from .deck import Suit
22
+ from .game import (
23
+ GameState, Phase, Seat, new_game, start_round,
24
+ play_card, legal_cards, team_of, partner,
25
+ TrickCard, replace,
26
+ )
27
+ from .bidding import bidding_turn, process_bid
28
+ from .scoring import score_round, apply_round_score
29
+ from .ai import AIPlayer, Difficulty
30
+ from .ui import (
31
+ display, prompt_card, prompt_bid, announce,
32
+ show_final_screen, show_main_menu, animate_score_update,
33
+ show_rules,
34
+ )
35
+
36
+
37
+ class TerminalGuard:
38
+ """Ensure terminal is restored on exit."""
39
+
40
+ def __init__(self) -> None:
41
+ self._restored = False
42
+ self._reader: KeyReader | None = None
43
+
44
+ def enter(self, reader: KeyReader) -> None:
45
+ self._reader = reader
46
+
47
+ def restore(self, _signum: int = 0, _frame: object = None) -> None:
48
+ if self._restored:
49
+ return
50
+ self._restored = True
51
+ sys.stdout.write(alt_screen_off() + show_cursor() + RESET)
52
+ sys.stdout.flush()
53
+ if self._reader and not self._reader._restored:
54
+ try:
55
+ self._reader.restore()
56
+ except Exception:
57
+ pass
58
+
59
+
60
+ def parse_args() -> argparse.Namespace:
61
+ parser = argparse.ArgumentParser(description="Belote – 4-player terminal card game")
62
+ parser.add_argument("--target", type=int, default=1000, help="Target score to win (default: 1000)")
63
+ parser.add_argument("--difficulty", choices=["easy", "medium", "hard"], default="medium",
64
+ help="AI difficulty (default: medium)")
65
+ parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducibility")
66
+ parser.add_argument(
67
+ "--speed", choices=["slow", "normal", "fast", "instant"], default="normal",
68
+ help="Game pace: slow (1.5s), normal (0.7s), fast (0.25s), instant (0s). Default: normal",
69
+ )
70
+ return parser.parse_args()
71
+
72
+
73
+ # AI delay and trick pause durations per speed setting.
74
+ # (ai_move_delay, trick_result_pause, round_result_pause)
75
+ _SPEED_TIMINGS: dict[str, tuple[float, float, float]] = {
76
+ "slow": (1.5, 2.0, 4.0),
77
+ "normal": (0.7, 1.2, 2.5),
78
+ "fast": (0.25, 0.4, 1.0),
79
+ "instant": (0.0, 0.0, 0.5),
80
+ }
81
+
82
+
83
+ def create_ai_players(difficulty: Difficulty) -> dict[Seat, AIPlayer]:
84
+ """Create AI players for non-South seats."""
85
+ return {
86
+ Seat.EAST: AIPlayer(Seat.EAST, difficulty),
87
+ Seat.NORTH: AIPlayer(Seat.NORTH, difficulty),
88
+ Seat.WEST: AIPlayer(Seat.WEST, difficulty),
89
+ }
90
+
91
+
92
+ def run_bidding(state: GameState, reader: KeyReader, ai_players: dict[Seat, AIPlayer],
93
+ ai_delay: float) -> GameState | None:
94
+ """Run the bidding phase. Returns None if user quits."""
95
+ current = state
96
+ while current.phase == Phase.BIDDING:
97
+ bidder = bidding_turn(current)
98
+
99
+ if bidder == Seat.SOUTH:
100
+ display(current, None)
101
+ bid = prompt_bid(current, reader)
102
+ if bid == "QUIT":
103
+ return None
104
+ else:
105
+ ai = ai_players[bidder]
106
+ bid = ai.decide_bid(current)
107
+ display(current, None)
108
+ if bid:
109
+ sys.stdout.write(f"\r\n {bidder.name} takes it as {bid.symbol}!\r\n")
110
+ else:
111
+ sys.stdout.write(f"\r\n {bidder.name} passes\r\n")
112
+ sys.stdout.flush()
113
+
114
+ # If someone takes, pause longer so user can see it
115
+ if bid:
116
+ time.sleep(ai_delay * 2 + 0.5)
117
+ else:
118
+ time.sleep(ai_delay)
119
+
120
+ current = process_bid(current, bid) # type: ignore[arg-type]
121
+
122
+ return current
123
+
124
+
125
+ def run_play(state: GameState, reader: KeyReader, ai_players: dict[Seat, AIPlayer],
126
+ ai_delay: float, trick_pause: float) -> GameState | None:
127
+ """Run the play phase (all 8 tricks). Returns None if user quits."""
128
+ current = state
129
+
130
+ while current.phase == Phase.PLAYING:
131
+ player = current.turn
132
+
133
+ if player == Seat.SOUTH:
134
+ card = prompt_card(current, reader)
135
+ if card is None:
136
+ return None
137
+ else:
138
+ ai = ai_players[player]
139
+ ai.update_memory(current)
140
+ card = ai.decide_card(current)
141
+
142
+ # 1. Show the card on the mat IMMEDIATELY
143
+ display_state = replace(current, current_trick=current.current_trick + (TrickCard(player, card),))
144
+ display(display_state, None)
145
+
146
+ # 2. Handle AI message and short delay for cards 1, 2, 3
147
+ if player != Seat.SOUTH:
148
+ sys.stdout.write(f"\r\n {player.name} plays {card}\r\n")
149
+ sys.stdout.flush()
150
+ if len(display_state.current_trick) < 4:
151
+ time.sleep(ai_delay)
152
+
153
+ # 3. If this completes a trick, pause longer and show announcements
154
+ if len(display_state.current_trick) == 4:
155
+ if len(current.completed_tricks) == 7: # This was the 8th trick
156
+ from .game import trick_winner_seat, team_of
157
+ winner = trick_winner_seat(display_state.current_trick, current.trump)
158
+ if winner:
159
+ team = "NS" if team_of(winner) == 0 else "EW"
160
+ announce(f"Dix de Der (Team {team})", duration=trick_pause * 0.8)
161
+ display(display_state, None)
162
+
163
+ # Check for Capot while cards are still visible
164
+ taker_team = team_of(current.taker) if current.taker else None
165
+ if taker_team is not None:
166
+ # Previous tricks + this one
167
+ all_tricks = list(current.completed_tricks) + [display_state.current_trick]
168
+ is_capot = all(
169
+ team_of(trick_winner_seat(t, current.trump)) == taker_team
170
+ for t in all_tricks
171
+ )
172
+ if is_capot:
173
+ announce("CAPOT!", duration=trick_pause * 1.2)
174
+ display(display_state, None)
175
+
176
+ time.sleep(trick_pause)
177
+
178
+ # 4. Transition to next state
179
+ current = play_card(current, card)
180
+
181
+ if current.announced:
182
+ announce(current.announced, duration=max(0.5, trick_pause * 0.6))
183
+ display(current, None)
184
+
185
+ return current
186
+
187
+
188
+ def run_round(state: GameState, reader: KeyReader, ai_players: dict[Seat, AIPlayer],
189
+ ai_delay: float, trick_pause: float, round_pause: float) -> GameState | None:
190
+ """Run a complete round: deal, bid, play, score. Returns None if user quits."""
191
+ rng = random.Random()
192
+ current = start_round(state, rng)
193
+
194
+ current = run_bidding(current, reader, ai_players, ai_delay)
195
+
196
+ if current is None:
197
+ return None
198
+
199
+ if current.phase == Phase.DEAL:
200
+ announce("All passed - Reshuffling!", duration=round_pause * 0.5)
201
+ return current # All passed, redeal
202
+
203
+ res_play = run_play(current, reader, ai_players, ai_delay, trick_pause)
204
+ if res_play is None:
205
+ return None
206
+ current = res_play
207
+
208
+ if current.phase == Phase.SCORING:
209
+ breakdown = score_round(current)
210
+
211
+ display(current, None)
212
+ sys.stdout.write(f"\r\n{'='*50}\r\n")
213
+ sys.stdout.write(f" Round Results:\r\n")
214
+ taker_name = current.taker.name if current.taker else "?"
215
+ team_label = 'NS' if team_of(current.taker) == 0 else 'EW'
216
+ sys.stdout.write(f" Taker: {taker_name} (Team {team_label})\r\n")
217
+ for msg in breakdown.messages:
218
+ sys.stdout.write(f" {BOLD}{gold_fg()}{msg}{RESET}\r\n")
219
+ ns_pts = breakdown.taker_total if breakdown.taker_team == 0 else breakdown.defender_total
220
+ ew_pts = breakdown.defender_total if breakdown.taker_team == 0 else breakdown.taker_total
221
+ sys.stdout.write(f" Team NS: {ns_pts} points\r\n")
222
+ sys.stdout.write(f" Team EW: {ew_pts} points\r\n")
223
+ sys.stdout.write(f"{'='*50}\r\n\r\n")
224
+ sys.stdout.flush()
225
+ time.sleep(round_pause)
226
+
227
+ # Animate score update
228
+ ns_old, ew_old = current.team_scores
229
+ if breakdown.taker_team == 0:
230
+ target_ns = ns_old + breakdown.taker_total
231
+ target_ew = ew_old + breakdown.defender_total
232
+ else:
233
+ target_ns = ns_old + breakdown.defender_total
234
+ target_ew = ew_old + breakdown.taker_total
235
+
236
+ animate_score_update(current, target_ns, target_ew)
237
+
238
+ current = apply_round_score(current, breakdown)
239
+
240
+ return current
241
+
242
+
243
+ def main() -> None:
244
+ args = parse_args()
245
+ ai_delay, trick_pause, round_pause = _SPEED_TIMINGS[args.speed]
246
+
247
+ # Setup terminal
248
+ guard = TerminalGuard()
249
+
250
+ def _sig_handler(_signum: int, _frame: object) -> None:
251
+ guard.restore()
252
+ sys.exit(0)
253
+
254
+ signal.signal(signal.SIGINT, _sig_handler)
255
+ signal.signal(signal.SIGTERM, _sig_handler)
256
+ atexit.register(guard.restore)
257
+
258
+ rng = random.Random(args.seed)
259
+
260
+ try:
261
+ with KeyReader() as reader:
262
+ guard.enter(reader) # type: ignore[arg-type]
263
+
264
+ target = args.target
265
+ difficulty = Difficulty(args.difficulty)
266
+ speed = args.speed
267
+
268
+ while True:
269
+ choice, diff_val, target, speed = show_main_menu(reader, difficulty.value, target, speed)
270
+ difficulty = Difficulty(diff_val)
271
+
272
+ if choice == "Quit":
273
+ break
274
+
275
+ if choice == "Rules & History":
276
+ show_rules(reader)
277
+ continue
278
+
279
+ if choice != "Start Game":
280
+ # Settings are now updated live in show_main_menu
281
+ continue
282
+
283
+ # Start Game
284
+ ai_delay, trick_pause, round_pause = _SPEED_TIMINGS[speed]
285
+ sys.stdout.write(alt_screen_on() + clear_screen() + hide_cursor())
286
+ sys.stdout.flush()
287
+
288
+ # Create AI players
289
+ ai_players = create_ai_players(difficulty)
290
+
291
+ # Seed AI random for reproducibility
292
+ if args.seed is not None:
293
+ for ai in ai_players.values():
294
+ ai._rng = random.Random(args.seed)
295
+
296
+ # Initialize game
297
+ state = new_game(target=target)
298
+
299
+ # Main game loop
300
+ while state.phase != Phase.GAME_OVER:
301
+ if state.phase == Phase.DEAL:
302
+ # Start a new round
303
+ pass # run_round will call start_round
304
+
305
+ res_round = run_round(state, reader, ai_players, ai_delay, trick_pause, round_pause)
306
+ if res_round is None:
307
+ break
308
+ state = res_round
309
+
310
+ # Check for game over
311
+ if state.phase == Phase.GAME_OVER:
312
+ break
313
+
314
+ # Check if both teams reached target
315
+ ns, ew = state.team_scores
316
+ if ns >= target or ew >= target:
317
+ from .game import replace
318
+ state = replace(state, phase=Phase.GAME_OVER)
319
+
320
+ if state.phase == Phase.GAME_OVER:
321
+ # Show final screen
322
+ show_final_screen(state)
323
+
324
+ # Wait for Enter/R/Q
325
+ sys.stdout.write("\n [Enter/Q] Main Menu [R] Rematch")
326
+ sys.stdout.flush()
327
+ rematch = False
328
+ while True:
329
+ ev = reader.read()
330
+ if ev.key == Key.CHAR and ev.char and ev.char.lower() == 'r':
331
+ rematch = True
332
+ break
333
+ if ev.key in (Key.ENTER, Key.QUIT):
334
+ break
335
+
336
+ if rematch:
337
+ # Skip the menu and start again
338
+ sys.stdout.write(alt_screen_off())
339
+ sys.stdout.flush()
340
+ continue
341
+
342
+ sys.stdout.write(alt_screen_off())
343
+ sys.stdout.flush()
344
+
345
+ except KeyboardInterrupt:
346
+ pass
347
+ finally:
348
+ guard.restore()
349
+
350
+
351
+ if __name__ == "__main__":
352
+ main()
belote/rules.py ADDED
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ # Rules and History of Belote in English and French
4
+
5
+ RULES_CONTENT = {
6
+ "en": {
7
+ "title": "BELOTE - RULES & HISTORY",
8
+ "sections": [
9
+ {
10
+ "header": "History",
11
+ "text": (
12
+ "Belote is a trick-taking card game that originated in France around 1920. "
13
+ "It is derived from the Dutch game 'Klaverjas' and has become the most "
14
+ "popular card game in France, often played in cafes and family gatherings. "
15
+ "The game is traditionally played by four people in two permanent partnerships."
16
+ )
17
+ },
18
+ {
19
+ "header": "The Deck",
20
+ "text": (
21
+ "A 32-card deck is used (7, 8, 9, 10, J, Q, K, A in four suits). "
22
+ "The ranking and values of cards differ between Trump and Non-Trump suits."
23
+ )
24
+ },
25
+ {
26
+ "header": "Bidding",
27
+ "text": (
28
+ "Bidding happens in two rounds. 5 cards are dealt, and a 21st card is turned face-up.\n"
29
+ "• Round 1: Players decide if they want to 'Take' the up-card's suit as trump.\n"
30
+ "• Round 2: If everyone passes, players can choose any other suit as trump.\n"
31
+ "The 'Taker' receives the up-card, and everyone gets their remaining cards (total of 8 each)."
32
+ )
33
+ },
34
+ {
35
+ "header": "Gameplay",
36
+ "text": (
37
+ "• Players must follow the lead suit if possible.\n"
38
+ "• If void in the lead suit, a player must 'cut' with a trump card.\n"
39
+ "• If the partner is winning a non-trump trick, cutting is optional (standard rules).\n"
40
+ "• When playing trumps, you must 'overtrump' (play a higher trump) if possible."
41
+ )
42
+ },
43
+ {
44
+ "header": "Scoring",
45
+ "text": (
46
+ "• Total card points: 152.\n"
47
+ "• 'Dix de Der': 10 points for winning the last trick.\n"
48
+ "• 'Belote & Rebelote': 20 points for holding the King and Queen of Trumps.\n"
49
+ "• 'Capot': 250 points for winning all 8 tricks.\n"
50
+ "The Taker's team must score more points than the defenders (Dedans/Chute)."
51
+ )
52
+ }
53
+ ]
54
+ },
55
+ "fr": {
56
+ "title": "LA BELOTE - RÈGLES & HISTOIRE",
57
+ "sections": [
58
+ {
59
+ "header": "Histoire",
60
+ "text": (
61
+ "La Belote est un jeu de cartes de type 'levées' apparu en France vers 1920. "
62
+ "Dérivé du jeu néerlandais 'Klaverjas', il est devenu le jeu de cartes le plus "
63
+ "populaire en France, pilier des cafés et des réunions de famille. "
64
+ "Il se joue traditionnellement à quatre joueurs répartis en deux équipes."
65
+ )
66
+ },
67
+ {
68
+ "header": "Le Jeu",
69
+ "text": (
70
+ "On utilise un jeu de 32 cartes (du 7 à l'As). "
71
+ "L'ordre et la valeur des cartes diffèrent selon que la couleur est Atout ou non."
72
+ )
73
+ },
74
+ {
75
+ "header": "Les Enchères",
76
+ "text": (
77
+ "Le contrat se décide en deux tours. 5 cartes sont distribuées et une 21ème est retournée.\n"
78
+ "• 1er tour: Les joueurs choisissent de 'Prendre' la couleur de la carte retournée.\n"
79
+ "• 2ème tour: Si tout le monde passe, on peut choisir n'importe quelle autre couleur.\n"
80
+ "Le 'Preneur' reçoit la carte retournée, et chacun reçoit le reste de ses cartes (8 au total)."
81
+ )
82
+ },
83
+ {
84
+ "header": "Le Jeu de la Carte",
85
+ "text": (
86
+ "• On doit fournir la couleur demandée si possible.\n"
87
+ "• Si on n'a pas la couleur, on doit 'couper' avec un atout.\n"
88
+ "• Si le partenaire est maître sur un pli hors-atout, la coupe est facultative.\n"
89
+ "• À l'atout, on est obligé de 'monter' (jouer un atout plus fort) si possible."
90
+ )
91
+ },
92
+ {
93
+ "header": "Le Score",
94
+ "text": (
95
+ "• Points des cartes: 152.\n"
96
+ "• 'Dix de Der': 10 points pour le dernier pli.\n"
97
+ "• 'Belote & Rebelote': 20 points pour le Roi et la Dame d'Atout.\n"
98
+ "• 'Capot': 250 points si une équipe remporte les 8 plis.\n"
99
+ "L'équipe du Preneur doit faire plus de points que la défense (Sinon, elle est 'Dedans')."
100
+ )
101
+ }
102
+ ]
103
+ }
104
+ }