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