1use std::collections::HashSet;
2use std::sync::Arc;
3
4use crate::board::Board;
5use crate::hex::hex_offsets;
6use crate::legal_moves::legal_moves;
7use crate::turn::TurnState;
8use crate::types::{Coord, Player};
9use crate::win::check_win;
10
11#[derive(Debug, Clone, Copy)]
13pub struct GameConfig {
14 pub win_length: u8,
16 pub placement_radius: i32,
18 pub max_moves: u32,
20}
21
22impl GameConfig {
23 pub const FULL_HEXO: GameConfig = GameConfig {
25 win_length: 6,
26 placement_radius: 8,
27 max_moves: 200,
28 };
29}
30
31#[derive(Debug, PartialEq, Eq)]
33pub enum MoveError {
34 GameOver,
36 CellOccupied,
38 OutOfRange,
40}
41
42#[derive(Clone)]
44pub struct GameState {
45 board: Board,
46 turn: TurnState,
47 config: GameConfig,
48 move_count: u32,
49 winner: Option<Player>,
50 offsets: Arc<[Coord]>,
52 cached_legal: HashSet<Coord>,
54}
55
56impl GameState {
57 pub fn new() -> Self {
59 Self::with_config(GameConfig::FULL_HEXO)
60 }
61
62 pub fn with_config(config: GameConfig) -> Self {
70 assert!(config.win_length >= 2, "win_length must be >= 2");
71 assert!(config.placement_radius >= 1, "placement_radius must be >= 1");
72 assert!(config.max_moves >= 1, "max_moves must be >= 1");
73
74 let board = Board::new();
75 let offsets: Arc<[Coord]> = hex_offsets(config.placement_radius).into();
76 let initial_legal: HashSet<Coord> =
77 legal_moves(&board, config.placement_radius).into_iter().collect();
78 GameState {
79 board,
80 turn: TurnState::P2Turn { moves_left: 2 },
81 config,
82 move_count: 0,
83 winner: None,
84 offsets,
85 cached_legal: initial_legal,
86 }
87 }
88
89 pub fn apply_move(&mut self, coord: Coord) -> Result<(), MoveError> {
91 if self.is_terminal() {
92 return Err(MoveError::GameOver);
93 }
94
95 if self.board.get(coord).is_some() {
96 return Err(MoveError::CellOccupied);
97 }
98
99 if !self.cached_legal.contains(&coord) {
100 return Err(MoveError::OutOfRange);
101 }
102
103 let player = self
104 .turn
105 .current_player()
106 .expect("turn has current player when not terminal");
107 self.board
108 .place(coord, player)
109 .expect("cell was verified empty");
110
111 self.cached_legal.remove(&coord);
113 let stones = self.board.stones();
114 for &(dq, dr) in self.offsets.iter() {
115 let cell = (coord.0 + dq, coord.1 + dr);
116 if !stones.contains_key(&cell) {
117 self.cached_legal.insert(cell);
118 }
119 }
120
121 self.move_count += 1;
122
123 let won = check_win(&self.board, coord, player, self.config.win_length);
124 if won {
125 self.winner = Some(player);
126 }
127
128 let draw = !won && self.move_count >= self.config.max_moves;
129 self.turn = self.turn.advance(won || draw);
130
131 Ok(())
132 }
133
134 pub fn legal_moves(&self) -> Vec<Coord> {
136 if self.is_terminal() {
137 return Vec::new();
138 }
139 let mut moves: Vec<Coord> = self.cached_legal.iter().copied().collect();
140 moves.sort_unstable();
141 moves
142 }
143
144 pub fn legal_move_count(&self) -> usize {
146 if self.is_terminal() { 0 } else { self.cached_legal.len() }
147 }
148
149 pub fn legal_moves_set(&self) -> &HashSet<Coord> {
152 &self.cached_legal
153 }
154
155 pub fn is_terminal(&self) -> bool {
157 self.turn == TurnState::GameOver
158 }
159
160 pub fn winner(&self) -> Option<Player> {
162 self.winner
163 }
164
165 pub fn current_player(&self) -> Option<Player> {
167 self.turn.current_player()
168 }
169
170 pub fn moves_remaining_this_turn(&self) -> u8 {
173 self.turn.moves_remaining().unwrap_or(0)
174 }
175
176 pub fn placed_stones(&self) -> Vec<(Coord, Player)> {
178 self.board
179 .stones()
180 .iter()
181 .map(|(&coord, &player)| (coord, player))
182 .collect()
183 }
184
185 pub fn move_count(&self) -> u32 {
187 self.move_count
188 }
189
190 pub fn config(&self) -> &GameConfig {
192 &self.config
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
205 fn initial_state_p2_to_move() {
206 let gs = GameState::new();
207 assert_eq!(gs.current_player(), Some(Player::P2));
208 }
209
210 #[test]
211 fn initial_state_two_moves_remaining() {
212 let gs = GameState::new();
213 assert_eq!(gs.moves_remaining_this_turn(), 2);
214 }
215
216 #[test]
217 fn initial_state_not_terminal() {
218 let gs = GameState::new();
219 assert!(!gs.is_terminal());
220 }
221
222 #[test]
223 fn initial_state_no_winner() {
224 let gs = GameState::new();
225 assert_eq!(gs.winner(), None);
226 }
227
228 #[test]
229 fn initial_state_p1_at_origin() {
230 let gs = GameState::new();
231 let stones = gs.placed_stones();
232 assert!(
233 stones.contains(&((0, 0), Player::P1)),
234 "expected P1 stone at origin, got {:?}",
235 stones
236 );
237 }
238
239 #[test]
244 fn apply_move_valid_decrements_moves_remaining() {
245 let mut gs = GameState::new();
246 gs.apply_move((1, 0)).unwrap();
247 assert_eq!(gs.moves_remaining_this_turn(), 1);
248 }
249
250 #[test]
251 fn apply_move_two_moves_switches_player_to_p1() {
252 let mut gs = GameState::new();
253 gs.apply_move((1, 0)).unwrap();
254 gs.apply_move((2, 0)).unwrap();
255 assert_eq!(gs.current_player(), Some(Player::P1));
256 }
257
258 #[test]
259 fn apply_move_occupied_returns_error() {
260 let mut gs = GameState::new();
261 let result = gs.apply_move((0, 0));
262 assert_eq!(result, Err(MoveError::CellOccupied));
263 }
264
265 #[test]
266 fn apply_move_out_of_range_returns_error() {
267 let mut gs = GameState::new();
268 let result = gs.apply_move((100, 100));
269 assert_eq!(result, Err(MoveError::OutOfRange));
270 }
271
272 #[test]
273 fn apply_move_after_game_over_returns_error() {
274 let config = GameConfig {
275 win_length: 4,
276 placement_radius: 8,
277 max_moves: 200,
278 };
279 let mut gs = GameState::with_config(config);
280 gs.apply_move((1, 0)).unwrap(); gs.apply_move((2, 0)).unwrap(); gs.apply_move((0, 3)).unwrap(); gs.apply_move((0, -3)).unwrap(); gs.apply_move((3, 0)).unwrap(); gs.apply_move((4, 0)).unwrap(); assert!(gs.is_terminal());
289 assert_eq!(gs.winner(), Some(Player::P2));
290 assert_eq!(gs.apply_move((5, 0)), Err(MoveError::GameOver));
291 }
292
293 #[test]
298 fn win_on_first_of_two_moves_ends_immediately() {
299 let config = GameConfig {
300 win_length: 4,
301 placement_radius: 8,
302 max_moves: 200,
303 };
304 let mut gs = GameState::with_config(config);
305 gs.apply_move((0, 1)).unwrap(); gs.apply_move((0, 2)).unwrap(); gs.apply_move((1, 1)).unwrap(); gs.apply_move((-1, -1)).unwrap(); gs.apply_move((0, 3)).unwrap(); assert!(!gs.is_terminal());
312 gs.apply_move((0, 4)).unwrap(); assert!(gs.is_terminal());
314 assert_eq!(gs.winner(), Some(Player::P2));
315 }
316
317 #[test]
322 fn draw_by_move_limit() {
323 let config = GameConfig {
324 win_length: 6,
325 placement_radius: 8,
326 max_moves: 4,
327 };
328 let mut gs = GameState::with_config(config);
329 gs.apply_move((1, 0)).unwrap();
330 gs.apply_move((0, 1)).unwrap();
331 gs.apply_move((-1, 0)).unwrap();
332 assert!(!gs.is_terminal());
333 gs.apply_move((0, -1)).unwrap(); assert!(gs.is_terminal());
335 assert_eq!(gs.winner(), None);
336 assert_eq!(gs.apply_move((1, 1)), Err(MoveError::GameOver));
337 }
338
339 #[test]
344 fn placed_cell_removed_from_legal_moves() {
345 let mut gs = GameState::new();
346 gs.apply_move((1, 0)).unwrap();
347 let moves = gs.legal_moves();
348 assert!(!moves.contains(&(1, 0)));
349 }
350
351 #[test]
352 fn placed_stones_grows() {
353 let mut gs = GameState::new();
354 assert_eq!(gs.placed_stones().len(), 1);
355 gs.apply_move((1, 0)).unwrap();
356 assert_eq!(gs.placed_stones().len(), 2);
357 gs.apply_move((0, 1)).unwrap();
358 assert_eq!(gs.placed_stones().len(), 3);
359 }
360
361 #[test]
362 fn clone_is_independent() {
363 let mut gs = GameState::new();
364 let gs2 = gs.clone();
365 gs.apply_move((1, 0)).unwrap();
366 assert_eq!(gs.placed_stones().len(), 2);
367 assert_eq!(gs2.placed_stones().len(), 1);
368 }
369
370 #[test]
371 fn custom_config_changes_legal_move_count() {
372 let config = GameConfig {
373 win_length: 4,
374 placement_radius: 4,
375 max_moves: 200,
376 };
377 let gs = GameState::with_config(config);
378 assert_eq!(gs.legal_moves().len(), 60);
379 }
380
381 #[test]
386 #[should_panic(expected = "win_length must be >= 2")]
387 fn invalid_config_win_length_zero() {
388 GameState::with_config(GameConfig { win_length: 0, placement_radius: 8, max_moves: 200 });
389 }
390
391 #[test]
392 #[should_panic(expected = "placement_radius must be >= 1")]
393 fn invalid_config_negative_radius() {
394 GameState::with_config(GameConfig { win_length: 6, placement_radius: -1, max_moves: 200 });
395 }
396
397 #[test]
398 #[should_panic(expected = "max_moves must be >= 1")]
399 fn invalid_config_zero_max_moves() {
400 GameState::with_config(GameConfig { win_length: 6, placement_radius: 8, max_moves: 0 });
401 }
402
403}