Skip to main content

hexo_engine/
game.rs

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/// Configuration parameters for a HeXO game.
12#[derive(Debug, Clone, Copy)]
13pub struct GameConfig {
14    /// Number of consecutive stones needed to win.
15    pub win_length: u8,
16    /// Hex-distance radius within which moves are legal.
17    pub placement_radius: i32,
18    /// Maximum total stone placements before the game is a draw.
19    pub max_moves: u32,
20}
21
22impl GameConfig {
23    /// Standard HeXO configuration: 6-in-a-row, radius 8, 200 total moves.
24    pub const FULL_HEXO: GameConfig = GameConfig {
25        win_length: 6,
26        placement_radius: 8,
27        max_moves: 200,
28    };
29}
30
31/// Errors returned by `GameState::apply_move`.
32#[derive(Debug, PartialEq, Eq)]
33pub enum MoveError {
34    /// The game has already ended (win or draw).
35    GameOver,
36    /// The target cell is already occupied.
37    CellOccupied,
38    /// The target cell is outside the placement radius.
39    OutOfRange,
40}
41
42/// The full game state: board, whose turn it is, move counter, and outcome.
43#[derive(Clone)]
44pub struct GameState {
45    board: Board,
46    turn: TurnState,
47    config: GameConfig,
48    move_count: u32,
49    winner: Option<Player>,
50    /// Precomputed hex-circle offsets for the configured radius (shared across clones).
51    offsets: Arc<[Coord]>,
52    /// Cached set of legal moves, updated incrementally on each apply_move.
53    cached_legal: HashSet<Coord>,
54}
55
56impl GameState {
57    /// Creates a new game with the standard FULL_HEXO configuration.
58    pub fn new() -> Self {
59        Self::with_config(GameConfig::FULL_HEXO)
60    }
61
62    /// Creates a new game with a custom configuration.
63    ///
64    /// P1's first stone is placed at (0,0) by `Board::new()`.
65    /// The game starts with P2 to move with 2 moves remaining.
66    ///
67    /// # Panics
68    /// Panics if `win_length < 2`, `placement_radius < 1`, or `max_moves < 1`.
69    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    /// Attempts to place the current player's stone at `coord`.
90    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        // Update legal moves cache incrementally.
112        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    /// Returns all legal moves for the current position, sorted lexicographically.
135    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    /// Returns the number of legal moves without allocating.
145    pub fn legal_move_count(&self) -> usize {
146        if self.is_terminal() { 0 } else { self.cached_legal.len() }
147    }
148
149    /// Returns a reference to the internal legal moves set. Iteration order is
150    /// arbitrary (not sorted). Use this on hot paths where sorting is unnecessary.
151    pub fn legal_moves_set(&self) -> &HashSet<Coord> {
152        &self.cached_legal
153    }
154
155    /// Returns `true` when the game has ended (win or draw).
156    pub fn is_terminal(&self) -> bool {
157        self.turn == TurnState::GameOver
158    }
159
160    /// Returns the winner, or `None` if there is no winner (game ongoing or draw).
161    pub fn winner(&self) -> Option<Player> {
162        self.winner
163    }
164
165    /// Returns the player who should move next, or `None` if the game is over.
166    pub fn current_player(&self) -> Option<Player> {
167        self.turn.current_player()
168    }
169
170    /// Returns how many moves the current player has left this turn.
171    /// Returns 0 when the game is over.
172    pub fn moves_remaining_this_turn(&self) -> u8 {
173        self.turn.moves_remaining().unwrap_or(0)
174    }
175
176    /// Returns all placed stones as `(coord, player)` pairs.
177    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    /// Returns the total number of moves made so far (not counting P1's opening stone).
186    pub fn move_count(&self) -> u32 {
187        self.move_count
188    }
189
190    /// Returns a reference to the game configuration.
191    pub fn config(&self) -> &GameConfig {
192        &self.config
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    // ------------------------------------------------------------------
201    // 1. Initial state
202    // ------------------------------------------------------------------
203
204    #[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    // ------------------------------------------------------------------
240    // 2. apply_move basic
241    // ------------------------------------------------------------------
242
243    #[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        // P2 builds 4-in-a-row on q-axis: (1,0),(2,0),(3,0),(4,0)
281        // P1 plays scattered to avoid accidental wins
282        gs.apply_move((1, 0)).unwrap();  // P2
283        gs.apply_move((2, 0)).unwrap();  // P2
284        gs.apply_move((0, 3)).unwrap();  // P1
285        gs.apply_move((0, -3)).unwrap(); // P1
286        gs.apply_move((3, 0)).unwrap();  // P2
287        gs.apply_move((4, 0)).unwrap();  // P2 → 4-in-a-row → win
288        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    // ------------------------------------------------------------------
294    // 3. Win detection
295    // ------------------------------------------------------------------
296
297    #[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        // P2 builds along r-axis
306        gs.apply_move((0, 1)).unwrap();  // P2
307        gs.apply_move((0, 2)).unwrap();  // P2
308        gs.apply_move((1, 1)).unwrap();  // P1 scattered
309        gs.apply_move((-1, -1)).unwrap(); // P1 scattered
310        gs.apply_move((0, 3)).unwrap();  // P2 — 3 in a row
311        assert!(!gs.is_terminal());
312        gs.apply_move((0, 4)).unwrap();  // P2 — 4 in a row → win
313        assert!(gs.is_terminal());
314        assert_eq!(gs.winner(), Some(Player::P2));
315    }
316
317    // ------------------------------------------------------------------
318    // 4. Draw
319    // ------------------------------------------------------------------
320
321    #[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(); // 4th move → draw
334        assert!(gs.is_terminal());
335        assert_eq!(gs.winner(), None);
336        assert_eq!(gs.apply_move((1, 1)), Err(MoveError::GameOver));
337    }
338
339    // ------------------------------------------------------------------
340    // 5. Legal moves, placed_stones, clone, config
341    // ------------------------------------------------------------------
342
343    #[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    // ------------------------------------------------------------------
382    // 6. Config validation
383    // ------------------------------------------------------------------
384
385    #[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}