Skip to main content

xo_core/
game_engine.rs

1use crate::types::{Cell, GameState, MoveError, Player};
2
3/// The core Tic-Tac-Toe game engine.
4///
5/// This struct manages the board, enforces rules, and provides
6/// an unbeatable AI opponent using the Minimax algorithm if enabled.
7///
8/// # Board Representation
9/// The board is stored internally as a flat array of 9 cells.  
10/// Indices map to positions like this:
11///
12/// ```text
13///  0 | 1 | 2
14/// -----------
15///  3 | 4 | 5
16/// -----------
17///  6 | 7 | 8
18/// ```
19///
20/// # Game Modes
21/// - **Human vs Human:** Both players call [`make_move`] manually.
22/// - **Human vs AI:** calls [`make_move`], then queries
23///   [`get_best_move`] to find the AI’s move and applies it with [`make_move`].
24///
25/// # Examples
26///
27/// ## Human vs Human
28/// ```
29/// use xo_core::{GameEngine, Player};
30///
31/// let mut game = GameEngine::with_ai(false);
32///
33/// game.make_move(0).unwrap(); // X plays top-left
34/// game.make_move(4).unwrap(); // O plays center
35///
36/// assert_eq!(game.current_player, Player::X);
37/// ```
38///
39/// ## Human vs AI
40/// ```
41/// use xo_core::GameEngine;
42///
43/// let mut game = GameEngine::with_ai(true);
44///
45/// game.make_move(0).unwrap(); // Human plays top-left
46///
47/// if let Some(ai_move) = game.get_best_move() {
48///     game.make_move(ai_move).unwrap(); // Apply AI move
49/// }
50/// ```
51pub struct GameEngine {
52    board: [Cell; 9],
53    /// The player whose turn it is.
54    pub current_player: Player,
55    /// Whether the AI is enabled.
56    ///
57    /// - `true`: Single-player vs AI
58    /// - `false`: Human vs Human
59    pub ai_enabled: bool,
60}
61
62impl GameEngine {
63    /// Creates a new instance of the game engine with an empty board.
64    ///
65    /// The game always starts with `Player::X`.  
66    /// By default, AI is **enabled**.
67    ///
68    /// # Example
69    /// ```
70    /// use xo_core::{GameEngine, Player, Cell};
71    ///
72    /// let game = GameEngine::new();
73    /// assert_eq!(game.current_player, Player::X);
74    /// assert!(game.get_board().iter().all(|&c| c == Cell::Empty));
75    /// assert!(game.ai_enabled);
76    /// ```
77    pub fn new() -> Self {
78        Self {
79            board: [Cell::Empty; 9],
80            current_player: Player::X,
81            ai_enabled: true,
82        }
83    }
84
85    /// Creates a new instance of the game engine with an option to disable AI.
86    ///
87    /// # Parameters
88    /// - `ai_enabled`:  
89    ///   - `true`: Single-player vs AI  
90    ///   - `false`: Human vs Human
91    ///
92    /// # Example
93    /// ```
94    /// use xo_core::GameEngine;
95    ///
96    /// let game = GameEngine::with_ai(false);
97    /// assert!(!game.ai_enabled);
98    /// ```
99    pub fn with_ai(ai_enabled: bool) -> Self {
100        Self {
101            board: [Cell::Empty; 9],
102            current_player: Player::X,
103            ai_enabled,
104        }
105    }
106
107    /// Returns a reference to the current board.
108    pub fn get_board(&self) -> &[Cell; 9] {
109        &self.board
110    }
111
112    /// Attempts to make a move for the current player at the given board index.
113    ///
114    /// # Parameters
115    /// - `index`: The 0-based cell index (0–8).
116    ///
117    /// # Returns
118    /// - `Ok(())` if the move was made successfully.
119    /// - `Err(MoveError)` if the move is invalid:
120    ///   - `MoveError::OutOfBounds` if `index >= 9`
121    ///   - `MoveError::CellOccupied` if the cell already has a mark
122    ///
123    /// # Example
124    /// ```
125    /// use xo_core::{GameEngine, MoveError};
126    ///
127    /// let mut game = GameEngine::new();
128    /// assert!(game.make_move(0).is_ok());
129    /// assert_eq!(game.make_move(0), Err(MoveError::CellOccupied));
130    /// ```
131    pub fn make_move(&mut self, index: usize) -> Result<(), MoveError> {
132        // First, check if the index is within the valid range of the board.
133        if index >= 9 {
134            return Err(MoveError::OutOfBounds);
135        }
136
137        // Then, check if the cell is already occupied.
138        if self.board[index] != Cell::Empty {
139            return Err(MoveError::CellOccupied);
140        }
141
142        // Place the current player's mark on the board.
143        match self.current_player {
144            Player::X => self.board[index] = Cell::X,
145            Player::O => self.board[index] = Cell::O,
146        }
147
148        // Switch to the other player for the next turn.
149        self.current_player = self.current_player.opponent();
150        Ok(())
151    }
152
153    /// Returns the current state of the game.
154    ///
155    /// Possible values:
156    /// - `GameState::InProgress`
157    /// - `GameState::Tie`
158    /// - `GameState::Won(Player::X)`
159    /// - `GameState::Won(Player::O)`
160    pub fn check_state(&self) -> GameState {
161        Self::check_board_state(&self, self.board)
162    }
163
164    /// Returns `true` if the game is finished (either win or draw).
165    pub fn is_over(&self) -> bool {
166        !matches!(self.check_state(), GameState::InProgress)
167    }
168
169    /// Calculates the best move for the current player using Minimax with pruning.
170    ///
171    /// Returns:
172    /// - `Some(index)` for the best move when AI is enabled.
173    /// - `None` if the game is over or AI is disabled.
174    ///
175    /// # Example
176    /// ```
177    /// use xo_core::GameEngine;
178    ///
179    /// let game = GameEngine::with_ai(false);
180    /// assert_eq!(game.get_best_move(), None); // AI disabled
181    /// ```
182    /// # Example with AI
183    /// ```
184    /// use xo_core::GameEngine;
185    ///
186    /// let mut game = GameEngine::new();
187    /// game.make_move(0).unwrap(); // X
188    /// game.make_move(4).unwrap(); // O
189    /// game.make_move(1).unwrap(); // X
190    ///
191    /// // O should block X's winning move at index 2
192    /// assert_eq!(game.get_best_move(), Some(2));
193    /// ```
194    ///
195    pub fn get_best_move(&self) -> Option<usize> {
196        // If the game is over, no move can be made.
197        // furthermore, if AI is disabled, return None.
198        if !self.ai_enabled || self.is_over() {
199            return None;
200        }
201
202        let mut best_score = -i32::MAX;
203        let mut best_move: Option<usize> = None;
204
205        // The current player is the maximizing player for the Minimax algorithm.
206        let maximizing_player = self.current_player;
207
208        // Iterate through each cell on the board.
209        for i in 0..9 {
210            // Only consider empty cells as potential moves.
211            if self.board[i] == Cell::Empty {
212                // Create a temporary clone of the board to simulate the move.
213                let mut temp_board = self.board;
214                match maximizing_player {
215                    Player::X => temp_board[i] = Cell::X,
216                    Player::O => temp_board[i] = Cell::O,
217                }
218
219                // Recursively call the minimax function to evaluate the score of this move.
220                let score = self.minimax_with_pruning(
221                    temp_board,
222                    maximizing_player.opponent(),
223                    -i32::MAX,
224                    i32::MAX,
225                );
226
227                // If this move's score is better than the current best score,
228                // update the best score and the best move index.
229                if score > best_score {
230                    best_score = score;
231                    best_move = Some(i);
232                }
233            }
234        }
235        best_move
236    }
237
238    /// The Minimax algorithm with Alpha-Beta pruning, implemented recursively.
239    ///
240    /// This is a private helper method that evaluates the game tree to find the
241    /// best possible move.
242    /// - `board`: The current state of the game board.
243    /// - `player`: The player whose turn it is to evaluate.
244    /// - `alpha`: The best score for the maximizing player.
245    /// - `beta`: The best score for the minimizing player.
246    ///
247    /// Returns an integer score for the current board state.
248    fn minimax_with_pruning(
249        &self,
250        board: [Cell; 9],
251        player: Player,
252        mut alpha: i32,
253        mut beta: i32,
254    ) -> i32 {
255        // Check the state of the board and return a score if the game is over.
256        let state = self.check_board_state(board);
257        match state {
258            GameState::Win(winner) => {
259                // Return a positive score for a win, negative for a loss.
260                // The score is large to represent a definite win/loss.
261                return if winner == self.current_player {
262                    10
263                } else {
264                    -10
265                };
266            }
267            GameState::Tie => return 0,
268            GameState::InProgress => {}
269        }
270
271        // Find all available moves (empty cells).
272        let available_moves: Vec<usize> = board
273            .iter()
274            .enumerate()
275            .filter_map(
276                |(i, &cell)| {
277                    if cell == Cell::Empty { Some(i) } else { None }
278                },
279            )
280            .collect();
281
282        // If there are no available moves, it's a tie.
283        if available_moves.is_empty() {
284            return 0;
285        }
286
287        // The current player is either the maximizing or minimizing player in this subtree.
288        let current_player_is_maximizing = player == self.current_player;
289
290        if current_player_is_maximizing {
291            let mut max_eval = -i32::MAX;
292            for &move_index in &available_moves {
293                // Simulate the move.
294                let mut temp_board = board;
295                match player {
296                    Player::X => temp_board[move_index] = Cell::X,
297                    Player::O => temp_board[move_index] = Cell::O,
298                }
299
300                // Recursively call minimax for the opponent.
301                let eval = self.minimax_with_pruning(temp_board, player.opponent(), alpha, beta);
302
303                // Update the maximum score.
304                max_eval = max_eval.max(eval);
305
306                // Update alpha for the maximizing player.
307                alpha = alpha.max(eval);
308
309                // Alpha-beta pruning condition.
310                if beta <= alpha {
311                    break;
312                }
313            }
314            max_eval
315        } else {
316            let mut min_eval = i32::MAX;
317            for &move_index in &available_moves {
318                // Simulate the move.
319                let mut temp_board = board;
320                match player {
321                    Player::X => temp_board[move_index] = Cell::X,
322                    Player::O => temp_board[move_index] = Cell::O,
323                }
324
325                // Recursively call minimax for the opponent.
326                let eval = self.minimax_with_pruning(temp_board, player.opponent(), alpha, beta);
327
328                // Update the minimum score.
329                min_eval = min_eval.min(eval);
330
331                // Update beta for the minimizing player.
332                beta = beta.min(eval);
333
334                // Alpha-beta pruning condition.
335                if beta <= alpha {
336                    break;
337                }
338            }
339            min_eval
340        }
341    }
342
343    /// A helper function to check the state of a given board.
344    /// This is used internally by the Minimax algorithm.
345    fn check_board_state(&self, board: [Cell; 9]) -> GameState {
346        // Define all possible winning combinations (rows, columns, diagonals).
347        let winning_combinations = [
348            // Rows
349            [0, 1, 2],
350            [3, 4, 5],
351            [6, 7, 8],
352            // Columns
353            [0, 3, 6],
354            [1, 4, 7],
355            [2, 5, 8],
356            // Diagonals
357            [0, 4, 8],
358            [2, 4, 6],
359        ];
360
361        // Iterate through each winning combination to check for a win.
362        for combination in &winning_combinations {
363            let cell_1 = board[combination[0]];
364            let cell_2 = board[combination[1]];
365            let cell_3 = board[combination[2]];
366
367            // If the cells are not empty and all three are the same, we have a winner.
368            if cell_1 != Cell::Empty && cell_1 == cell_2 && cell_2 == cell_3 {
369                // Determine the winning player based on the cell's state.
370                return match cell_1 {
371                    Cell::X => GameState::Win(Player::X),
372                    Cell::O => GameState::Win(Player::O),
373                    _ => unreachable!(),
374                };
375            }
376        }
377
378        // If no winner is found, check if the board is full.
379        if !board.iter().any(|&cell| cell == Cell::Empty) {
380            return GameState::Tie;
381        }
382
383        // If neither a win nor a tie, the game is still ongoing.
384        GameState::InProgress
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*; // Import everything from this module
391
392    #[test]
393    fn x_can_win() {
394        let mut game = GameEngine::new();
395        game.make_move(0).unwrap(); // X
396        game.make_move(3).unwrap(); // O
397        game.make_move(1).unwrap(); // X
398        game.make_move(4).unwrap(); // O
399        game.make_move(2).unwrap(); // X wins
400        assert_eq!(game.check_state(), GameState::Win(Player::X));
401    }
402
403    #[test]
404    fn tie_game() {
405        let mut game = GameEngine::new();
406        let moves = [0, 1, 2, 4, 3, 5, 7, 6, 8];
407        for &i in &moves {
408            game.make_move(i).unwrap();
409        }
410        assert_eq!(game.check_state(), GameState::Tie);
411    }
412
413    #[test]
414    fn invalid_move_out_of_bounds() {
415        let mut game = GameEngine::new();
416        assert_eq!(game.make_move(9), Err(MoveError::OutOfBounds));
417    }
418
419    #[test]
420    fn invalid_move_occupied() {
421        let mut game = GameEngine::new();
422        game.make_move(0).unwrap();
423        assert_eq!(game.make_move(0), Err(MoveError::CellOccupied));
424    }
425
426    #[test]
427    fn minimax_ai_blocks_win() {
428        let mut game = GameEngine::new();
429        game.make_move(0).unwrap(); // X
430        game.make_move(4).unwrap(); // O
431        game.make_move(1).unwrap(); // X
432        // O (AI) should now block X at 2
433        assert_eq!(game.get_best_move(), Some(2));
434    }
435
436    #[test]
437    fn human_vs_human_mode() {
438        let game = GameEngine::with_ai(false);
439        assert_eq!(game.get_best_move(), None); // AI disabled
440    }
441}