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

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

31 

32 

33class GamePhase(Enum): 

34 STARTING = auto() 

35 

36 WAITING_FOR_DRAW = auto() 

37 WAITING_FOR_ACTION = auto() 

38 WAITING_FOR_BONUS = auto() 

39 

40 LAST_ROUND = auto() 

41 FINISHED = auto() 

42 

43 

44@dataclass 

45class GameState: 

46 """ 

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

48 

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 

63 

64 deck: Deck = field(default_factory=Deck) 

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

66 

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

68 drawn_card: Card | None = None 

69 current_player_index: int = 0 

70 

71 pending_bonus: FaceCard | None = None 

72 last_round_starter: int | None = None 

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

74 

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

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

77 self.players.append(player) 

78 

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 

83 

84 def is_game_over(self) -> bool: 

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

86 return self.phase == GamePhase.FINISHED 

87 

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

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

90 self.phase = new_phase 

91 

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] 

96 

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 

101 

102 

103class Game: 

104 """ 

105 Main game engine implementing the Beaverbunch state machine. 

106 

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

118 

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 

122 

123 # ------------------------------------------------------------------ 

124 # Setup 

125 # ------------------------------------------------------------------ 

126 

127 def start(self) -> None: 

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

129 validate_start(self.state, self.settings) 

130 

131 # Create and shuffle deck 

132 cards = self.settings.deck_factory() 

133 self.state.deck = Deck(cards=cards) 

134 self.state.deck.shuffle() 

135 

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

142 

143 # Place first card on discard pile 

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

145 self.state.discard_pile.append(first_discard) 

146 

147 # Phase stays STARTING until all players have peeked 

148 self.state.initial_peeks_done = set() 

149 

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) 

157 

158 if player_id in self.state.initial_peeks_done: 

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

160 

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

166 

167 self.state.initial_peeks_done.add(player_id) 

168 

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) 

172 

173 return peeked_cards 

174 

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

176 # Drawing 

177 # ------------------------------------------------------------------ 

178 

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) 

183 

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

185 self.state.drawn_card = card 

186 self.state.set_phase(GamePhase.WAITING_FOR_ACTION) 

187 return card 

188 

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) 

192 

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 

197 

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

199 # Actions after drawing 

200 # ------------------------------------------------------------------ 

201 

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) 

208 

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 

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 self.state.discard_pile.append(drawn) 

226 self.state.drawn_card = None 

227 

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 

234 

235 self._advance_turn() 

236 return None 

237 

238 # ------------------------------------------------------------------ 

239 # Bonus actions 

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

241 

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) 

245 

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

247 peeked = player.hand[action.hand_index] 

248 

249 self.state.pending_bonus = None 

250 self._advance_turn() 

251 return peeked 

252 

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) 

256 

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 

264 

265 self.state.pending_bonus = None 

266 self._advance_turn() 

267 

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 

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 self.state.set_phase(GamePhase.WAITING_FOR_DRAW) 

284 

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

286 """Skip the current bonus action.""" 

287 validate_skip_bonus(self.state, player_id) 

288 

289 self.state.pending_bonus = None 

290 self._advance_turn() 

291 

292 # ------------------------------------------------------------------ 

293 # Snap (exclusively in WAITING_FOR_DRAW) 

294 # ------------------------------------------------------------------ 

295 

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. 

300 

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) 

305 

306 top_discard = self.state.top_discard 

307 hand_card = player.hand[action.hand_index] 

308 

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 

320 

321 # ------------------------------------------------------------------ 

322 # End game / scoring 

323 # ------------------------------------------------------------------ 

324 

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

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

327 return calculate_scores(self.state.players) 

328 

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

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

331 return determine_winner(self.state.players) 

332 

333 # ------------------------------------------------------------------ 

334 # Internal helpers 

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

336 

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 

346 

347 self.state.next_player() 

348 

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 

356 

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) 

361 

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]