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

1from dataclasses import dataclass, field 

2from enum import Enum, auto 

3 

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) 

21 

22 

23class GamePhase(Enum): 

24 STARTING = auto() 

25 

26 WAITING_FOR_DRAW = auto() 

27 WAITING_FOR_ACTION = auto() 

28 WAITING_FOR_BONUS = auto() 

29 

30 LAST_ROUND = auto() 

31 FINISHED = auto() 

32 

33 

34@dataclass 

35class GameState: 

36 """ 

37 Represents the current state of the game, including players, turn order, and phase. 

38 

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 

53 

54 deck: Deck = field(default_factory=Deck) 

55 discard_pile: list[Card] = field(default_factory=list) 

56 

57 players: list[Player] = field(default_factory=list) 

58 drawn_card: Card | None = None 

59 current_player_index: int = 0 

60 

61 pending_bonus: FaceCard | None = None 

62 last_round_starter: int | None = None 

63 initial_peeks_done: set[str] = field(default_factory=set) 

64 

65 def add_player(self, player: Player) -> None: 

66 """Adds a player to the game.""" 

67 self.players.append(player) 

68 

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 

73 

74 def is_game_over(self) -> bool: 

75 """Checks if the game has reached the finished phase.""" 

76 return self.phase == GamePhase.FINISHED 

77 

78 def set_phase(self, new_phase: GamePhase) -> None: 

79 """Updates the current phase of the game.""" 

80 self.phase = new_phase 

81 

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] 

86 

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 

91 

92 

93class Game: 

94 """ 

95 Main game engine implementing the Beaverbunch state machine. 

96 

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 """ 

108 

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 

112 

113 # ------------------------------------------------------------------ 

114 # Setup 

115 # ------------------------------------------------------------------ 

116 

117 def start(self) -> None: 

118 """Initialize the game: validate, create deck, deal cards, place first discard.""" 

119 validate_start(self.state, self.settings) 

120 

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() 

128 

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]) 

135 

136 # Place first card on discard pile 

137 first_discard = self.state.deck.draw() 

138 self.state.discard_pile.append(first_discard) 

139 

140 # Phase stays STARTING until all players have peeked 

141 self.state.initial_peeks_done = set() 

142 

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) 

147 

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) 

155 

156 if player_id in self.state.initial_peeks_done: 

157 raise RuleViolation(f"Player {player_id} has already peeked") 

158 

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]) 

164 

165 self.state.initial_peeks_done.add(player_id) 

166 

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) 

170 

171 return peeked_cards 

172 

173 # ------------------------------------------------------------------ 

174 # Drawing 

175 # ------------------------------------------------------------------ 

176 

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) 

181 

182 card = self.state.deck.draw() 

183 self.state.drawn_card = card 

184 self.state.set_phase(GamePhase.WAITING_FOR_ACTION) 

185 return card 

186 

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) 

190 

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 

195 

196 # ------------------------------------------------------------------ 

197 # Actions after drawing 

198 # ------------------------------------------------------------------ 

199 

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) 

206 

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 

213 

214 self._advance_turn() 

215 return old_card 

216 

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) 

223 

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 

229 

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 

236 

237 self._advance_turn() 

238 return None 

239 

240 # ------------------------------------------------------------------ 

241 # Bonus actions 

242 # ------------------------------------------------------------------ 

243 

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) 

247 

248 player.hand.slots[action.hand_index].known = True 

249 peeked = player.hand[action.hand_index] 

250 

251 self.state.pending_bonus = None 

252 self._advance_turn() 

253 return peeked 

254 

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) 

258 

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 

266 

267 self.state.pending_bonus = None 

268 self._advance_turn() 

269 

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 ) 

280 

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) 

287 

288 def skip_bonus(self, player_id: str) -> None: 

289 """Skip the current bonus action.""" 

290 validate_skip_bonus(self.state, player_id) 

291 

292 self.state.pending_bonus = None 

293 self._advance_turn() 

294 

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) 

303 

304 # ------------------------------------------------------------------ 

305 # Snap (exclusively in WAITING_FOR_DRAW) 

306 # ------------------------------------------------------------------ 

307 

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. 

312 

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) 

317 

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] 

322 

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 

334 

335 # ------------------------------------------------------------------ 

336 # End game / scoring 

337 # ------------------------------------------------------------------ 

338 

339 def get_scores(self) -> list[tuple[str, int]]: 

340 """Return sorted scores for all players.""" 

341 return calculate_scores(self.state.players) 

342 

343 def get_winner(self) -> list[Player]: 

344 """Return the winner(s) of the game.""" 

345 return determine_winner(self.state.players) 

346 

347 # ------------------------------------------------------------------ 

348 # Internal helpers 

349 # ------------------------------------------------------------------ 

350 

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 

360 

361 self.state.next_player() 

362 

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 

370 

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) 

375 

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]