Skip to content

Commit 1f056f4

Browse files
committed
feat: add minimax AI agent (and remove some comments)
1 parent e31da95 commit 1f056f4

4 files changed

Lines changed: 248 additions & 60 deletions

File tree

topics/tic-tac-toe/src/ai.rs

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
use crate::board::{Board, Player, Cell};
2+
use crate::game::Game;
3+
4+
/// AI player that uses the Minimax algorithm to play optimally
5+
pub struct AI {
6+
player: Player,
7+
}
8+
9+
impl AI {
10+
/// Creates a new AI player
11+
pub fn new(player: Player) -> Self {
12+
Self { player }
13+
}
14+
15+
/// Gets the best move for the AI using the Minimax algorithm
16+
pub fn get_best_move(&self, game: &Game) -> Option<usize> {
17+
let available_moves = game.get_available_moves();
18+
if available_moves.is_empty() {
19+
return None;
20+
}
21+
22+
let mut best_score = i32::MIN;
23+
let mut best_move = None;
24+
25+
for &position in &available_moves {
26+
let mut game_copy = self.clone_game(game);
27+
28+
game_copy.make_move(position);
29+
30+
let score = self.minimax(&game_copy, 0, false);
31+
32+
if score > best_score {
33+
best_score = score;
34+
best_move = Some(position);
35+
}
36+
}
37+
38+
best_move
39+
}
40+
41+
/// Minimax algorithm implementation with depth-first search
42+
/// Returns the score for the current game state from the AI's perspective
43+
/// is_maximizing: true when it's AI's turn (maximize), false when opponent's turn (minimize)
44+
fn minimax(&self, game: &Game, depth: u32, is_maximizing: bool) -> i32 {
45+
// Check terminal states (game over)
46+
if let Some(winner) = game.check_winner() {
47+
return if winner == self.player {
48+
10 - depth as i32 // AI wins: prefer winning sooner (higher score for fewer moves)
49+
} else {
50+
depth as i32 - 10 // AI loses: prefer losing later (less negative score)
51+
};
52+
}
53+
54+
if game.is_game_over() {
55+
return 0;
56+
}
57+
58+
let available_moves = game.get_available_moves();
59+
60+
if is_maximizing {
61+
// AI's turn - maximize the score
62+
let mut max_score = i32::MIN;
63+
64+
for &position in &available_moves {
65+
let mut game_copy = self.clone_game(game);
66+
game_copy.make_move(position);
67+
68+
let score = self.minimax(&game_copy, depth + 1, false);
69+
max_score = max_score.max(score);
70+
}
71+
72+
max_score
73+
} else {
74+
// Opponent's turn - minimize the score (from AI's perspective)
75+
let mut min_score = i32::MAX;
76+
77+
for &position in &available_moves {
78+
let mut game_copy = self.clone_game(game);
79+
game_copy.make_move(position);
80+
81+
let score = self.minimax(&game_copy, depth + 1, true);
82+
min_score = min_score.min(score);
83+
}
84+
85+
min_score
86+
}
87+
}
88+
89+
/// Creates a copy of the game state for simulation
90+
/// This is necessary since Game doesn't implement Clone
91+
fn clone_game(&self, game: &Game) -> Game {
92+
let mut new_game = Game::new();
93+
94+
let board = game.get_board();
95+
96+
let mut moves = Vec::new();
97+
for position in 1..=9 {
98+
if let Some(Cell::Occupied(player)) = board.get_cell(position) {
99+
moves.push((position, player));
100+
}
101+
}
102+
103+
104+
let mut x_moves = Vec::new();
105+
let mut o_moves = Vec::new();
106+
107+
for (pos, player) in moves {
108+
match player {
109+
Player::X => x_moves.push(pos),
110+
Player::O => o_moves.push(pos),
111+
}
112+
}
113+
114+
let mut move_sequence = Vec::new();
115+
let max_len = x_moves.len().max(o_moves.len());
116+
117+
for i in 0..max_len {
118+
if i < x_moves.len() {
119+
move_sequence.push(x_moves[i]);
120+
}
121+
if i < o_moves.len() {
122+
move_sequence.push(o_moves[i]);
123+
}
124+
}
125+
126+
for &position in &move_sequence {
127+
new_game.make_move(position);
128+
}
129+
130+
new_game
131+
}
132+
}
133+
134+
#[cfg(test)]
135+
mod tests {
136+
use super::*;
137+
use crate::game::Game;
138+
139+
#[test]
140+
fn test_ai_creation() {
141+
let ai = AI::new(Player::O);
142+
assert_eq!(ai.player, Player::O);
143+
144+
let ai_x = AI::new(Player::X);
145+
assert_eq!(ai_x.player, Player::X);
146+
}
147+
148+
#[test]
149+
fn test_ai_blocks_winning_move() {
150+
let mut game = Game::new();
151+
let ai = AI::new(Player::O);
152+
153+
// X threatens to win in top row
154+
game.make_move(1); // X
155+
game.make_move(4); // O (AI's previous move)
156+
game.make_move(2); // X
157+
// Now X threatens to win at position 3
158+
159+
let ai_move = ai.get_best_move(&game);
160+
assert_eq!(ai_move, Some(3)); // AI should block at position 3
161+
}
162+
163+
#[test]
164+
fn test_ai_takes_winning_move() {
165+
let mut game = Game::new();
166+
let ai = AI::new(Player::O);
167+
168+
// Set up a scenario where AI can win
169+
game.make_move(1); // X
170+
game.make_move(4); // O
171+
game.make_move(2); // X
172+
game.make_move(5); // O
173+
game.make_move(7); // X
174+
// Now O can win at position 6
175+
176+
let ai_move = ai.get_best_move(&game);
177+
assert_eq!(ai_move, Some(6)); // AI should win at position 6
178+
}
179+
180+
#[test]
181+
fn test_ai_chooses_valid_move_on_empty_board() {
182+
let game = Game::new();
183+
let ai = AI::new(Player::X);
184+
185+
let ai_move = ai.get_best_move(&game);
186+
// On an empty board, any move should be valid (1-9)
187+
// The AI should choose some valid position
188+
assert!(ai_move.is_some());
189+
let position = ai_move.unwrap();
190+
assert!(position >= 1 && position <= 9);
191+
}
192+
193+
#[test]
194+
fn test_ai_no_moves_available() {
195+
let mut game = Game::new();
196+
let ai = AI::new(Player::O);
197+
198+
// Fill the board completely
199+
for position in 1..=9 {
200+
let player = if position % 2 == 1 { Player::X } else { Player::O };
201+
game.make_move(position);
202+
}
203+
204+
let ai_move = ai.get_best_move(&game);
205+
assert_eq!(ai_move, None); // No moves available
206+
}
207+
208+
#[test]
209+
fn test_minimax_prefers_winning_sooner() {
210+
let mut game = Game::new();
211+
let ai = AI::new(Player::O);
212+
213+
// Create a scenario where AI has multiple ways to win
214+
// AI should prefer the immediate win over a longer path to victory
215+
game.make_move(1); // X
216+
game.make_move(4); // O
217+
game.make_move(2); // X
218+
game.make_move(5); // O
219+
game.make_move(8); // X
220+
// AI can win immediately at position 6
221+
222+
let ai_move = ai.get_best_move(&game);
223+
assert_eq!(ai_move, Some(6)); // Should take the immediate win
224+
}
225+
}

0 commit comments

Comments
 (0)