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 ADDED
File without changes
belote/ai.py ADDED
@@ -0,0 +1,412 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+
7
+ from .deck import Card, Rank, Suit, trick_rank, card_points as card_points_fn
8
+ from .game import (
9
+ GameState,
10
+ Seat,
11
+ Phase,
12
+ legal_cards,
13
+ team_of,
14
+ partner,
15
+ )
16
+
17
+
18
+ class Difficulty(Enum):
19
+ EASY = "easy"
20
+ MEDIUM = "medium"
21
+ HARD = "hard"
22
+
23
+
24
+ class AIMemory:
25
+ """Tracks information about played cards and inferred voids."""
26
+
27
+ def __init__(self) -> None:
28
+ self.played: set[Card] = set()
29
+ self.known_voids: dict[Seat, set[Suit]] = {}
30
+ self.partner_hand: set[Card] = set()
31
+
32
+
33
+ class AIPlayer:
34
+ def __init__(self, seat: Seat, difficulty: Difficulty = Difficulty.MEDIUM) -> None:
35
+ self.seat = seat
36
+ self.difficulty = difficulty
37
+ self.memory = AIMemory()
38
+ self._rng = random.Random()
39
+
40
+ def update_memory(self, state: GameState) -> None:
41
+ """Update memory with currently visible information."""
42
+ # Track all cards in completed tricks
43
+ for trick in state.completed_tricks:
44
+ for tc in trick:
45
+ self.memory.played.add(tc.card)
46
+ for tc in state.current_trick:
47
+ self.memory.played.add(tc.card)
48
+
49
+ # Partner's hand is visible (for NS team, South sees North's plays)
50
+ # In this implementation, AI tracks what it can see
51
+ p = partner(self.seat)
52
+ if state.phase in (Phase.PLAYING, Phase.SCORING):
53
+ # Partner's remaining cards
54
+ for card in state.hand_of(p):
55
+ self.memory.partner_hand.add(card)
56
+
57
+ def decide_bid(self, state: GameState) -> Suit | None:
58
+ """Decide whether to bid and which suit."""
59
+ hand = state.hand_of(self.seat)
60
+
61
+ if self.difficulty == Difficulty.EASY:
62
+ bid = self._easy_bid(hand)
63
+ elif self.difficulty == Difficulty.MEDIUM:
64
+ bid = self._medium_bid(hand, state)
65
+ else:
66
+ bid = self._hard_bid(hand, state)
67
+
68
+ # Respect bidding round rules
69
+ if state.bidding_round == 1:
70
+ # Round 1: only take the up-card's suit
71
+ if bid == state.up_card.suit: # type: ignore[union-attr]
72
+ return bid
73
+ return None
74
+ else:
75
+ # Round 2: pick any suit except the one from Round 1
76
+ if bid == state.up_card.suit: # type: ignore[union-attr]
77
+ return None
78
+ return bid
79
+
80
+ def decide_card(self, state: GameState) -> Card:
81
+ """Decide which card to play."""
82
+ hand = state.hand_of(self.seat)
83
+ legal = legal_cards(state, self.seat)
84
+
85
+ if not hand:
86
+ # Should never happen — game state is invalid if hand is empty mid-play.
87
+ raise ValueError(
88
+ f"AI {self.seat.name}: hand is empty during PLAYING phase — "
89
+ "likely a deal bug (check deck.deal gives 8 cards per player)."
90
+ )
91
+ if not legal:
92
+ # Defensive: legal_cards should never be empty mid-play.
93
+ # Fall back to full hand rather than constructing a phantom card.
94
+ # The old fallback Card(Suit.SPADES, Rank.ACE) caused crashes when
95
+ # that card wasn't in the player's hand.
96
+ legal = hand
97
+
98
+ if self.difficulty == Difficulty.EASY:
99
+ return self._easy_play(state, legal)
100
+ elif self.difficulty == Difficulty.MEDIUM:
101
+ return self._medium_play(state, legal)
102
+ else:
103
+ return self._hard_play(state, legal)
104
+
105
+ # ---- Easy AI ----
106
+
107
+ def _easy_bid(self, hand: tuple[Card, ...]) -> Suit | None:
108
+ """Bid if hand has >= 2 trump honors (J, 9, A) in any suit."""
109
+ honors = {Rank.JACK, Rank.NINE, Rank.ACE}
110
+ for suit in Suit:
111
+ count = sum(1 for c in hand if c.suit == suit and c.rank in honors)
112
+ if count >= 2:
113
+ return suit
114
+ return None
115
+
116
+ def _easy_play(self, state: GameState, legal: tuple[Card, ...]) -> Card:
117
+ """Uniform random over legal moves."""
118
+ return self._rng.choice(legal)
119
+
120
+ # ---- Medium AI ----
121
+
122
+ def _medium_bid(self, hand: tuple[Card, ...], state: GameState) -> Suit | None:
123
+ """Heuristic score per suit."""
124
+ suit_scores: dict[Suit, int] = {s: 0 for s in Suit}
125
+ honor_values = {
126
+ Rank.JACK: 4,
127
+ Rank.NINE: 3,
128
+ Rank.ACE: 2,
129
+ Rank.KING: 1,
130
+ Rank.QUEEN: 1,
131
+ }
132
+
133
+ for card in hand:
134
+ if card.rank in honor_values:
135
+ suit_scores[card.suit] += honor_values[card.rank]
136
+ # Count cards in suit
137
+ suit_scores[card.suit] += 0.1
138
+
139
+ # Void/singleton bonuses for potential discards
140
+ for suit in Suit:
141
+ count = sum(1 for c in hand if c.suit == suit)
142
+ if count == 0:
143
+ for other in Suit:
144
+ if other != suit:
145
+ suit_scores[other] += 0.5
146
+
147
+ best_suit = max(Suit, key=lambda s: suit_scores[s])
148
+ if suit_scores[best_suit] >= 4:
149
+ return best_suit
150
+ return None
151
+
152
+ def _medium_play(self, state: GameState, legal: tuple[Card, ...]) -> Card:
153
+ """Strategic play: lead high, cover partner, duck when winning."""
154
+ trump = state.trump
155
+ trick = state.current_trick
156
+
157
+ if not trick:
158
+ # Leading
159
+ return self._medium_lead(legal, trump)
160
+
161
+ lead_card = trick[0].card
162
+ lead_suit = lead_card.suit
163
+ p = partner(self.seat)
164
+
165
+ # Check if partner is winning
166
+ from .game import _current_trick_winner
167
+ current_winner = _current_trick_winner(
168
+ [tc for tc in trick if tc.seat != self.seat], trump, lead_suit
169
+ )
170
+ partner_winning = current_winner is not None and current_winner == p
171
+
172
+ if partner_winning and lead_suit != trump:
173
+ # Partner winning, discard low
174
+ return min(legal, key=lambda c: trick_rank(c, trump))
175
+
176
+ if lead_suit == trump:
177
+ # Trump led
178
+ my_trumps = [c for c in legal if c.suit == trump]
179
+ if my_trumps:
180
+ if current_winner is not None and current_winner == p:
181
+ # Partner winning trump, play lowest trump
182
+ return min(my_trumps, key=lambda c: trick_rank(c, trump))
183
+ else:
184
+ # Try to win if we can afford it
185
+ return max(my_trumps, key=lambda c: trick_rank(c, trump))
186
+ # No trumps, discard low non-trump
187
+ return min(legal, key=lambda c: card_points_fn(c, trump))
188
+
189
+ # Non-trump led
190
+ my_suit = [c for c in legal if c.suit == lead_suit]
191
+ if my_suit:
192
+ # Must follow
193
+ if current_winner is not None and current_winner == p:
194
+ return min(my_suit, key=lambda c: trick_rank(c, trump))
195
+ else:
196
+ return max(my_suit, key=lambda c: trick_rank(c, trump))
197
+
198
+ # Void - must trump or discard
199
+ my_trumps = [c for c in legal if c.suit == trump]
200
+ if my_trumps:
201
+ return max(my_trumps, key=lambda c: trick_rank(c, trump))
202
+
203
+ return min(legal, key=lambda c: card_points_fn(c, trump))
204
+
205
+ def _medium_lead(self, legal: tuple[Card, ...], trump: Suit | None) -> Card:
206
+ """Lead strategy: high non-trump A first, then trump pulls."""
207
+ if not trump:
208
+ return legal[0]
209
+
210
+ # Try to lead Ace of non-trump suit
211
+ for card in legal:
212
+ if card.rank == Rank.ACE and card.suit != trump:
213
+ return card
214
+
215
+ # Lead lowest trump to pull
216
+ trumps = [c for c in legal if c.suit == trump]
217
+ if trumps:
218
+ return min(trumps, key=lambda c: trick_rank(c, trump))
219
+
220
+ # Lead lowest non-trump
221
+ non_trumps = [c for c in legal if c.suit != trump]
222
+ if non_trumps:
223
+ return min(non_trumps, key=lambda c: trick_rank(c, trump))
224
+
225
+ return legal[0]
226
+
227
+ # ---- Hard AI ----
228
+
229
+ def _hard_bid(self, hand: tuple[Card, ...], state: GameState) -> Suit | None:
230
+ """Monte-Carlo-lite bidding evaluation."""
231
+ suit_scores: dict[Suit, float] = {s: 0.0 for s in Suit}
232
+
233
+ for suit in Suit:
234
+ # Count honors and cards in suit
235
+ trump_cards = [c for c in hand if c.suit == suit]
236
+ honor_count = sum(1 for c in trump_cards if c.rank in (Rank.JACK, Rank.NINE, Rank.ACE))
237
+ point_total = sum(card_points_fn(c, suit) for c in trump_cards)
238
+
239
+ # Base score from points
240
+ suit_scores[suit] = point_total * 0.5 + honor_count * 3
241
+
242
+ # Dealer position bonus
243
+ if state.dealer == self.seat or state.dealer == partner(self.seat):
244
+ suit_scores[suit] *= 1.1
245
+
246
+ # Void bonuses
247
+ for other in Suit:
248
+ if other != suit:
249
+ other_count = sum(1 for c in hand if c.suit == other)
250
+ if other_count == 0:
251
+ suit_scores[suit] += 2
252
+ elif other_count == 1:
253
+ suit_scores[suit] += 1
254
+
255
+ best_suit = max(Suit, key=lambda s: suit_scores[s])
256
+ if suit_scores[best_suit] >= 6:
257
+ return best_suit
258
+ return None
259
+
260
+ def _hard_play(self, state: GameState, legal: tuple[Card, ...]) -> Card:
261
+ """1-ply lookahead with void inference."""
262
+ trump = state.trump
263
+ trick = state.current_trick
264
+
265
+ if not trump:
266
+ return legal[0]
267
+
268
+ # Update void inferences from completed tricks
269
+ self._update_voids(state)
270
+
271
+ if not trick:
272
+ return self._hard_lead(legal, trump, state)
273
+
274
+ lead_suit = trick[0].card.suit
275
+ p = partner(self.seat)
276
+
277
+ from .game import _current_trick_winner
278
+ current_winner = _current_trick_winner(
279
+ [tc for tc in trick if tc.seat != self.seat], trump, lead_suit
280
+ )
281
+ partner_winning = current_winner is not None and current_winner == p
282
+
283
+ # Score each legal card by expected outcome
284
+ best_card = legal[0]
285
+ best_score = -999
286
+
287
+ for card in legal:
288
+ score = self._score_card_play(card, state, trump, trick, partner_winning)
289
+ if score > best_score:
290
+ best_score = score
291
+ best_card = card
292
+
293
+ return best_card
294
+
295
+ def _score_card_play(
296
+ self,
297
+ card: Card,
298
+ state: GameState,
299
+ trump: Suit,
300
+ trick: tuple,
301
+ partner_winning: bool,
302
+ ) -> float:
303
+ """Score a card play decision."""
304
+ score = 0.0
305
+ points = card_points_fn(card, trump)
306
+ rank = trick_rank(card, trump)
307
+
308
+ # Base: card point value
309
+ score += points * 0.1
310
+
311
+ if not trick:
312
+ # Leading
313
+ if card.suit == trump:
314
+ # Leading trump is generally good for pulling
315
+ score += 2
316
+ elif card.rank == Rank.ACE:
317
+ score += 3
318
+ elif points == 0:
319
+ # Leading waste card
320
+ score += 1
321
+ return score
322
+
323
+ lead_suit = trick[0].card.suit
324
+
325
+ if partner_winning and lead_suit != trump:
326
+ # Partner winning - discard low value cards
327
+ score -= points * 0.5
328
+ if card.suit != trump:
329
+ score += 1
330
+ return score
331
+
332
+ # Try to win the trick
333
+ highest_rank = max(trick_rank(tc.card, trump) for tc in trick)
334
+ if rank > highest_rank:
335
+ score += 10 # Winning is valuable
336
+
337
+ # Trump considerations
338
+ if card.suit == trump:
339
+ if lead_suit != trump:
340
+ # Trumping a non-trump trick
341
+ trump_in_trick = [tc for tc in trick if tc.card.suit == trump]
342
+ if trump_in_trick:
343
+ highest_trump = max(trick_rank(tc.card, trump) for tc in trump_in_trick)
344
+ if rank > highest_trump:
345
+ score += 5 # Overtrumping
346
+ else:
347
+ score -= 3 # Wasting a trump
348
+ else:
349
+ score += 4 # First trump in trick
350
+ else:
351
+ # Trump led
352
+ if rank > highest_rank:
353
+ score += 5
354
+ else:
355
+ score -= 2
356
+
357
+ # Don't waste high-point cards unnecessarily
358
+ if points >= 10 and rank <= highest_rank:
359
+ score -= 5
360
+
361
+ # Strand opponent honors: if we know opponent is void in lead suit,
362
+ # playing a low card of lead suit forces them to trump or discard
363
+ opp = self.seat.next_seat()
364
+ if opp in self.memory.known_voids:
365
+ voids = self.memory.known_voids[opp]
366
+ if lead_suit in voids and card.suit == lead_suit:
367
+ score += 3
368
+
369
+ return score
370
+
371
+ def _hard_lead(
372
+ self, legal: tuple[Card, ...], trump: Suit, state: GameState
373
+ ) -> Card:
374
+ """Strategic lead with void awareness."""
375
+ # Prefer leading suit where opponent is known void
376
+ for card in legal:
377
+ if card.suit != trump:
378
+ opp = self.seat.next_seat()
379
+ if opp in self.memory.known_voids and card.suit in self.memory.known_voids[opp]:
380
+ return card
381
+
382
+ # Lead Ace of non-trump
383
+ for card in legal:
384
+ if card.rank == Rank.ACE and card.suit != trump:
385
+ return card
386
+
387
+ # Lead low trump for pulling
388
+ trumps = [c for c in legal if c.suit == trump]
389
+ if trumps:
390
+ return min(trumps, key=lambda c: trick_rank(c, trump))
391
+
392
+ # Lead lowest non-trump
393
+ non_trumps = [c for c in legal if c.suit != trump]
394
+ if non_trumps:
395
+ return min(non_trumps, key=lambda c: trick_rank(c, trump))
396
+
397
+ return legal[0]
398
+
399
+ def _update_voids(self, state: GameState) -> None:
400
+ """Infer voids from played cards."""
401
+ for seat in Seat:
402
+ if seat not in self.memory.known_voids:
403
+ self.memory.known_voids[seat] = set()
404
+
405
+ for trick in state.completed_tricks:
406
+ if len(trick) < 2:
407
+ continue
408
+ lead_suit = trick[0].card.suit
409
+ for tc in trick[1:]:
410
+ if tc.card.suit != lead_suit:
411
+ # This player didn't follow suit - likely void
412
+ self.memory.known_voids[tc.seat].add(lead_suit)
belote/ansi.py ADDED
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ _RESET_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
6
+
7
+ RESET = "\x1b[0m"
8
+
9
+
10
+ def visible_len(s: str) -> int:
11
+ """Return length of string with ANSI escape codes stripped."""
12
+ return len(_RESET_RE.sub("", s))
13
+
14
+
15
+ def ansi_center(s: str, width: int) -> str:
16
+ """Center a string by visible width, ignoring ANSI escape codes."""
17
+ vlen = visible_len(s)
18
+ pad = max(0, width - vlen)
19
+ left = pad // 2
20
+ return " " * left + s
21
+
22
+
23
+ def ansi_ljust(s: str, width: int) -> str:
24
+ """Left-justify a string to `width` visible characters, ANSI-aware.
25
+ Never use str.ljust() on ANSI strings — it counts escape bytes as chars.
26
+ """
27
+ vlen = visible_len(s)
28
+ pad = max(0, width - vlen)
29
+ return s + " " * pad
30
+
31
+
32
+ def fg(r: int, g: int, b: int) -> str:
33
+ return f"\x1b[38;2;{r};{g};{b}m"
34
+
35
+
36
+ def bg(r: int, g: int, b: int) -> str:
37
+ return f"\x1b[48;2;{r};{g};{b}m"
38
+
39
+
40
+ BOLD = "\x1b[1m"
41
+ DIM = "\x1b[2m"
42
+ REVERSE = "\x1b[7m"
43
+ UNDERLINE = "\x1b[4m"
44
+ STRIKETHROUGH = "\x1b[9m"
45
+
46
+
47
+ def move(row: int, col: int) -> str:
48
+ return f"\x1b[{row};{col}H"
49
+
50
+
51
+ def clear_screen() -> str:
52
+ return "\x1b[2J" + move(1, 1)
53
+
54
+
55
+ def clear_line() -> str:
56
+ return "\x1b[2K"
57
+
58
+
59
+ def clear_to_eol() -> str:
60
+ return "\x1b[K"
61
+
62
+
63
+ def hide_cursor() -> str:
64
+ return "\x1b[?25l"
65
+
66
+
67
+ def show_cursor() -> str:
68
+ return "\x1b[?25h"
69
+
70
+
71
+ def alt_screen_on() -> str:
72
+ return "\x1b[?1049h"
73
+
74
+
75
+ def alt_screen_off() -> str:
76
+ return "\x1b[?1049l"
77
+
78
+
79
+ def save_cursor() -> str:
80
+ return "\x1b7"
81
+
82
+
83
+ def restore_cursor() -> str:
84
+ return "\x1b8"
85
+
86
+
87
+ def scroll_region(top: int, bottom: int) -> str:
88
+ return f"\x1b[{top};{bottom}r"
89
+
90
+
91
+ # Palette constants
92
+ def felt_bg() -> str:
93
+ return bg(20, 90, 50)
94
+
95
+
96
+ def red_fg() -> str:
97
+ return fg(220, 60, 60)
98
+
99
+
100
+ def black_fg() -> str:
101
+ return fg(20, 20, 20)
102
+
103
+
104
+ def card_face_bg() -> str:
105
+ return bg(245, 245, 235)
106
+
107
+
108
+ def face_card_bg() -> str:
109
+ return bg(255, 245, 180)
110
+
111
+
112
+ def card_back_bg() -> str:
113
+ return bg(120, 30, 30)
114
+
115
+
116
+ def highlight_bg() -> str:
117
+ return bg(240, 200, 80)
118
+
119
+
120
+ def gold_fg() -> str:
121
+ return fg(240, 200, 80)
122
+
123
+
124
+ def white_fg() -> str:
125
+ return fg(240, 240, 240)
126
+
127
+
128
+ def light_gray_fg() -> str:
129
+ return fg(180, 180, 180)
130
+
131
+
132
+ def green_fg() -> str:
133
+ return fg(80, 220, 120)
134
+
135
+
136
+ def banner_bg() -> str:
137
+ return bg(60, 60, 180)
138
+
139
+
140
+ def banner_fg() -> str:
141
+ return fg(255, 255, 100)
belote/bidding.py ADDED
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from .deck import Card, Suit
4
+ from .game import (
5
+ GameState,
6
+ Phase,
7
+ Seat,
8
+ get_bidder,
9
+ place_bid,
10
+ play_card,
11
+ legal_cards,
12
+ trick_winner_seat,
13
+ )
14
+
15
+
16
+ def bidding_turn(state: GameState) -> Seat:
17
+ """Return the seat whose turn it is to bid."""
18
+ return get_bidder(state.dealer, state.bidder_index)
19
+
20
+
21
+ def process_bid(state: GameState, bid: Suit | None) -> GameState:
22
+ """Process a bid and return the new state."""
23
+ return place_bid(state, bid)
24
+
25
+
26
+ def run_bidding_round(
27
+ state: GameState,
28
+ bid_decisions: dict[Seat, Suit | None],
29
+ ) -> GameState:
30
+ """Run through all bidding turns using pre-decided bids."""
31
+ current = state
32
+ while current.phase == Phase.BIDDING:
33
+ bidder = bidding_turn(current)
34
+ bid = bid_decisions.get(bidder)
35
+ if bid is None and bidder not in bid_decisions:
36
+ break
37
+ current = process_bid(current, bid_decisions.get(bidder))
38
+ return current
39
+
40
+
41
+ def run_play_turn(state: GameState, card: Card) -> GameState:
42
+ """Play a single card and advance state."""
43
+ return play_card(state, card)
44
+
45
+
46
+ def is_round_complete(state: GameState) -> bool:
47
+ """Check if the current round's play phase is complete."""
48
+ return len(state.completed_tricks) >= 8
49
+
50
+
51
+ def is_game_over(state: GameState) -> bool:
52
+ """Check if the game has ended (someone reached target score)."""
53
+ ns, ew = state.team_scores
54
+ return ns >= state.target or ew >= state.target
55
+
56
+
57
+ def winning_team(state: GameState) -> int | None:
58
+ """Return 0 (NS) or 1 (EW) if game is over, else None."""
59
+ ns, ew = state.team_scores
60
+ if ns >= state.target and ew >= state.target:
61
+ # Both crossed - taker wins if they succeeded, defenders if they failed
62
+ # Simplified: higher score wins
63
+ return 0 if ns >= ew else 1
64
+ if ns >= state.target:
65
+ return 0
66
+ if ew >= state.target:
67
+ return 1
68
+ return None