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/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)