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/scoring.py ADDED
@@ -0,0 +1,488 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Final
5
+
6
+ from .deck import Card, Rank, Suit, card_points as card_points_fn
7
+ from .game import (
8
+ GameState,
9
+ Seat,
10
+ Sequence,
11
+ Carre,
12
+ BeloteDecl,
13
+ Declaration,
14
+ team_of,
15
+ partner,
16
+ trick_winner_seat,
17
+ )
18
+
19
+ # Rank numeric values for sequence detection (ascending order)
20
+ _RANK_VALUES: dict[Rank, int] = {
21
+ Rank.SEVEN: 1,
22
+ Rank.EIGHT: 2,
23
+ Rank.NINE: 3,
24
+ Rank.TEN: 4,
25
+ Rank.JACK: 5,
26
+ Rank.QUEEN: 6,
27
+ Rank.KING: 7,
28
+ Rank.ACE: 8,
29
+ }
30
+
31
+ # Carré point values
32
+ _CARRE_POINTS: dict[Rank, int] = {
33
+ Rank.JACK: 200,
34
+ Rank.NINE: 150,
35
+ Rank.ACE: 100,
36
+ Rank.TEN: 100,
37
+ Rank.KING: 100,
38
+ Rank.QUEEN: 100,
39
+ Rank.SEVEN: 0,
40
+ Rank.EIGHT: 0,
41
+ }
42
+
43
+ # Sequence point values
44
+ _SEQUENCE_POINTS: dict[int, int] = {
45
+ 3: 20,
46
+ 4: 50,
47
+ 5: 100,
48
+ }
49
+
50
+ BELOTE_POINTS: Final = 20
51
+ LAST_TRICK_BONUS: Final = 10
52
+ CAPOT_BASE: Final = 250
53
+ TOTAL_POINTS: Final = 162 # 152 card points + 10 last trick
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class ScoringBreakdown:
58
+ taker_team: int # 0 = NS, 1 = EW
59
+ taker_card_pts: int
60
+ defender_card_pts: int
61
+ raw_taker_card_pts: int # raw card points before failed-bid adjustment
62
+ raw_defender_card_pts: int # raw card points before failed-bid adjustment
63
+ last_trick_team: int | None # who got the +10
64
+ taker_declarations: int
65
+ defender_declarations: int
66
+ taker_belote: int
67
+ defender_belote: int
68
+ taker_total: int
69
+ defender_total: int
70
+ is_capot: bool
71
+ is_failed: bool
72
+ messages: tuple[str, ...]
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class ResolvedDeclarations:
77
+ ns_sequences: tuple[Sequence, ...]
78
+ ew_sequences: tuple[Sequence, ...]
79
+ ns_carres: tuple[Carre, ...]
80
+ ew_carres: tuple[Carre, ...]
81
+ ns_belote: bool
82
+ ew_belote: bool
83
+ scoring_team: int | None # which team scores sequences/carres (0=NS, 1=EW, None=cancel)
84
+
85
+
86
+ def detect_belote(hand: tuple[Card, ...], trump: Suit) -> bool:
87
+ """Check if hand contains both K and Q of trump."""
88
+ king = Card(trump, Rank.KING)
89
+ queen = Card(trump, Rank.QUEEN)
90
+ return king in hand and queen in hand
91
+
92
+
93
+ def detect_sequences(hand: tuple[Card, ...]) -> list[Sequence]:
94
+ """Find all maximal sequences (tierce/quarte/quinte) in a hand.
95
+
96
+ Sequences are based on rank order 7<8<9<10<J<Q<K<A within a single suit.
97
+ Only sequences of length >= 3 count.
98
+ """
99
+ # Group cards by suit
100
+ by_suit: dict[Suit, list[int]] = {}
101
+ for card in hand:
102
+ if card.suit not in by_suit:
103
+ by_suit[card.suit] = []
104
+ by_suit[card.suit].append(_RANK_VALUES[card.rank])
105
+
106
+ sequences: list[Sequence] = []
107
+ for suit, ranks in by_suit.items():
108
+ ranks.sort()
109
+ # Find consecutive runs
110
+ if not ranks:
111
+ continue
112
+ run_start = 0
113
+ for i in range(1, len(ranks)):
114
+ if ranks[i] != ranks[i - 1] + 1:
115
+ run_len = i - run_start
116
+ if run_len >= 3:
117
+ top = ranks[i - 1]
118
+ seq_cards = tuple(
119
+ c for c in hand
120
+ if c.suit == suit and _RANK_VALUES[c.rank] >= ranks[run_start]
121
+ and _RANK_VALUES[c.rank] <= ranks[i - 1]
122
+ )
123
+ sequences.append(Sequence(
124
+ length=run_len,
125
+ top_rank=top,
126
+ suit=suit,
127
+ is_trump=False, # will be updated later
128
+ cards=seq_cards,
129
+ ))
130
+ run_start = i
131
+ # Final run
132
+ run_len = len(ranks) - run_start
133
+ if run_len >= 3:
134
+ top = ranks[-1]
135
+ seq_cards = tuple(
136
+ c for c in hand
137
+ if c.suit == suit and _RANK_VALUES[c.rank] >= ranks[run_start]
138
+ and _RANK_VALUES[c.rank] <= ranks[-1]
139
+ )
140
+ sequences.append(Sequence(
141
+ length=run_len,
142
+ top_rank=top,
143
+ suit=suit,
144
+ is_trump=False,
145
+ cards=seq_cards,
146
+ ))
147
+ return sequences
148
+
149
+
150
+ def detect_carres(hand: tuple[Card, ...]) -> list[Carre]:
151
+ """Find all carrés (four of a kind by rank) in a hand."""
152
+ by_rank: dict[Rank, list[Card]] = {}
153
+ for card in hand:
154
+ if card.rank not in by_rank:
155
+ by_rank[card.rank] = []
156
+ by_rank[card.rank].append(card)
157
+
158
+ carres: list[Carre] = []
159
+ for rank, cards in by_rank.items():
160
+ if len(cards) == 4:
161
+ carres.append(Carre(
162
+ rank=_RANK_VALUES[rank],
163
+ cards=tuple(cards),
164
+ ))
165
+ return carres
166
+
167
+
168
+ def _sequence_strength(seq: Sequence) -> tuple[int, int, bool]:
169
+ """Comparable tuple for sequence priority: (length, top_rank, is_trump)."""
170
+ return (seq.length, seq.top_rank, seq.is_trump)
171
+
172
+
173
+ def _carre_strength(carre: Carre) -> tuple[int, int]:
174
+ """Comparable tuple for carré priority: (points, rank)."""
175
+ return (_CARRE_POINTS.get(
176
+ next(r for r in Rank if _RANK_VALUES[r] == carre.rank), 0
177
+ ), carre.rank)
178
+
179
+
180
+ def _best_sequence(sequences: list[Sequence]) -> Sequence | None:
181
+ if not sequences:
182
+ return None
183
+ return max(sequences, key=_sequence_strength)
184
+
185
+
186
+ def _best_carre(carres: list[Carre]) -> Carre | None:
187
+ if not carres:
188
+ return None
189
+ return max(carres, key=_carre_strength)
190
+
191
+
192
+ def _carre_points(carre: Carre) -> int:
193
+ rank = next(r for r in Rank if _RANK_VALUES[r] == carre.rank)
194
+ return _CARRE_POINTS[rank]
195
+
196
+
197
+ def _sequence_points(seq: Sequence) -> int:
198
+ return _SEQUENCE_POINTS.get(seq.length, 0)
199
+
200
+
201
+ def resolve_declarations(
202
+ decls_per_seat: dict[Seat, dict[str, object]],
203
+ trump: Suit,
204
+ ) -> ResolvedDeclarations:
205
+ """Resolve declarations per §3.7.
206
+
207
+ decls_per_seat maps each seat to a dict with keys:
208
+ 'sequences': list[Sequence]
209
+ 'carres': list[Carre]
210
+ 'belote': bool
211
+ """
212
+ ns_seqs: list[Sequence] = []
213
+ ew_seqs: list[Sequence] = []
214
+ ns_carres: list[Carre] = []
215
+ ew_carres: list[Carre] = []
216
+ ns_belote = False
217
+ ew_belote = False
218
+
219
+ for seat, decls in decls_per_seat.items():
220
+ seqs = [s for s in decls.get("sequences", [])]
221
+ updated_seqs = []
222
+ for s in seqs:
223
+ updated_seqs.append(Sequence(
224
+ length=s.length, top_rank=s.top_rank, suit=s.suit,
225
+ is_trump=(s.suit == trump), cards=s.cards,
226
+ ))
227
+ carres = list(decls.get("carres", []))
228
+ has_belote = decls.get("belote", False)
229
+
230
+ if team_of(seat) == 0:
231
+ ns_seqs.extend(updated_seqs)
232
+ ns_carres.extend(carres)
233
+ ns_belote = ns_belote or has_belote
234
+ else:
235
+ ew_seqs.extend(updated_seqs)
236
+ ew_carres.extend(carres)
237
+ ew_belote = ew_belote or has_belote
238
+
239
+ # Determine which team scores sequences/carres
240
+ ns_best_seq = _best_sequence(ns_seqs)
241
+ ew_best_seq = _best_sequence(ew_seqs)
242
+ ns_best_carre = _best_carre(ns_carres)
243
+ ew_best_carre = _best_carre(ew_carres)
244
+
245
+ scoring_team: int | None = None
246
+
247
+ # Carrés outrank any sequence
248
+ if ns_best_carre and ew_best_carre:
249
+ # Both have carrés - higher carré wins
250
+ ns_cp = _carre_points(ns_best_carre)
251
+ ew_cp = _carre_points(ew_best_carre)
252
+ if ns_cp > ew_cp:
253
+ scoring_team = 0
254
+ elif ew_cp > ns_cp:
255
+ scoring_team = 1
256
+ else:
257
+ # Same carré strength - compare rank
258
+ if ns_best_carre.rank > ew_best_carre.rank:
259
+ scoring_team = 0
260
+ elif ew_best_carre.rank > ns_best_carre.rank:
261
+ scoring_team = 1
262
+ else:
263
+ scoring_team = None # cancel
264
+ elif ns_best_carre:
265
+ scoring_team = 0
266
+ elif ew_best_carre:
267
+ scoring_team = 1
268
+ elif ns_best_seq and ew_best_seq:
269
+ ns_str = _sequence_strength(ns_best_seq)
270
+ ew_str = _sequence_strength(ew_best_seq)
271
+ if ns_str > ew_str:
272
+ scoring_team = 0
273
+ elif ew_str > ns_str:
274
+ scoring_team = 1
275
+ else:
276
+ scoring_team = None # cancel
277
+ elif ns_best_seq:
278
+ scoring_team = 0
279
+ elif ew_best_seq:
280
+ scoring_team = 1
281
+
282
+ return ResolvedDeclarations(
283
+ ns_sequences=tuple(ns_seqs),
284
+ ew_sequences=tuple(ew_seqs),
285
+ ns_carres=tuple(ns_carres),
286
+ ew_carres=tuple(ew_carres),
287
+ ns_belote=ns_belote,
288
+ ew_belote=ew_belote,
289
+ scoring_team=scoring_team,
290
+ )
291
+
292
+
293
+ def score_round(state: GameState) -> ScoringBreakdown:
294
+ """Score the completed round per §3.8."""
295
+ if state.trump is None or state.taker is None:
296
+ return ScoringBreakdown(
297
+ taker_team=team_of(Seat.SOUTH),
298
+ taker_card_pts=0, defender_card_pts=0,
299
+ raw_taker_card_pts=0, raw_defender_card_pts=0,
300
+ last_trick_team=None,
301
+ taker_declarations=0, defender_declarations=0,
302
+ taker_belote=0, defender_belote=0,
303
+ taker_total=0, defender_total=0,
304
+ is_capot=False, is_failed=False,
305
+ messages=(),
306
+ )
307
+
308
+ trump = state.trump
309
+ taker_team = team_of(state.taker)
310
+ defender_team = 1 - taker_team
311
+
312
+ # Calculate card points per team from completed tricks
313
+ taker_card_pts = 0
314
+ defender_card_pts = 0
315
+ for trick in state.completed_tricks:
316
+ winner = trick_winner_seat(trick, trump)
317
+ if winner is None:
318
+ continue
319
+ trick_pts = sum(card_points_fn(tc.card, trump) for tc in trick)
320
+ if team_of(winner) == taker_team:
321
+ taker_card_pts += trick_pts
322
+ else:
323
+ defender_card_pts += trick_pts
324
+
325
+ # Last trick bonus
326
+ last_trick_winner = state.last_trick_winner
327
+ last_trick_team: int | None = None
328
+ if last_trick_winner is not None:
329
+ last_trick_team = team_of(last_trick_winner)
330
+ if last_trick_team == taker_team:
331
+ taker_card_pts += LAST_TRICK_BONUS
332
+ else:
333
+ defender_card_pts += LAST_TRICK_BONUS
334
+
335
+ # --- Detect declarations from reconstructed initial hands ---
336
+ # Reconstruct each player's initial hand from all cards they played
337
+ hands_by_seat: dict[Seat, list[Card]] = {s: [] for s in Seat}
338
+ for trick in state.completed_tricks:
339
+ for tc in trick:
340
+ hands_by_seat[tc.seat].append(tc.card)
341
+ for tc in state.current_trick:
342
+ hands_by_seat[tc.seat].append(tc.card)
343
+ initial_hands = {s: tuple(cards) for s, cards in hands_by_seat.items()}
344
+
345
+ # Detect sequences, carres, and belote for each seat
346
+ decls_per_seat: dict[Seat, dict[str, object]] = {}
347
+ for seat in Seat:
348
+ hand = initial_hands[seat]
349
+ seqs = detect_sequences(hand)
350
+ carres = detect_carres(hand)
351
+ has_belote = detect_belote(hand, trump)
352
+ decls_per_seat[seat] = {
353
+ "sequences": seqs,
354
+ "carres": carres,
355
+ "belote": has_belote,
356
+ }
357
+
358
+ resolved = resolve_declarations(decls_per_seat, trump)
359
+
360
+ # Compute belote points per team
361
+ taker_has_belote = any(
362
+ decls_per_seat[s].get("belote", False)
363
+ for s in Seat if team_of(s) == taker_team
364
+ )
365
+ defender_has_belote = any(
366
+ decls_per_seat[s].get("belote", False)
367
+ for s in Seat if team_of(s) == defender_team
368
+ )
369
+ taker_belote = BELOTE_POINTS if taker_has_belote else 0
370
+ defender_belote = BELOTE_POINTS if defender_has_belote else 0
371
+
372
+ # Compute declaration points (sequences + carres) per team
373
+ scoring_team = resolved.scoring_team
374
+ taker_declarations = 0
375
+ defender_declarations = 0
376
+
377
+ if scoring_team is not None:
378
+ # Only the winning team scores their sequences and carres
379
+ if scoring_team == taker_team:
380
+ for seq in resolved.ns_sequences if taker_team == 0 else resolved.ew_sequences:
381
+ taker_declarations += _sequence_points(seq)
382
+ for carre in resolved.ns_carres if taker_team == 0 else resolved.ew_carres:
383
+ taker_declarations += _carre_points(carre)
384
+ else:
385
+ for seq in resolved.ns_sequences if defender_team == 0 else resolved.ew_sequences:
386
+ defender_declarations += _sequence_points(seq)
387
+ for carre in resolved.ns_carres if defender_team == 0 else resolved.ew_carres:
388
+ defender_declarations += _carre_points(carre)
389
+
390
+ # Check capot: taker won all 8 tricks
391
+ is_capot = all(
392
+ trick_winner_seat(trick, trump) is not None and
393
+ team_of(trick_winner_seat(trick, trump)) == taker_team
394
+ for trick in state.completed_tricks
395
+ ) if state.completed_tricks else False
396
+
397
+ messages: list[str] = []
398
+
399
+ if is_capot:
400
+ messages.append("Capot!")
401
+ taker_total = CAPOT_BASE + taker_declarations + taker_belote
402
+ defender_total = defender_belote
403
+ return ScoringBreakdown(
404
+ taker_team=taker_team,
405
+ taker_card_pts=0, defender_card_pts=0,
406
+ raw_taker_card_pts=taker_card_pts, raw_defender_card_pts=defender_card_pts,
407
+ last_trick_team=last_trick_team,
408
+ taker_declarations=taker_declarations,
409
+ defender_declarations=defender_declarations,
410
+ taker_belote=taker_belote,
411
+ defender_belote=defender_belote,
412
+ taker_total=taker_total,
413
+ defender_total=defender_total,
414
+ is_capot=True, is_failed=False,
415
+ messages=tuple(messages),
416
+ )
417
+
418
+ # Check bid failure
419
+ is_failed = taker_card_pts < defender_card_pts
420
+
421
+ if is_failed:
422
+ messages.append("Chute! (bid failed)")
423
+ taker_total = taker_belote
424
+ defender_total = TOTAL_POINTS + defender_declarations + defender_belote + taker_declarations
425
+ else:
426
+ taker_total = taker_card_pts + taker_declarations + taker_belote
427
+ defender_total = defender_card_pts + defender_declarations + defender_belote
428
+
429
+ return ScoringBreakdown(
430
+ taker_team=taker_team,
431
+ taker_card_pts=taker_card_pts if not is_failed else 0,
432
+ defender_card_pts=defender_card_pts if not is_failed else 0,
433
+ raw_taker_card_pts=taker_card_pts,
434
+ raw_defender_card_pts=defender_card_pts,
435
+ last_trick_team=last_trick_team,
436
+ taker_declarations=taker_declarations,
437
+ defender_declarations=defender_declarations,
438
+ taker_belote=taker_belote,
439
+ defender_belote=defender_belote,
440
+ taker_total=taker_total,
441
+ defender_total=defender_total,
442
+ is_capot=False, is_failed=is_failed,
443
+ messages=tuple(messages),
444
+ )
445
+
446
+
447
+ def apply_round_score(state: GameState, breakdown: ScoringBreakdown) -> GameState:
448
+ """Apply round scoring result to team scores and advance state."""
449
+ ns, ew = state.team_scores
450
+ if breakdown.taker_team == 0:
451
+ ns += breakdown.taker_total
452
+ ew += breakdown.defender_total
453
+ else:
454
+ ew += breakdown.taker_total
455
+ ns += breakdown.defender_total
456
+
457
+ new_scores = (ns, ew)
458
+
459
+ # Determine if game is over
460
+ if ns >= state.target or ew >= state.target:
461
+ from .game import Phase as P
462
+ phase = P.GAME_OVER
463
+ else:
464
+ from .game import Phase as P
465
+ phase = P.DEAL
466
+
467
+ # Rotate dealer
468
+ from .game import replace as dataclass_replace
469
+ return dataclass_replace(
470
+ state,
471
+ team_scores=new_scores,
472
+ dealer=state.dealer.next_seat(),
473
+ phase=phase,
474
+ trump=None,
475
+ taker=None,
476
+ current_trick=(),
477
+ completed_tricks=(),
478
+ last_trick_winner=None,
479
+ bids=(),
480
+ bidder_index=0,
481
+ bid_suits=(),
482
+ round_scores=(0, 0),
483
+ declarations=(),
484
+ declarations_resolved=False,
485
+ announced=None,
486
+ belote_tracker=(False, False),
487
+ first_trick_done=False,
488
+ )