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/deck.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Suit(Enum):
|
|
9
|
+
SPADES = "♠"
|
|
10
|
+
HEARTS = "♥"
|
|
11
|
+
DIAMONDS = "♦"
|
|
12
|
+
CLUBS = "♣"
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def symbol(self) -> str:
|
|
16
|
+
return self.value
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def is_red(self) -> bool:
|
|
20
|
+
return self in (Suit.HEARTS, Suit.DIAMONDS)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Rank(Enum):
|
|
24
|
+
SEVEN = "7"
|
|
25
|
+
EIGHT = "8"
|
|
26
|
+
NINE = "9"
|
|
27
|
+
TEN = "10"
|
|
28
|
+
JACK = "J"
|
|
29
|
+
QUEEN = "Q"
|
|
30
|
+
KING = "K"
|
|
31
|
+
ACE = "A"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class Card:
|
|
36
|
+
suit: Suit
|
|
37
|
+
rank: Rank
|
|
38
|
+
|
|
39
|
+
def __repr__(self) -> str:
|
|
40
|
+
return f"{self.rank.value}{self.suit.symbol}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def make_deck() -> tuple[Card, ...]:
|
|
44
|
+
"""Return 32 cards in deterministic order: suits in S/H/D/C, ranks 7→A."""
|
|
45
|
+
suits = list(Suit)
|
|
46
|
+
ranks = list(Rank)
|
|
47
|
+
return tuple(Card(s, r) for s in suits for r in ranks)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def shuffle(deck: tuple[Card, ...], rng: random.Random) -> tuple[Card, ...]:
|
|
51
|
+
"""Shuffle deck in place (well, returns new shuffled tuple)."""
|
|
52
|
+
lst = list(deck)
|
|
53
|
+
rng.shuffle(lst)
|
|
54
|
+
return tuple(lst)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def deal(deck: tuple[Card, ...]) -> tuple[tuple[tuple[Card, ...], ...], Card, tuple[tuple[Card, ...], ...]]:
|
|
58
|
+
"""Deal for Belote:
|
|
59
|
+
1. Initial deal: 5 cards each (3 then 2).
|
|
60
|
+
2. Up-card: the 21st card.
|
|
61
|
+
3. Remaining cards: 3 cards each (totaling 8).
|
|
62
|
+
Returns (initial_hands, up_card, remaining_cards).
|
|
63
|
+
"""
|
|
64
|
+
initial: list[list[Card]] = [[], [], [], []]
|
|
65
|
+
remaining: list[list[Card]] = [[], [], [], []]
|
|
66
|
+
|
|
67
|
+
idx = 0
|
|
68
|
+
# First 3
|
|
69
|
+
for _ in range(3):
|
|
70
|
+
for h in initial:
|
|
71
|
+
h.append(deck[idx])
|
|
72
|
+
idx += 1
|
|
73
|
+
# Next 2
|
|
74
|
+
for _ in range(2):
|
|
75
|
+
for h in initial:
|
|
76
|
+
h.append(deck[idx])
|
|
77
|
+
idx += 1
|
|
78
|
+
|
|
79
|
+
# Up-card
|
|
80
|
+
up_card = deck[idx]
|
|
81
|
+
idx += 1
|
|
82
|
+
|
|
83
|
+
# Remaining 11 cards
|
|
84
|
+
# We'll just return them as a flat tuple and distribute in game.py
|
|
85
|
+
remaining_pool = deck[idx:]
|
|
86
|
+
return (
|
|
87
|
+
tuple(tuple(h) for h in initial),
|
|
88
|
+
up_card,
|
|
89
|
+
remaining_pool
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Trump ranking: J=7, 9=6, A=5, 10=4, K=3, Q=2, 8=1, 7=0 (higher = stronger)
|
|
94
|
+
_TRUMP_ORDER: dict[Rank, int] = {
|
|
95
|
+
Rank.SEVEN: 0,
|
|
96
|
+
Rank.EIGHT: 1,
|
|
97
|
+
Rank.QUEEN: 2,
|
|
98
|
+
Rank.KING: 3,
|
|
99
|
+
Rank.TEN: 4,
|
|
100
|
+
Rank.ACE: 5,
|
|
101
|
+
Rank.NINE: 6,
|
|
102
|
+
Rank.JACK: 7,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Non-trump ranking: A=7, 10=6, K=5, Q=4, J=3, 9=2, 8=1, 7=0
|
|
106
|
+
_NONTRUMP_ORDER: dict[Rank, int] = {
|
|
107
|
+
Rank.SEVEN: 0,
|
|
108
|
+
Rank.EIGHT: 1,
|
|
109
|
+
Rank.NINE: 2,
|
|
110
|
+
Rank.JACK: 3,
|
|
111
|
+
Rank.QUEEN: 4,
|
|
112
|
+
Rank.KING: 5,
|
|
113
|
+
Rank.TEN: 6,
|
|
114
|
+
Rank.ACE: 7,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def trick_rank(card: Card, trump: Suit) -> int:
|
|
119
|
+
"""Higher value = stronger card. Returns 0-15 (trump cards get 8-15)."""
|
|
120
|
+
if card.suit == trump:
|
|
121
|
+
return 8 + _TRUMP_ORDER[card.rank]
|
|
122
|
+
return _NONTRUMP_ORDER[card.rank]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def card_points(card: Card, trump: Suit) -> int:
|
|
126
|
+
"""Point value of a card. Sum over all 32 cards = 152."""
|
|
127
|
+
if card.suit == trump:
|
|
128
|
+
match card.rank:
|
|
129
|
+
case Rank.JACK:
|
|
130
|
+
return 20
|
|
131
|
+
case Rank.NINE:
|
|
132
|
+
return 14
|
|
133
|
+
case Rank.ACE:
|
|
134
|
+
return 11
|
|
135
|
+
case Rank.TEN:
|
|
136
|
+
return 10
|
|
137
|
+
case Rank.KING:
|
|
138
|
+
return 4
|
|
139
|
+
case Rank.QUEEN:
|
|
140
|
+
return 3
|
|
141
|
+
case Rank.EIGHT | Rank.SEVEN:
|
|
142
|
+
return 0
|
|
143
|
+
else:
|
|
144
|
+
match card.rank:
|
|
145
|
+
case Rank.JACK:
|
|
146
|
+
return 2
|
|
147
|
+
case Rank.NINE | Rank.EIGHT | Rank.SEVEN:
|
|
148
|
+
return 0
|
|
149
|
+
case Rank.ACE:
|
|
150
|
+
return 11
|
|
151
|
+
case Rank.TEN:
|
|
152
|
+
return 10
|
|
153
|
+
case Rank.KING:
|
|
154
|
+
return 4
|
|
155
|
+
case Rank.QUEEN:
|
|
156
|
+
return 3
|
belote/game.py
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
from .deck import Card, Suit, trick_rank
|
|
9
|
+
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
# Enums
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
class Phase(Enum):
|
|
15
|
+
DEAL = "DEAL"
|
|
16
|
+
BIDDING = "BIDDING"
|
|
17
|
+
PLAYING = "PLAYING"
|
|
18
|
+
SCORING = "SCORING"
|
|
19
|
+
GAME_OVER = "GAME_OVER"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Seat(Enum):
|
|
23
|
+
SOUTH = 0
|
|
24
|
+
EAST = 1
|
|
25
|
+
NORTH = 2
|
|
26
|
+
WEST = 3
|
|
27
|
+
|
|
28
|
+
def next_seat(self) -> Seat:
|
|
29
|
+
return Seat((self.value + 1) % 4)
|
|
30
|
+
|
|
31
|
+
def prev_seat(self) -> Seat:
|
|
32
|
+
return Seat((self.value - 1) % 4)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def name(self) -> str:
|
|
36
|
+
match self:
|
|
37
|
+
case Seat.SOUTH:
|
|
38
|
+
return "South"
|
|
39
|
+
case Seat.EAST:
|
|
40
|
+
return "East"
|
|
41
|
+
case Seat.NORTH:
|
|
42
|
+
return "North"
|
|
43
|
+
case Seat.WEST:
|
|
44
|
+
return "West"
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def label(self) -> str:
|
|
48
|
+
if self == Seat.SOUTH:
|
|
49
|
+
return "You"
|
|
50
|
+
return self.name
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Helpers
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def team_of(seat: Seat) -> int:
|
|
58
|
+
"""0 = NS, 1 = EW."""
|
|
59
|
+
return 0 if seat in (Seat.SOUTH, Seat.NORTH) else 1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def partner(seat: Seat) -> Seat:
|
|
63
|
+
if seat == Seat.SOUTH:
|
|
64
|
+
return Seat.NORTH
|
|
65
|
+
if seat == Seat.NORTH:
|
|
66
|
+
return Seat.SOUTH
|
|
67
|
+
if seat == Seat.EAST:
|
|
68
|
+
return Seat.WEST
|
|
69
|
+
return Seat.EAST
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Declaration types
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class Sequence:
|
|
78
|
+
length: int
|
|
79
|
+
top_rank: int # numeric rank value (higher = better)
|
|
80
|
+
suit: Suit
|
|
81
|
+
is_trump: bool
|
|
82
|
+
cards: tuple[Card, ...]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class Carre:
|
|
87
|
+
rank: int # numeric rank value
|
|
88
|
+
cards: tuple[Card, ...]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True, slots=True)
|
|
92
|
+
class BeloteDecl:
|
|
93
|
+
cards: tuple[Card, ...] # K+Q of trump
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True)
|
|
97
|
+
class Declaration:
|
|
98
|
+
seat: Seat
|
|
99
|
+
kind: str # "belote" | "rebelote" | "sequence" | "carre"
|
|
100
|
+
detail: Sequence | Carre | BeloteDecl | None = None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# GameState
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True, slots=True)
|
|
108
|
+
class TrickCard:
|
|
109
|
+
seat: Seat
|
|
110
|
+
card: Card
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class GameState:
|
|
115
|
+
hands: tuple[tuple[Card, ...], ...] # indexed by Seat.value
|
|
116
|
+
trump: Suit | None
|
|
117
|
+
dealer: Seat
|
|
118
|
+
leader: Seat # leads the current trick
|
|
119
|
+
turn: Seat # whose turn to act
|
|
120
|
+
phase: Phase
|
|
121
|
+
bids: tuple[Suit | None, ...] # one entry per bidder; None = pass
|
|
122
|
+
taker: Seat | None
|
|
123
|
+
current_trick: tuple[TrickCard, ...]
|
|
124
|
+
completed_tricks: tuple[tuple[TrickCard, ...], ...]
|
|
125
|
+
last_trick_winner: Seat | None
|
|
126
|
+
declarations: tuple[Declaration, ...]
|
|
127
|
+
declarations_resolved: bool
|
|
128
|
+
team_scores: tuple[int, int] # (NS, EW)
|
|
129
|
+
round_scores: tuple[int, int] # this round declaration points (NS, EW)
|
|
130
|
+
target: int
|
|
131
|
+
up_card: Card | None # The card turned up during bidding phase
|
|
132
|
+
remaining_cards: tuple[Card, ...] # The 11 cards to be dealt after bidding
|
|
133
|
+
bidder_index: int # which player is bidding (0-3 into the bidding order)
|
|
134
|
+
bidding_round: int # 1 or 2
|
|
135
|
+
bid_suits: tuple[Suit, ...] # suits that have been bid (for bidding phase)
|
|
136
|
+
announced: str | None # transient announcement message
|
|
137
|
+
belote_tracker: tuple[bool, bool] # (belote_announced, rebelote_announced) for trump holder
|
|
138
|
+
first_trick_done: bool # whether trick 1 has completed (triggers declaration resolution)
|
|
139
|
+
|
|
140
|
+
def hand_of(self, seat: Seat) -> tuple[Card, ...]:
|
|
141
|
+
return self.hands[seat.value]
|
|
142
|
+
|
|
143
|
+
def tricks_won_by_team(self, team: int) -> int:
|
|
144
|
+
count = 0
|
|
145
|
+
for trick in self.completed_tricks:
|
|
146
|
+
winner = trick_winner_seat(trick, self.trump)
|
|
147
|
+
if winner is not None and team_of(winner) == team:
|
|
148
|
+
count += 1
|
|
149
|
+
return count
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Pure transitions
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def new_game(target: int = 1000) -> GameState:
|
|
157
|
+
return GameState(
|
|
158
|
+
hands=(() for _ in range(4)), # type: ignore[arg-type]
|
|
159
|
+
trump=None,
|
|
160
|
+
dealer=Seat.SOUTH,
|
|
161
|
+
leader=Seat.SOUTH,
|
|
162
|
+
turn=Seat.SOUTH,
|
|
163
|
+
phase=Phase.DEAL,
|
|
164
|
+
bids=(),
|
|
165
|
+
taker=None,
|
|
166
|
+
current_trick=(),
|
|
167
|
+
completed_tricks=(),
|
|
168
|
+
last_trick_winner=None,
|
|
169
|
+
declarations=(),
|
|
170
|
+
declarations_resolved=False,
|
|
171
|
+
team_scores=(0, 0),
|
|
172
|
+
round_scores=(0, 0),
|
|
173
|
+
target=target,
|
|
174
|
+
up_card=None,
|
|
175
|
+
remaining_cards=(),
|
|
176
|
+
bidder_index=0,
|
|
177
|
+
bidding_round=1,
|
|
178
|
+
bid_suits=(),
|
|
179
|
+
announced=None,
|
|
180
|
+
belote_tracker=(False, False),
|
|
181
|
+
first_trick_done=False,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def start_round(state: GameState, rng: random.Random) -> GameState:
|
|
186
|
+
"""Deal cards and start bidding phase."""
|
|
187
|
+
deck = shuffle_deck(rng)
|
|
188
|
+
initial_hands, up_card, remaining = deal_cards(deck)
|
|
189
|
+
dealer = state.dealer
|
|
190
|
+
first_bidder = dealer.next_seat() # bidding starts left of dealer
|
|
191
|
+
|
|
192
|
+
return replace(
|
|
193
|
+
state,
|
|
194
|
+
hands=initial_hands,
|
|
195
|
+
trump=None,
|
|
196
|
+
leader=first_bidder,
|
|
197
|
+
turn=first_bidder,
|
|
198
|
+
phase=Phase.BIDDING,
|
|
199
|
+
bids=(),
|
|
200
|
+
taker=None,
|
|
201
|
+
current_trick=(),
|
|
202
|
+
completed_tricks=(),
|
|
203
|
+
last_trick_winner=None,
|
|
204
|
+
declarations=(),
|
|
205
|
+
declarations_resolved=False,
|
|
206
|
+
round_scores=(0, 0),
|
|
207
|
+
up_card=up_card,
|
|
208
|
+
remaining_cards=remaining,
|
|
209
|
+
bidder_index=0,
|
|
210
|
+
bidding_round=1,
|
|
211
|
+
bid_suits=(),
|
|
212
|
+
announced=None,
|
|
213
|
+
belote_tracker=(False, False),
|
|
214
|
+
first_trick_done=False,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def shuffle_deck(rng: random.Random) -> tuple[Card, ...]:
|
|
219
|
+
from .deck import make_deck, shuffle as shuffle_deck_
|
|
220
|
+
return shuffle_deck_(make_deck(), rng)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def deal_cards(deck: tuple[Card, ...]) -> tuple[tuple[Card, ...], ...]:
|
|
224
|
+
from .deck import deal as deal_cards_
|
|
225
|
+
return deal_cards_(deck)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def bidding_order(dealer: Seat) -> tuple[Seat, ...]:
|
|
229
|
+
"""Return seats in bidding order (counter-clockwise from left of dealer)."""
|
|
230
|
+
start = dealer.next_seat()
|
|
231
|
+
return tuple(start.next_seat().next_seat().next_seat() if i == 0 else
|
|
232
|
+
(start if i == 0 else
|
|
233
|
+
(start.next_seat() if i == 1 else
|
|
234
|
+
(start.next_seat().next_seat() if i == 2 else
|
|
235
|
+
start.next_seat().next_seat().next_seat())))
|
|
236
|
+
for i in range(4))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def get_bidder(dealer: Seat, index: int) -> Seat:
|
|
240
|
+
"""Get the seat of the bidder at the given index."""
|
|
241
|
+
start = dealer.next_seat()
|
|
242
|
+
return Seat((start.value + index) % 4)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def place_bid(state: GameState, bid: Suit | None) -> GameState:
|
|
246
|
+
"""Process a bid from the current bidder."""
|
|
247
|
+
new_bids = state.bids + (bid,)
|
|
248
|
+
|
|
249
|
+
if bid is not None:
|
|
250
|
+
# Someone chose trump
|
|
251
|
+
taker = get_bidder(state.dealer, state.bidder_index)
|
|
252
|
+
|
|
253
|
+
# Distribute remaining 11 cards
|
|
254
|
+
new_hands = [list(h) for h in state.hands]
|
|
255
|
+
pool = list(state.remaining_cards)
|
|
256
|
+
pool_idx = 0
|
|
257
|
+
|
|
258
|
+
# Ordering: from dealer.next_seat() around the table
|
|
259
|
+
for i in range(4):
|
|
260
|
+
s = get_bidder(state.dealer, i)
|
|
261
|
+
if s == taker:
|
|
262
|
+
new_hands[s.value].append(state.up_card) # type: ignore[arg-type]
|
|
263
|
+
# Taker only needs 2 more
|
|
264
|
+
new_hands[s.value].extend(pool[pool_idx:pool_idx+2])
|
|
265
|
+
pool_idx += 2
|
|
266
|
+
else:
|
|
267
|
+
# Others need 3 more
|
|
268
|
+
new_hands[s.value].extend(pool[pool_idx:pool_idx+3])
|
|
269
|
+
pool_idx += 3
|
|
270
|
+
|
|
271
|
+
return replace(
|
|
272
|
+
state,
|
|
273
|
+
hands=tuple(tuple(h) for h in new_hands),
|
|
274
|
+
bids=new_bids,
|
|
275
|
+
trump=bid,
|
|
276
|
+
taker=taker,
|
|
277
|
+
leader=state.dealer.next_seat(), # Standard: left of dealer leads
|
|
278
|
+
turn=state.dealer.next_seat(),
|
|
279
|
+
phase=Phase.PLAYING,
|
|
280
|
+
up_card=None,
|
|
281
|
+
remaining_cards=(),
|
|
282
|
+
)
|
|
283
|
+
else:
|
|
284
|
+
# Pass
|
|
285
|
+
next_index = state.bidder_index + 1
|
|
286
|
+
if next_index >= 4:
|
|
287
|
+
if state.bidding_round == 1:
|
|
288
|
+
# Move to round 2
|
|
289
|
+
return replace(
|
|
290
|
+
state,
|
|
291
|
+
bids=(),
|
|
292
|
+
bidder_index=0,
|
|
293
|
+
bidding_round=2,
|
|
294
|
+
turn=state.dealer.next_seat(),
|
|
295
|
+
)
|
|
296
|
+
else:
|
|
297
|
+
# All passed round 2 -> redeal
|
|
298
|
+
return replace(
|
|
299
|
+
state,
|
|
300
|
+
bids=(),
|
|
301
|
+
dealer=state.dealer.next_seat(),
|
|
302
|
+
phase=Phase.DEAL,
|
|
303
|
+
bidder_index=0,
|
|
304
|
+
bidding_round=1,
|
|
305
|
+
bid_suits=(),
|
|
306
|
+
)
|
|
307
|
+
next_bidder = get_bidder(state.dealer, next_index)
|
|
308
|
+
return replace(
|
|
309
|
+
state,
|
|
310
|
+
bids=new_bids,
|
|
311
|
+
turn=next_bidder,
|
|
312
|
+
bidder_index=next_index,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
# Trick play
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
def legal_cards(state: GameState, seat: Seat) -> tuple[Card, ...]:
|
|
321
|
+
"""Return the set of legal cards for the given seat to play."""
|
|
322
|
+
if state.phase != Phase.PLAYING or state.trump is None:
|
|
323
|
+
return state.hand_of(seat)
|
|
324
|
+
|
|
325
|
+
hand = state.hand_of(seat)
|
|
326
|
+
trick = state.current_trick
|
|
327
|
+
|
|
328
|
+
if not trick:
|
|
329
|
+
# Leading: any card is legal
|
|
330
|
+
return hand
|
|
331
|
+
|
|
332
|
+
lead_card = trick[0].card
|
|
333
|
+
lead_suit = lead_card.suit
|
|
334
|
+
trump = state.trump
|
|
335
|
+
|
|
336
|
+
# Check what cards have been played
|
|
337
|
+
played_suits = {tc.card.suit for tc in trick}
|
|
338
|
+
played_cards = [tc for tc in trick if tc.seat != seat]
|
|
339
|
+
|
|
340
|
+
# Determine who is currently winning the trick
|
|
341
|
+
current_winner = _current_trick_winner(played_cards, trump, lead_suit)
|
|
342
|
+
partner_winning = current_winner is not None and partner(seat) == current_winner
|
|
343
|
+
|
|
344
|
+
my_suit_cards = [c for c in hand if c.suit == lead_suit]
|
|
345
|
+
|
|
346
|
+
if lead_suit == trump:
|
|
347
|
+
# Trump led: must follow if possible, must overtrump if possible
|
|
348
|
+
if my_suit_cards:
|
|
349
|
+
highest_in_trick = max(trick_rank(tc.card, trump) for tc in played_cards)
|
|
350
|
+
must_overtrump = any(
|
|
351
|
+
trick_rank(c, trump) > highest_in_trick for c in my_suit_cards
|
|
352
|
+
)
|
|
353
|
+
if must_overtrump:
|
|
354
|
+
return tuple(c for c in my_suit_cards if trick_rank(c, trump) > highest_in_trick)
|
|
355
|
+
return tuple(my_suit_cards)
|
|
356
|
+
else:
|
|
357
|
+
# Void in trump (led suit) - can only play non-trump
|
|
358
|
+
return tuple(c for c in hand if c.suit != trump)
|
|
359
|
+
else:
|
|
360
|
+
# Non-trump led
|
|
361
|
+
if my_suit_cards:
|
|
362
|
+
# Must follow suit
|
|
363
|
+
return tuple(my_suit_cards)
|
|
364
|
+
else:
|
|
365
|
+
# Void in led suit
|
|
366
|
+
if partner_winning:
|
|
367
|
+
# Partner is winning - can discard
|
|
368
|
+
return hand
|
|
369
|
+
else:
|
|
370
|
+
# Must trump if possible
|
|
371
|
+
my_trumps = [c for c in hand if c.suit == trump]
|
|
372
|
+
if my_trumps:
|
|
373
|
+
# Must trump; also must overtrump if possible
|
|
374
|
+
highest_trump_in_trick = max(
|
|
375
|
+
(trick_rank(tc.card, trump) for tc in played_cards if tc.card.suit == trump),
|
|
376
|
+
default=-1,
|
|
377
|
+
)
|
|
378
|
+
can_overtrump = any(
|
|
379
|
+
trick_rank(c, trump) > highest_trump_in_trick for c in my_trumps
|
|
380
|
+
)
|
|
381
|
+
if can_overtrump:
|
|
382
|
+
return tuple(
|
|
383
|
+
c for c in my_trumps
|
|
384
|
+
if trick_rank(c, trump) > highest_trump_in_trick
|
|
385
|
+
)
|
|
386
|
+
return tuple(my_trumps)
|
|
387
|
+
else:
|
|
388
|
+
# Can't follow, can't trump - discard
|
|
389
|
+
return hand
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _current_trick_winner(
|
|
393
|
+
played: list[TrickCard], trump: Suit, lead_suit: Suit
|
|
394
|
+
) -> Seat | None:
|
|
395
|
+
"""Determine who is currently winning the trick based on cards played so far."""
|
|
396
|
+
if not played:
|
|
397
|
+
return None
|
|
398
|
+
best = played[0]
|
|
399
|
+
for tc in played[1:]:
|
|
400
|
+
if _card_beats(tc.card, best.card, trump, lead_suit):
|
|
401
|
+
best = tc
|
|
402
|
+
return best.seat
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _card_beats(card: Card, current_best: Card, trump: Suit, lead_suit: Suit) -> bool:
|
|
406
|
+
"""Does card beat current_best?"""
|
|
407
|
+
if lead_suit == trump:
|
|
408
|
+
# Trump led - highest trump wins
|
|
409
|
+
if card.suit == trump and current_best.suit == trump:
|
|
410
|
+
return trick_rank(card, trump) > trick_rank(current_best, trump)
|
|
411
|
+
if card.suit == trump:
|
|
412
|
+
return True
|
|
413
|
+
return False
|
|
414
|
+
else:
|
|
415
|
+
# Non-trump led
|
|
416
|
+
if card.suit == trump and current_best.suit == trump:
|
|
417
|
+
return trick_rank(card, trump) > trick_rank(current_best, trump)
|
|
418
|
+
if card.suit == trump and current_best.suit != trump:
|
|
419
|
+
return True
|
|
420
|
+
if card.suit != trump and current_best.suit == trump:
|
|
421
|
+
return False
|
|
422
|
+
if card.suit == lead_suit and current_best.suit == lead_suit:
|
|
423
|
+
return trick_rank(card, trump) > trick_rank(current_best, trump)
|
|
424
|
+
if card.suit == lead_suit:
|
|
425
|
+
return True
|
|
426
|
+
return False
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def trick_winner_seat(trick: tuple[TrickCard, ...], trump: Suit | None) -> Seat | None:
|
|
430
|
+
"""Determine the winner of a completed trick."""
|
|
431
|
+
if not trick or trump is None:
|
|
432
|
+
return None
|
|
433
|
+
lead_suit = trick[0].card.suit
|
|
434
|
+
winner = trick[0]
|
|
435
|
+
for tc in trick[1:]:
|
|
436
|
+
if _card_beats(tc.card, winner.card, trump, lead_suit):
|
|
437
|
+
winner = tc
|
|
438
|
+
return winner.seat
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def play_card(state: GameState, card: Card) -> GameState:
|
|
442
|
+
"""Play a card. Returns new state, possibly advancing trick/round/phase."""
|
|
443
|
+
hand = list(state.hand_of(state.turn))
|
|
444
|
+
if card not in hand:
|
|
445
|
+
raise ValueError(f"Card {card} not in hand of {state.turn.name}")
|
|
446
|
+
|
|
447
|
+
hand.remove(card)
|
|
448
|
+
new_hands = list(state.hands)
|
|
449
|
+
new_hands[state.turn.value] = tuple(hand)
|
|
450
|
+
|
|
451
|
+
new_trick = state.current_trick + (TrickCard(state.turn, card),)
|
|
452
|
+
|
|
453
|
+
# Check for belote/rebelote announcements
|
|
454
|
+
announced = state.announced
|
|
455
|
+
belote_tracker = list(state.belote_tracker)
|
|
456
|
+
if state.trump:
|
|
457
|
+
king_trump = Card(state.trump, __import__("deck", fromlist=["Rank"]).Rank.KING)
|
|
458
|
+
queen_trump = Card(state.trump, __import__("deck", fromlist=["Rank"]).Rank.QUEEN)
|
|
459
|
+
k_in_hand = king_trump in state.hand_of(state.turn)
|
|
460
|
+
q_in_hand = queen_trump in state.hand_of(state.turn)
|
|
461
|
+
played_k = card == king_trump
|
|
462
|
+
played_q = card == queen_trump
|
|
463
|
+
|
|
464
|
+
if (k_in_hand and q_in_hand):
|
|
465
|
+
if not belote_tracker[0] and (played_k or played_q):
|
|
466
|
+
belote_tracker[0] = True
|
|
467
|
+
announced = "Belote!"
|
|
468
|
+
elif belote_tracker[0] and not belote_tracker[1] and (played_k or played_q):
|
|
469
|
+
belote_tracker[1] = True
|
|
470
|
+
announced = "Rebelote!"
|
|
471
|
+
|
|
472
|
+
# Check if trick is complete (4 cards)
|
|
473
|
+
if len(new_trick) == 4:
|
|
474
|
+
winner = trick_winner_seat(new_trick, state.trump)
|
|
475
|
+
if winner is None:
|
|
476
|
+
winner = state.turn
|
|
477
|
+
|
|
478
|
+
new_completed = state.completed_tricks + (new_trick,)
|
|
479
|
+
tricks_count = len(new_completed)
|
|
480
|
+
|
|
481
|
+
# Check if round is complete (8 tricks)
|
|
482
|
+
if tricks_count >= 8:
|
|
483
|
+
# Round over
|
|
484
|
+
ns_round, ew_round = state.round_scores
|
|
485
|
+
return replace(
|
|
486
|
+
state,
|
|
487
|
+
hands=tuple(new_hands),
|
|
488
|
+
current_trick=(),
|
|
489
|
+
completed_tricks=new_completed,
|
|
490
|
+
last_trick_winner=winner,
|
|
491
|
+
leader=winner,
|
|
492
|
+
turn=winner,
|
|
493
|
+
phase=Phase.SCORING,
|
|
494
|
+
announced=announced,
|
|
495
|
+
belote_tracker=tuple(belote_tracker),
|
|
496
|
+
first_trick_done=True,
|
|
497
|
+
)
|
|
498
|
+
else:
|
|
499
|
+
# Next trick led by winner
|
|
500
|
+
first_trick_done = state.first_trick_done or tricks_count >= 1
|
|
501
|
+
return replace(
|
|
502
|
+
state,
|
|
503
|
+
hands=tuple(new_hands),
|
|
504
|
+
current_trick=(),
|
|
505
|
+
completed_tricks=new_completed,
|
|
506
|
+
last_trick_winner=winner,
|
|
507
|
+
leader=winner,
|
|
508
|
+
turn=winner,
|
|
509
|
+
phase=Phase.PLAYING,
|
|
510
|
+
announced=announced,
|
|
511
|
+
belote_tracker=tuple(belote_tracker),
|
|
512
|
+
first_trick_done=first_trick_done,
|
|
513
|
+
)
|
|
514
|
+
else:
|
|
515
|
+
# Next player in trick
|
|
516
|
+
next_turn = state.turn.next_seat()
|
|
517
|
+
return replace(
|
|
518
|
+
state,
|
|
519
|
+
hands=tuple(new_hands),
|
|
520
|
+
current_trick=new_trick,
|
|
521
|
+
turn=next_turn,
|
|
522
|
+
announced=announced,
|
|
523
|
+
belote_tracker=tuple(belote_tracker),
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def advance_turn(state: GameState) -> GameState:
|
|
528
|
+
"""Advance to the next player's turn (used after AI plays)."""
|
|
529
|
+
return replace(state, turn=state.turn.next_seat())
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def set_announced(state: GameState, msg: str) -> GameState:
|
|
533
|
+
return replace(state, announced=msg)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def clear_announced(state: GameState) -> GameState:
|
|
537
|
+
return replace(state, announced=None)
|