Coverage for src / beaverbunch / core / game.py: 100.0%
178 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 19:37 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 19:37 +0000
1from dataclasses import dataclass, field
2from enum import Enum, auto
4from beaverbunch.core.actions import (
5 DiscardDrawnAction,
6 PeekOwnCardAction,
7 ReplaceCardAction,
8 SnapAction,
9 SwapCardAction,
10)
11from beaverbunch.core.card import Card, FaceCard
12from beaverbunch.core.deck import Deck
13from beaverbunch.core.game_settings import GameSettings
14from beaverbunch.core.hand import CardSlot, Hand
15from beaverbunch.core.player import Player
16from beaverbunch.core.rules import (
17 RuleViolation, calculate_scores, determine_winner, validate_beaver, validate_discard_drawn, validate_draw,
18 validate_draw_from_discard, validate_initial_peek, validate_peek, validate_replace_card, validate_skip_bonus,
19 validate_snap, validate_start, validate_swap,
20)
23class GamePhase(Enum):
24 STARTING = auto()
26 WAITING_FOR_DRAW = auto()
27 WAITING_FOR_ACTION = auto()
28 WAITING_FOR_BONUS = auto()
30 LAST_ROUND = auto()
31 FINISHED = auto()
34@dataclass
35class GameState:
36 """
37 Represents the current state of the game, including players, turn order, and phase.
39 Attributes:
40 phase (GamePhase): Current phase of the game.
41 turn_count (int): Number of turns that have been completed.
42 deck (Deck): The main deck of cards for the game.
43 discard_pile (list): List of cards that have been discarded.
44 players (list): List of players in the game.
45 drawn_card (Card | None): The card currently drawn by the active player.
46 current_player_index (int): Index of the current player in the players list.
47 pending_bonus (FaceCard | None): The bonus action waiting to be resolved.
48 last_round_starter (int | None): Index of the player who triggered the last round.
49 initial_peeks_done (set): Set of player names who have completed their initial peek.
50 """
51 phase: GamePhase = GamePhase.STARTING
52 turn_count: int = 0
54 deck: Deck = field(default_factory=Deck)
55 discard_pile: list[Card] = field(default_factory=list)
57 players: list[Player] = field(default_factory=list)
58 drawn_card: Card | None = None
59 current_player_index: int = 0
61 pending_bonus: FaceCard | None = None
62 last_round_starter: int | None = None
63 initial_peeks_done: set[str] = field(default_factory=set)
65 def add_player(self, player: Player) -> None:
66 """Adds a player to the game."""
67 self.players.append(player)
69 def next_player(self) -> None:
70 """Advances to the next player's turn and updates the turn count."""
71 self.current_player_index = (self.current_player_index + 1) % len(self.players)
72 self.turn_count += 1
74 def is_game_over(self) -> bool:
75 """Checks if the game has reached the finished phase."""
76 return self.phase == GamePhase.FINISHED
78 def set_phase(self, new_phase: GamePhase) -> None:
79 """Updates the current phase of the game."""
80 self.phase = new_phase
82 @property
83 def current_player(self) -> Player:
84 """Returns the player whose turn it currently is."""
85 return self.players[self.current_player_index]
87 @property
88 def top_discard(self) -> Card | None:
89 """Returns the top card of the discard pile, or None if empty."""
90 return self.discard_pile[-1] if self.discard_pile else None
93class Game:
94 """
95 Main game engine implementing the Beaverbunch state machine.
97 State transitions:
98 STARTING -> start() -> STARTING (waiting for peeks)
99 -> peek_initial() (all done) -> WAITING_FOR_DRAW
100 WAITING_FOR_DRAW -> snap() -> WAITING_FOR_DRAW (any player)
101 -> draw_card() / draw_from_discard() -> WAITING_FOR_ACTION
102 WAITING_FOR_ACTION -> keep_drawn_card() -> WAITING_FOR_DRAW / LAST_ROUND / FINISHED
103 -> discard_drawn_card() -> WAITING_FOR_BONUS / WAITING_FOR_DRAW
104 WAITING_FOR_BONUS -> execute_peek() / execute_swap() / skip_bonus()
105 -> WAITING_FOR_DRAW / WAITING_FOR_ACTION (King)
106 LAST_ROUND follows the same flow but checks for end condition after each turn.
107 """
109 def __init__(self, state: GameState | None = None, settings: GameSettings | None = None) -> None:
110 self.state = GameState() if state is None else state
111 self.settings = GameSettings() if settings is None else settings
113 # ------------------------------------------------------------------
114 # Setup
115 # ------------------------------------------------------------------
117 def start(self) -> None:
118 """Initialize the game: validate, create deck, deal cards, place first discard."""
119 validate_start(self.state, self.settings)
121 # Create and shuffle deck
122 cards_or_deck = self.settings.deck_factory()
123 if isinstance(cards_or_deck, Deck):
124 self.state.deck = cards_or_deck
125 else:
126 self.state.deck = Deck(cards=cards_or_deck)
127 self.state.deck.shuffle()
129 # Deal cards to each player
130 for player in self.state.players:
131 dealt = self.state.deck.draw_n(self.settings.initial_hand_size)
132 if not isinstance(dealt, list):
133 dealt = [dealt] # pragma: no cover
134 player.hand = Hand(slots=[CardSlot(card=c) for c in dealt])
136 # Place first card on discard pile
137 first_discard = self.state.deck.draw()
138 self.state.discard_pile.append(first_discard)
140 # Phase stays STARTING until all players have peeked
141 self.state.initial_peeks_done = set()
143 # When no initial peek is required, skip STARTING and go straight to draw phase
144 if self.settings.initial_cards_known == 0:
145 self.state.initial_peeks_done = {p.name for p in self.state.players}
146 self.state.set_phase(GamePhase.WAITING_FOR_DRAW)
148 def peek_initial(self, player_id: str, indices: list[int]) -> list[Card]:
149 """
150 Player peeks at their initial cards.
151 Returns the cards that were peeked at.
152 Once all players have peeked, transitions to WAITING_FOR_DRAW.
153 """
154 player = validate_initial_peek(self.state, self.settings, player_id, indices)
156 if player_id in self.state.initial_peeks_done:
157 raise RuleViolation(f"Player {player_id} has already peeked")
159 # Mark cards as known
160 peeked_cards = []
161 for idx in indices:
162 player.hand.slots[idx].known = True
163 peeked_cards.append(player.hand[idx])
165 self.state.initial_peeks_done.add(player_id)
167 # If all players have peeked, start the game
168 if len(self.state.initial_peeks_done) == len(self.state.players):
169 self.state.set_phase(GamePhase.WAITING_FOR_DRAW)
171 return peeked_cards
173 # ------------------------------------------------------------------
174 # Drawing
175 # ------------------------------------------------------------------
177 def draw_card(self, player_id: str) -> Card:
178 """Draw a card from the deck. Returns the drawn card."""
179 self._refill_deck_if_needed()
180 validate_draw(self.state, player_id)
182 card = self.state.deck.draw()
183 self.state.drawn_card = card
184 self.state.set_phase(GamePhase.WAITING_FOR_ACTION)
185 return card
187 def draw_from_discard(self, player_id: str) -> Card:
188 """Draw the top card from the discard pile. Returns the drawn card."""
189 validate_draw_from_discard(self.state, player_id)
191 card = self.state.discard_pile.pop()
192 self.state.drawn_card = card
193 self.state.set_phase(GamePhase.WAITING_FOR_ACTION)
194 return card
196 # ------------------------------------------------------------------
197 # Actions after drawing
198 # ------------------------------------------------------------------
200 def keep_drawn_card(self, action: ReplaceCardAction) -> Card:
201 """
202 Replace a hand card with the drawn card.
203 Returns the old card that was discarded.
204 """
205 player = validate_replace_card(self.state, action)
207 drawn = self.state.drawn_card
208 if drawn is None:
209 raise RuleViolation("No card has been drawn to keep")
210 old_card = player.hand.replace_card(action.hand_index, drawn)
211 self.state.discard_pile.append(old_card)
212 self.state.drawn_card = None
214 self._advance_turn()
215 return old_card
217 def discard_drawn_card(self, action: DiscardDrawnAction) -> FaceCard | None:
218 """
219 Discard the drawn card without replacing anything.
220 Returns the bonus action type if the discarded card triggers one, else None.
221 """
222 validate_discard_drawn(self.state, action)
224 drawn = self.state.drawn_card
225 if drawn is None:
226 raise RuleViolation("No card has been drawn to discard")
227 self.state.discard_pile.append(drawn)
228 self.state.drawn_card = None
230 # Check for bonus action (Jack, Queen, King)
231 bonus = drawn.bonus_action
232 if bonus is not None:
233 self.state.pending_bonus = bonus
234 self.state.set_phase(GamePhase.WAITING_FOR_BONUS)
235 return bonus
237 self._advance_turn()
238 return None
240 # ------------------------------------------------------------------
241 # Bonus actions
242 # ------------------------------------------------------------------
244 def execute_peek(self, action: PeekOwnCardAction) -> Card:
245 """Execute a Jack bonus: peek at one of your own cards. Returns the peeked card."""
246 player = validate_peek(self.state, action)
248 player.hand.slots[action.hand_index].known = True
249 peeked = player.hand[action.hand_index]
251 self.state.pending_bonus = None
252 self._advance_turn()
253 return peeked
255 def execute_swap(self, action: SwapCardAction) -> None:
256 """Execute a Queen bonus: blind-swap one of your cards with another player's card."""
257 player, target = validate_swap(self.state, action)
259 # Swap the cards
260 own_slot = player.hand.slots[action.own_index]
261 target_slot = target.hand.slots[action.target_index]
262 own_slot.card, target_slot.card = target_slot.card, own_slot.card
263 # Both cards become unknown after a blind swap
264 own_slot.known = False
265 target_slot.known = False
267 self.state.pending_bonus = None
268 self._advance_turn()
270 def execute_king_bonus(self) -> None:
271 """
272 Execute a King bonus: the player draws another card (same rules).
273 Transitions back to WAITING_FOR_DRAW (or LAST_ROUND) so the player
274 draws again, but does NOT advance the turn.
275 """
276 if self.state.pending_bonus != FaceCard.KING:
277 raise RuleViolation(
278 f"No King bonus pending (pending: {self.state.pending_bonus})",
279 )
281 self.state.pending_bonus = None
282 # Go back to draw phase for the SAME player (no next_player)
283 if self.state.last_round_starter is not None:
284 self.state.set_phase(GamePhase.LAST_ROUND)
285 else:
286 self.state.set_phase(GamePhase.WAITING_FOR_DRAW)
288 def skip_bonus(self, player_id: str) -> None:
289 """Skip the current bonus action."""
290 validate_skip_bonus(self.state, player_id)
292 self.state.pending_bonus = None
293 self._advance_turn()
295 def trigger_last_round(self, player_id: str) -> None:
296 """
297 Player declares the last round (Beaver/Knock).
298 Marks the current player as the starter of the last round.
299 """
300 validate_beaver(self.state, player_id)
301 self.state.last_round_starter = self.state.current_player_index
302 self.state.set_phase(GamePhase.LAST_ROUND)
304 # ------------------------------------------------------------------
305 # Snap (exclusively in WAITING_FOR_DRAW)
306 # ------------------------------------------------------------------
308 def snap(self, action: SnapAction) -> bool:
309 """
310 Attempt to snap: claim that one of your hand cards matches the top discard.
311 Any player can snap during WAITING_FOR_DRAW.
313 Returns True if the snap was correct (card removed from hand),
314 False if incorrect (card stays + penalty card drawn).
315 """
316 player = validate_snap(self.state, action)
318 top_discard = self.state.top_discard
319 if top_discard is None:
320 raise RuleViolation("Cannot snap when there is no card on the discard pile")
321 hand_card = player.hand[action.hand_index]
323 if hand_card.value == top_discard.value:
324 # Correct snap: remove card from hand
325 player.hand.remove_card(action.hand_index)
326 return True
327 else:
328 # Incorrect snap: draw a penalty card
329 self._refill_deck_if_needed()
330 if len(self.state.deck) > 0:
331 penalty = self.state.deck.draw()
332 player.hand.add_card(penalty)
333 return False
335 # ------------------------------------------------------------------
336 # End game / scoring
337 # ------------------------------------------------------------------
339 def get_scores(self) -> list[tuple[str, int]]:
340 """Return sorted scores for all players."""
341 return calculate_scores(self.state.players)
343 def get_winner(self) -> list[Player]:
344 """Return the winner(s) of the game."""
345 return determine_winner(self.state.players)
347 # ------------------------------------------------------------------
348 # Internal helpers
349 # ------------------------------------------------------------------
351 def _advance_turn(self) -> None:
352 """Advance to the next player, checking for last-round / game-over conditions."""
353 # Check if a joker was just discarded (triggers last round)
354 if (
355 self.state.last_round_starter is None
356 and self.state.top_discard is not None
357 and self.state.top_discard.is_joker
358 ):
359 self.state.last_round_starter = self.state.current_player_index
361 self.state.next_player()
363 # Check if last round is complete (we've gone around back to the starter)
364 if (
365 self.state.last_round_starter is not None
366 and self.state.current_player_index == self.state.last_round_starter
367 ):
368 self.state.set_phase(GamePhase.FINISHED)
369 return
371 if self.state.last_round_starter is not None:
372 self.state.set_phase(GamePhase.LAST_ROUND)
373 else:
374 self.state.set_phase(GamePhase.WAITING_FOR_DRAW)
376 def _refill_deck_if_needed(self) -> None:
377 """If the deck is empty, reshuffle the discard pile (minus top card) into the deck."""
378 if len(self.state.deck) == 0 and len(self.state.discard_pile) > 1:
379 top = self.state.discard_pile.pop()
380 self.state.deck = Deck(cards=self.state.discard_pile)
381 self.state.deck.shuffle()
382 self.state.discard_pile = [top]