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}