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

1""" 

2Pure validation functions for Beaverbunch game rules. 

3 

4These functions check whether an action is valid given the current game state, 

5but never mutate the state themselves. 

6""" 

7 

8from __future__ import annotations 

9 

10from typing import TYPE_CHECKING 

11 

12from beaverbunch.core.card import FaceCard 

13 

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 

25 

26 

27class RuleViolation(Exception): 

28 """Raised when a game action violates the rules.""" 

29 

30 

31# --------------------------------------------------------------------------- 

32# Helpers 

33# --------------------------------------------------------------------------- 

34 

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 ) 

42 

43 

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

50 

51 

52def _current_player(state: GameState) -> Player: 

53 """Return the player whose turn it currently is.""" 

54 return state.players[state.current_player_index] 

55 

56 

57# --------------------------------------------------------------------------- 

58# Setup validations 

59# --------------------------------------------------------------------------- 

60 

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 ) 

71 

72 

73def validate_start(state: GameState, settings: GameSettings) -> None: 

74 """Validate that the game can be started.""" 

75 from beaverbunch.core.game import GamePhase 

76 

77 _require_phase(state, GamePhase.STARTING) 

78 validate_player_count(settings, len(state.players)) 

79 

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

84 

85 

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 

94 

95 _require_phase(state, GamePhase.STARTING) 

96 player = _get_player(state, player_id) 

97 

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 

108 

109 

110# --------------------------------------------------------------------------- 

111# Turn validations 

112# --------------------------------------------------------------------------- 

113 

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 

117 

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 

130 

131 

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 

135 

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 

148 

149 

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 

153 

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 

164 

165 

166def validate_discard_drawn(state: GameState, action: DiscardDrawnAction) -> Player: 

167 """Validate discarding the drawn card.""" 

168 from beaverbunch.core.game import GamePhase 

169 

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 

178 

179 

180# --------------------------------------------------------------------------- 

181# Bonus action validations 

182# --------------------------------------------------------------------------- 

183 

184def validate_peek(state: GameState, action: PeekOwnCardAction) -> Player: 

185 """Validate a Jack peek bonus action.""" 

186 from beaverbunch.core.game import GamePhase 

187 

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 

200 

201 

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 

205 

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 

223 

224 

225def validate_skip_bonus(state: GameState, player_id: str) -> Player: 

226 """Validate skipping a bonus action.""" 

227 from beaverbunch.core.game import GamePhase 

228 

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 

237 

238 

239# --------------------------------------------------------------------------- 

240# Snap validation (happens during WAITING_FOR_DRAW) 

241# --------------------------------------------------------------------------- 

242 

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 

246 

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 

254 

255 

256# --------------------------------------------------------------------------- 

257# Scoring 

258# --------------------------------------------------------------------------- 

259 

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 

265 

266 

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] 

276