Coverage for src / beaverbunch / core / game.py: 100.0%
162 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-05 20:45 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-05 20:45 +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 Hand, CardSlot
15from beaverbunch.core.player import Player
16from beaverbunch.core.rules import (
17 RuleViolation,
18 validate_start,
19 validate_initial_peek,
20 validate_draw,
21 validate_draw_from_discard,
22 validate_replace_card,
23 validate_discard_drawn,
24 validate_peek,
25 validate_swap,
26 validate_skip_bonus,
27 validate_snap,
28 calculate_scores,
29 determine_winner,
30)
33class GamePhase(Enum):
34 STARTING = auto()
36 WAITING_FOR_DRAW = auto()
37 WAITING_FOR_ACTION = auto()
38 WAITING_FOR_BONUS = auto()
40 LAST_ROUND = auto()
41 FINISHED = auto()
44@dataclass
45class GameState:
46 """
47 Represents the current state of the game, including players, turn order, and phase.
49 Attributes:
50 phase (GamePhase): Current phase of the game.
51 turn_count (int): Number of turns that have been completed.
52 deck (Deck): The main deck of cards for the game.
53 discard_pile (list): List of cards that have been discarded.
54 players (list): List of players in the game.
55 drawn_card (Card | None): The card currently drawn by the active player.
56 current_player_index (int): Index of the current player in the players list.
57 pending_bonus (FaceCard | None): The bonus action waiting to be resolved.
58 last_round_starter (int | None): Index of the player who triggered the last round.
59 initial_peeks_done (set): Set of player names who have completed their initial peek.
60 """
61 phase: GamePhase = GamePhase.STARTING
62 turn_count: int = 0
64 deck: Deck = field(default_factory=Deck)
65 discard_pile: list[Card] = field(default_factory=list)
67 players: list[Player] = field(default_factory=list)
68 drawn_card: Card | None = None
69 current_player_index: int = 0
71 pending_bonus: FaceCard | None = None
72 last_round_starter: int | None = None
73 initial_peeks_done: set[str] = field(default_factory=set)
75 def add_player(self, player: Player) -> None:
76 """Adds a player to the game."""
77 self.players.append(player)
79 def next_player(self) -> None:
80 """Advances to the next player's turn and updates the turn count."""
81 self.current_player_index = (self.current_player_index + 1) % len(self.players)
82 self.turn_count += 1
84 def is_game_over(self) -> bool:
85 """Checks if the game has reached the finished phase."""
86 return self.phase == GamePhase.FINISHED
88 def set_phase(self, new_phase: GamePhase) -> None:
89 """Updates the current phase of the game."""
90 self.phase = new_phase
92 @property
93 def current_player(self) -> Player:
94 """Returns the player whose turn it currently is."""
95 return self.players[self.current_player_index]
97 @property
98 def top_discard(self) -> Card | None:
99 """Returns the top card of the discard pile, or None if empty."""
100 return self.discard_pile[-1] if self.discard_pile else None
103class Game:
104 """
105 Main game engine implementing the Beaverbunch state machine.
107 State transitions:
108 STARTING -> start() -> STARTING (waiting for peeks)
109 -> peek_initial() (all done) -> WAITING_FOR_DRAW
110 WAITING_FOR_DRAW -> snap() -> WAITING_FOR_DRAW (any player)
111 -> draw_card() / draw_from_discard() -> WAITING_FOR_ACTION
112 WAITING_FOR_ACTION -> keep_drawn_card() -> WAITING_FOR_DRAW / LAST_ROUND / FINISHED
113 -> discard_drawn_card() -> WAITING_FOR_BONUS / WAITING_FOR_DRAW
114 WAITING_FOR_BONUS -> execute_peek() / execute_swap() / skip_bonus()
115 -> WAITING_FOR_DRAW / WAITING_FOR_ACTION (King)
116 LAST_ROUND follows the same flow but checks for end condition after each turn.
117 """
119 def __init__(self, state: GameState | None = None, settings: GameSettings | None = None) -> None:
120 self.state = GameState() if state is None else state
121 self.settings = GameSettings() if settings is None else settings
123 # ------------------------------------------------------------------
124 # Setup
125 # ------------------------------------------------------------------
127 def start(self) -> None:
128 """Initialize the game: validate, create deck, deal cards, place first discard."""
129 validate_start(self.state, self.settings)
131 # Create and shuffle deck
132 cards = self.settings.deck_factory()
133 self.state.deck = Deck(cards=cards)
134 self.state.deck.shuffle()
136 # Deal cards to each player
137 for player in self.state.players:
138 dealt = self.state.deck.draw(self.settings.initial_hand_size)
139 if not isinstance(dealt, list):
140 dealt = [dealt] # pragma: no cover
141 player.hand = Hand(slots=[CardSlot(card=c) for c in dealt])
143 # Place first card on discard pile
144 first_discard = self.state.deck.draw()
145 self.state.discard_pile.append(first_discard)
147 # Phase stays STARTING until all players have peeked
148 self.state.initial_peeks_done = set()
150 def peek_initial(self, player_id: str, indices: list[int]) -> list[Card]:
151 """
152 Player peeks at their initial cards.
153 Returns the cards that were peeked at.
154 Once all players have peeked, transitions to WAITING_FOR_DRAW.
155 """
156 player = validate_initial_peek(self.state, self.settings, player_id, indices)
158 if player_id in self.state.initial_peeks_done:
159 raise RuleViolation(f"Player {player_id} has already peeked")
161 # Mark cards as known
162 peeked_cards = []
163 for idx in indices:
164 player.hand.slots[idx].known = True
165 peeked_cards.append(player.hand[idx])
167 self.state.initial_peeks_done.add(player_id)
169 # If all players have peeked, start the game
170 if len(self.state.initial_peeks_done) == len(self.state.players):
171 self.state.set_phase(GamePhase.WAITING_FOR_DRAW)
173 return peeked_cards
175 # ------------------------------------------------------------------
176 # Drawing
177 # ------------------------------------------------------------------
179 def draw_card(self, player_id: str) -> Card:
180 """Draw a card from the deck. Returns the drawn card."""
181 self._refill_deck_if_needed()
182 validate_draw(self.state, player_id)
184 card = self.state.deck.draw()
185 self.state.drawn_card = card
186 self.state.set_phase(GamePhase.WAITING_FOR_ACTION)
187 return card
189 def draw_from_discard(self, player_id: str) -> Card:
190 """Draw the top card from the discard pile. Returns the drawn card."""
191 validate_draw_from_discard(self.state, player_id)
193 card = self.state.discard_pile.pop()
194 self.state.drawn_card = card
195 self.state.set_phase(GamePhase.WAITING_FOR_ACTION)
196 return card
198 # ------------------------------------------------------------------
199 # Actions after drawing
200 # ------------------------------------------------------------------
202 def keep_drawn_card(self, action: ReplaceCardAction) -> Card:
203 """
204 Replace a hand card with the drawn card.
205 Returns the old card that was discarded.
206 """
207 player = validate_replace_card(self.state, action)
209 drawn = self.state.drawn_card
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 self.state.discard_pile.append(drawn)
226 self.state.drawn_card = None
228 # Check for bonus action (Jack, Queen, King)
229 bonus = drawn.bonus_action
230 if bonus is not None:
231 self.state.pending_bonus = bonus
232 self.state.set_phase(GamePhase.WAITING_FOR_BONUS)
233 return bonus
235 self._advance_turn()
236 return None
238 # ------------------------------------------------------------------
239 # Bonus actions
240 # ------------------------------------------------------------------
242 def execute_peek(self, action: PeekOwnCardAction) -> Card:
243 """Execute a Jack bonus: peek at one of your own cards. Returns the peeked card."""
244 player = validate_peek(self.state, action)
246 player.hand.slots[action.hand_index].known = True
247 peeked = player.hand[action.hand_index]
249 self.state.pending_bonus = None
250 self._advance_turn()
251 return peeked
253 def execute_swap(self, action: SwapCardAction) -> None:
254 """Execute a Queen bonus: blind-swap one of your cards with another player's card."""
255 player, target = validate_swap(self.state, action)
257 # Swap the cards
258 own_slot = player.hand.slots[action.own_index]
259 target_slot = target.hand.slots[action.target_index]
260 own_slot.card, target_slot.card = target_slot.card, own_slot.card
261 # Both cards become unknown after a blind swap
262 own_slot.known = False
263 target_slot.known = False
265 self.state.pending_bonus = None
266 self._advance_turn()
268 def execute_king_bonus(self) -> None:
269 """
270 Execute a King bonus: the player draws another card (same rules).
271 Transitions back to WAITING_FOR_DRAW so the player draws again,
272 but does NOT advance the turn.
273 """
274 from beaverbunch.core.rules import _current_player
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 self.state.set_phase(GamePhase.WAITING_FOR_DRAW)
285 def skip_bonus(self, player_id: str) -> None:
286 """Skip the current bonus action."""
287 validate_skip_bonus(self.state, player_id)
289 self.state.pending_bonus = None
290 self._advance_turn()
292 # ------------------------------------------------------------------
293 # Snap (exclusively in WAITING_FOR_DRAW)
294 # ------------------------------------------------------------------
296 def snap(self, action: SnapAction) -> bool:
297 """
298 Attempt to snap: claim that one of your hand cards matches the top discard.
299 Any player can snap during WAITING_FOR_DRAW.
301 Returns True if the snap was correct (card removed from hand),
302 False if incorrect (card stays + penalty card drawn).
303 """
304 player = validate_snap(self.state, action)
306 top_discard = self.state.top_discard
307 hand_card = player.hand[action.hand_index]
309 if hand_card.value == top_discard.value:
310 # Correct snap: remove card from hand
311 player.hand.remove_card(action.hand_index)
312 return True
313 else:
314 # Incorrect snap: draw a penalty card
315 self._refill_deck_if_needed()
316 if len(self.state.deck) > 0:
317 penalty = self.state.deck.draw()
318 player.hand.add_card(penalty)
319 return False
321 # ------------------------------------------------------------------
322 # End game / scoring
323 # ------------------------------------------------------------------
325 def get_scores(self) -> list[tuple[str, int]]:
326 """Return sorted scores for all players."""
327 return calculate_scores(self.state.players)
329 def get_winner(self) -> list[Player]:
330 """Return the winner(s) of the game."""
331 return determine_winner(self.state.players)
333 # ------------------------------------------------------------------
334 # Internal helpers
335 # ------------------------------------------------------------------
337 def _advance_turn(self) -> None:
338 """Advance to the next player, checking for last-round / game-over conditions."""
339 # Check if a joker was just discarded (triggers last round)
340 if (
341 self.state.last_round_starter is None
342 and self.state.top_discard is not None
343 and self.state.top_discard.is_joker
344 ):
345 self.state.last_round_starter = self.state.current_player_index
347 self.state.next_player()
349 # Check if last round is complete (we've gone around back to the starter)
350 if (
351 self.state.last_round_starter is not None
352 and self.state.current_player_index == self.state.last_round_starter
353 ):
354 self.state.set_phase(GamePhase.FINISHED)
355 return
357 if self.state.last_round_starter is not None:
358 self.state.set_phase(GamePhase.LAST_ROUND)
359 else:
360 self.state.set_phase(GamePhase.WAITING_FOR_DRAW)
362 def _refill_deck_if_needed(self) -> None:
363 """If the deck is empty, reshuffle the discard pile (minus top card) into the deck."""
364 if len(self.state.deck) == 0 and len(self.state.discard_pile) > 1:
365 top = self.state.discard_pile.pop()
366 self.state.deck = Deck(cards=self.state.discard_pile)
367 self.state.deck.shuffle()
368 self.state.discard_pile = [top]