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/__init__.py +0 -0
- belote/ai.py +412 -0
- belote/ansi.py +141 -0
- belote/bidding.py +68 -0
- belote/deck.py +156 -0
- belote/game.py +537 -0
- belote/input.py +151 -0
- belote/main.py +352 -0
- belote/rules.py +104 -0
- belote/scoring.py +488 -0
- belote/ui.py +825 -0
- belote_cli-0.9.0.dist-info/METADATA +177 -0
- belote_cli-0.9.0.dist-info/RECORD +16 -0
- belote_cli-0.9.0.dist-info/WHEEL +4 -0
- belote_cli-0.9.0.dist-info/entry_points.txt +2 -0
- belote_cli-0.9.0.dist-info/licenses/LICENSE +21 -0
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
|
+
}
|