Coverage for src / beaverbunch / core / rules.py: 100.0%
145 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
1"""
2Pure validation functions for Beaverbunch game rules.
4These functions check whether an action is valid given the current game state,
5but never mutate the state themselves.
6"""
8from __future__ import annotations
10from typing import TYPE_CHECKING
12from beaverbunch.core.card import FaceCard
14if TYPE_CHECKING:
15 from beaverbunch.core.actions import (
16 DiscardDrawnAction,
17 PeekOwnCardAction,
18 ReplaceCardAction,
19 SnapAction,
20 SwapCardAction,
21 )
22 from beaverbunch.core.game import GamePhase, GameState
23 from beaverbunch.core.game_settings import GameSettings
24 from beaverbunch.core.player import Player
27class RuleViolation(Exception):
28 """Raised when a game action violates the rules."""
31# ---------------------------------------------------------------------------
32# Helpers
33# ---------------------------------------------------------------------------
35def _require_phase(state: GameState, *phases: GamePhase) -> None:
36 """Raise RuleViolation if the game is not in one of the expected phases."""
37 if state.phase not in phases:
38 allowed = ", ".join(p.name for p in phases)
39 raise RuleViolation(
40 f"Action not allowed in phase {state.phase.name} (expected: {allowed})"
41 )
44def _get_player(state: GameState, player_id: str) -> Player:
45 """Return the player with the given id (name), or raise RuleViolation."""
46 for p in state.players:
47 if p.name == player_id:
48 return p
49 raise RuleViolation(f"Unknown player: {player_id}")
52def _current_player(state: GameState) -> Player:
53 """Return the player whose turn it currently is."""
54 return state.players[state.current_player_index]
57# ---------------------------------------------------------------------------
58# Setup validations
59# ---------------------------------------------------------------------------
61def validate_player_count(settings: GameSettings, count: int) -> None:
62 """Validate that the player count is within the allowed range."""
63 if count < settings.min_players:
64 raise RuleViolation(
65 f"Not enough players: {count} (minimum: {settings.min_players})"
66 )
67 if count > settings.max_players:
68 raise RuleViolation(
69 f"Too many players: {count} (maximum: {settings.max_players})"
70 )
73def validate_start(state: GameState, settings: GameSettings) -> None:
74 """Validate that the game can be started."""
75 from beaverbunch.core.game import GamePhase
77 _require_phase(state, GamePhase.STARTING)
78 validate_player_count(settings, len(state.players))
80 # Ensure unique player names (used as IDs)
81 names = [p.name for p in state.players]
82 if len(names) != len(set(names)):
83 raise RuleViolation("All player names must be unique")
86def validate_initial_peek(
87 state: GameState,
88 settings: GameSettings,
89 player_id: str,
90 indices: list[int],
91) -> Player:
92 """Validate an initial-peek action. Returns the player."""
93 from beaverbunch.core.game import GamePhase
95 _require_phase(state, GamePhase.STARTING)
96 player = _get_player(state, player_id)
98 if len(indices) != settings.initial_cards_known:
99 raise RuleViolation(
100 f"Must peek exactly {settings.initial_cards_known} cards, got {len(indices)}"
101 )
102 for idx in indices:
103 if idx < 0 or idx >= len(player.hand):
104 raise RuleViolation(f"Card index {idx} out of range")
105 if len(set(indices)) != len(indices):
106 raise RuleViolation("Duplicate card indices")
107 return player
110# ---------------------------------------------------------------------------
111# Turn validations
112# ---------------------------------------------------------------------------
114def validate_draw(state: GameState, player_id: str) -> Player:
115 """Validate that the player can draw a card from the deck."""
116 from beaverbunch.core.game import GamePhase
118 _require_phase(state, GamePhase.WAITING_FOR_DRAW, GamePhase.LAST_ROUND)
119 player = _get_player(state, player_id)
120 current = _current_player(state)
121 if player.name != current.name:
122 raise RuleViolation(
123 f"It's {current.name}'s turn, not {player.name}'s"
124 )
125 if state.drawn_card is not None:
126 raise RuleViolation("A card has already been drawn this turn")
127 if len(state.deck) == 0:
128 raise RuleViolation("The deck is empty")
129 return player
132def validate_draw_from_discard(state: GameState, player_id: str) -> Player:
133 """Validate that the player can draw the top card from the discard pile."""
134 from beaverbunch.core.game import GamePhase
136 _require_phase(state, GamePhase.WAITING_FOR_DRAW, GamePhase.LAST_ROUND)
137 player = _get_player(state, player_id)
138 current = _current_player(state)
139 if player.name != current.name:
140 raise RuleViolation(
141 f"It's {current.name}'s turn, not {player.name}'s"
142 )
143 if state.drawn_card is not None:
144 raise RuleViolation("A card has already been drawn this turn")
145 if not state.discard_pile:
146 raise RuleViolation("The discard pile is empty")
147 return player
150def validate_replace_card(state: GameState, action: ReplaceCardAction) -> Player:
151 """Validate replacing a hand card with the drawn card."""
152 from beaverbunch.core.game import GamePhase
154 _require_phase(state, GamePhase.WAITING_FOR_ACTION)
155 player = _get_player(state, action.player_id)
156 current = _current_player(state)
157 if player.name != current.name:
158 raise RuleViolation(f"It's {current.name}'s turn, not {player.name}'s")
159 if state.drawn_card is None:
160 raise RuleViolation("No card has been drawn yet")
161 if action.hand_index < 0 or action.hand_index >= len(player.hand):
162 raise RuleViolation(f"Card index {action.hand_index} out of range")
163 return player
166def validate_discard_drawn(state: GameState, action: DiscardDrawnAction) -> Player:
167 """Validate discarding the drawn card."""
168 from beaverbunch.core.game import GamePhase
170 _require_phase(state, GamePhase.WAITING_FOR_ACTION)
171 player = _get_player(state, action.player_id)
172 current = _current_player(state)
173 if player.name != current.name:
174 raise RuleViolation(f"It's {current.name}'s turn, not {player.name}'s")
175 if state.drawn_card is None:
176 raise RuleViolation("No card has been drawn yet")
177 return player
180# ---------------------------------------------------------------------------
181# Bonus action validations
182# ---------------------------------------------------------------------------
184def validate_peek(state: GameState, action: PeekOwnCardAction) -> Player:
185 """Validate a Jack peek bonus action."""
186 from beaverbunch.core.game import GamePhase
188 _require_phase(state, GamePhase.WAITING_FOR_BONUS)
189 player = _get_player(state, action.player_id)
190 current = _current_player(state)
191 if player.name != current.name:
192 raise RuleViolation(f"It's {current.name}'s turn, not {player.name}'s")
193 if state.pending_bonus != FaceCard.JACK:
194 raise RuleViolation(
195 f"No Jack bonus pending (pending: {state.pending_bonus})"
196 )
197 if action.hand_index < 0 or action.hand_index >= len(player.hand):
198 raise RuleViolation(f"Card index {action.hand_index} out of range")
199 return player
202def validate_swap(state: GameState, action: SwapCardAction) -> tuple[Player, Player]:
203 """Validate a Queen swap bonus action. Returns (acting player, target player)."""
204 from beaverbunch.core.game import GamePhase
206 _require_phase(state, GamePhase.WAITING_FOR_BONUS)
207 player = _get_player(state, action.player_id)
208 current = _current_player(state)
209 if player.name != current.name:
210 raise RuleViolation(f"It's {current.name}'s turn, not {player.name}'s")
211 if state.pending_bonus != FaceCard.QUEEN:
212 raise RuleViolation(
213 f"No Queen bonus pending (pending: {state.pending_bonus})"
214 )
215 if action.player_id == action.target_player_id:
216 raise RuleViolation("Cannot swap with yourself")
217 target = _get_player(state, action.target_player_id)
218 if action.own_index < 0 or action.own_index >= len(player.hand):
219 raise RuleViolation(f"Own card index {action.own_index} out of range")
220 if action.target_index < 0 or action.target_index >= len(target.hand):
221 raise RuleViolation(f"Target card index {action.target_index} out of range")
222 return player, target
225def validate_skip_bonus(state: GameState, player_id: str) -> Player:
226 """Validate skipping a bonus action."""
227 from beaverbunch.core.game import GamePhase
229 _require_phase(state, GamePhase.WAITING_FOR_BONUS)
230 player = _get_player(state, player_id)
231 current = _current_player(state)
232 if player.name != current.name:
233 raise RuleViolation(f"It's {current.name}'s turn, not {player.name}'s")
234 if state.pending_bonus is None:
235 raise RuleViolation("No bonus action to skip")
236 return player
239# ---------------------------------------------------------------------------
240# Snap validation (happens during WAITING_FOR_DRAW)
241# ---------------------------------------------------------------------------
243def validate_snap(state: GameState, action: SnapAction) -> Player:
244 """Validate a snap attempt. Any player can snap during WAITING_FOR_DRAW."""
245 from beaverbunch.core.game import GamePhase
247 _require_phase(state, GamePhase.WAITING_FOR_DRAW, GamePhase.LAST_ROUND)
248 player = _get_player(state, action.player_id)
249 if not state.discard_pile:
250 raise RuleViolation("Discard pile is empty — nothing to snap")
251 if action.hand_index < 0 or action.hand_index >= len(player.hand):
252 raise RuleViolation(f"Card index {action.hand_index} out of range")
253 return player
256# ---------------------------------------------------------------------------
257# Scoring
258# ---------------------------------------------------------------------------
260def calculate_scores(players: list[Player]) -> list[tuple[str, int]]:
261 """Return a list of (player_name, total_points), sorted by points ascending."""
262 scores = [(p.name, p.get_points()) for p in players]
263 scores.sort(key=lambda x: (x[1],))
264 return scores
267def determine_winner(players: list[Player]) -> list[Player]:
268 """Return the winner(s). Lowest points wins. Ties broken by fewest cards."""
269 if not players:
270 return []
271 scored = [(p, p.get_points(), len(p.hand)) for p in players]
272 scored.sort(key=lambda x: (x[1], x[2]))
273 best_points = scored[0][1]
274 best_cards = scored[0][2]
275 return [p for p, pts, cards in scored if pts == best_points and cards == best_cards]