From f1248500f1c4c6bcae96f437d60f3f3e491035de Mon Sep 17 00:00:00 2001 From: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> Date: Sun, 2 Nov 2025 19:50:15 +0100 Subject: [PATCH 01/15] chore: init project Signed-off-by: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> --- topics/tic-tac-toe/Cargo.lock | 7 +++++++ topics/tic-tac-toe/Cargo.toml | 6 ++++++ topics/tic-tac-toe/src/main.rs | 3 +++ 3 files changed, 16 insertions(+) create mode 100644 topics/tic-tac-toe/Cargo.lock create mode 100644 topics/tic-tac-toe/Cargo.toml create mode 100644 topics/tic-tac-toe/src/main.rs diff --git a/topics/tic-tac-toe/Cargo.lock b/topics/tic-tac-toe/Cargo.lock new file mode 100644 index 0000000..32be7da --- /dev/null +++ b/topics/tic-tac-toe/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "tic-tac-toe" +version = "0.1.0" diff --git a/topics/tic-tac-toe/Cargo.toml b/topics/tic-tac-toe/Cargo.toml new file mode 100644 index 0000000..a579fbf --- /dev/null +++ b/topics/tic-tac-toe/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "tic-tac-toe" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/topics/tic-tac-toe/src/main.rs b/topics/tic-tac-toe/src/main.rs new file mode 100644 index 0000000..e7a11a9 --- /dev/null +++ b/topics/tic-tac-toe/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} From fe1dd79cd1e75aba3a62583884ea571a2ca08718 Mon Sep 17 00:00:00 2001 From: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> Date: Sun, 2 Nov 2025 20:12:23 +0100 Subject: [PATCH 02/15] chore: add types Signed-off-by: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> --- topics/tic-tac-toe/src/main.rs | 5 +++++ topics/tic-tac-toe/src/player/ai_minmax.rs | 19 +++++++++++++++++++ topics/tic-tac-toe/src/player/mod.rs | 11 +++++++++++ topics/tic-tac-toe/src/player/terminal.rs | 18 ++++++++++++++++++ topics/tic-tac-toe/src/types.rs | 14 ++++++++++++++ 5 files changed, 67 insertions(+) create mode 100644 topics/tic-tac-toe/src/player/ai_minmax.rs create mode 100644 topics/tic-tac-toe/src/player/mod.rs create mode 100644 topics/tic-tac-toe/src/player/terminal.rs create mode 100644 topics/tic-tac-toe/src/types.rs diff --git a/topics/tic-tac-toe/src/main.rs b/topics/tic-tac-toe/src/main.rs index e7a11a9..fe3bfe9 100644 --- a/topics/tic-tac-toe/src/main.rs +++ b/topics/tic-tac-toe/src/main.rs @@ -1,3 +1,8 @@ +pub mod player; +pub mod types; + +pub use types::Result; + fn main() { println!("Hello, world!"); } diff --git a/topics/tic-tac-toe/src/player/ai_minmax.rs b/topics/tic-tac-toe/src/player/ai_minmax.rs new file mode 100644 index 0000000..3bcba87 --- /dev/null +++ b/topics/tic-tac-toe/src/player/ai_minmax.rs @@ -0,0 +1,19 @@ +use crate::{ + player::PlayerBehavior, + types::{Grid, Position}, +}; + +/// A player simulated using the Min-Max algorithm +pub struct AIMinMax; + +impl PlayerBehavior for AIMinMax { + fn game_start(&self) {} + + fn play(&self, grid: Grid) -> crate::Result { + todo!(); + } + + fn game_ended(&self, grid: Grid, winner: bool) { + todo!() + } +} diff --git a/topics/tic-tac-toe/src/player/mod.rs b/topics/tic-tac-toe/src/player/mod.rs new file mode 100644 index 0000000..0eb0948 --- /dev/null +++ b/topics/tic-tac-toe/src/player/mod.rs @@ -0,0 +1,11 @@ +use crate::types::{Grid, Position}; + +pub mod ai_minmax; +pub mod terminal; + +/// Represents a player that can play a [`crate::logic::game::Game`] +pub trait PlayerBehavior { + fn game_start(&self); + fn play(&self, grid: Grid) -> crate::Result; + fn game_ended(&self, grid: Grid, winner: bool); +} diff --git a/topics/tic-tac-toe/src/player/terminal.rs b/topics/tic-tac-toe/src/player/terminal.rs new file mode 100644 index 0000000..e286889 --- /dev/null +++ b/topics/tic-tac-toe/src/player/terminal.rs @@ -0,0 +1,18 @@ +use crate::{player::PlayerBehavior, types::{Grid, PlayerID, Position}}; + +/// A player that interacts via a terminal (stdout and stdin) +pub struct TerminalPlayer; + +impl PlayerBehavior for TerminalPlayer { + fn game_start(&self) { + println!("Game starts"); + } + + fn play(&self, grid: Grid) -> crate::Result { + todo!(); + } + + fn game_ended(&self, grid: Grid, winner: bool) { + todo!() + } +} \ No newline at end of file diff --git a/topics/tic-tac-toe/src/types.rs b/topics/tic-tac-toe/src/types.rs new file mode 100644 index 0000000..f420a78 --- /dev/null +++ b/topics/tic-tac-toe/src/types.rs @@ -0,0 +1,14 @@ +pub type Position = u8; + +pub type Grid = [Option; 9]; + +pub enum PlayerID { + Player1, + Player2, +} + +pub type Result = std::result::Result; +pub enum Error { + Other(String), + InvalidInput, +} From 1967f4853222207f223f4a859af8dd450c18146d Mon Sep 17 00:00:00 2001 From: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> Date: Sun, 2 Nov 2025 21:12:07 +0100 Subject: [PATCH 03/15] feat: implement terminal player Signed-off-by: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> --- topics/tic-tac-toe/src/player/terminal.rs | 46 +++++++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/topics/tic-tac-toe/src/player/terminal.rs b/topics/tic-tac-toe/src/player/terminal.rs index e286889..443dea9 100644 --- a/topics/tic-tac-toe/src/player/terminal.rs +++ b/topics/tic-tac-toe/src/player/terminal.rs @@ -1,18 +1,56 @@ -use crate::{player::PlayerBehavior, types::{Grid, PlayerID, Position}}; +use std::io::{Write, stdin, stdout}; + +use crate::{ + player::PlayerBehavior, + types::{Error, Grid, PlayerID, Position}, +}; /// A player that interacts via a terminal (stdout and stdin) pub struct TerminalPlayer; +fn read_position() -> crate::Result { + print!("Enter your move (0-8): "); + stdout().flush().map_err(|e| Error::Other(e.to_string()))?; + + let mut input = String::new(); + stdin() + .read_line(&mut input) + .map_err(|e| Error::Other(e.to_string()))?; + + match input.trim().parse::() { + Ok(num) if num < 9 => return Ok(num), + _ => Err(Error::InvalidInput), + } +} + impl PlayerBehavior for TerminalPlayer { fn game_start(&self) { println!("Game starts"); } fn play(&self, grid: Grid) -> crate::Result { - todo!(); + loop { + match read_position() { + Ok(pos) => { + if grid[pos as usize].is_none() { + return Ok(pos); + } else { + println!("Position {} is already taken. Try again.", pos); + } + } + Err(Error::InvalidInput) => { + println!("Invalid input. Please enter a number between 0 and 8."); + } + Err(e) => return Err(e), + } + } } fn game_ended(&self, grid: Grid, winner: bool) { - todo!() + if winner { + println!("You won!"); + } else { + println!("You lost!"); + } } -} \ No newline at end of file +} From 8a2ff2a7776b96ca1d1c5117368f598eeaac4bf7 Mon Sep 17 00:00:00 2001 From: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> Date: Sun, 2 Nov 2025 20:47:50 +0100 Subject: [PATCH 04/15] feat: implement AI using minmax Signed-off-by: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> --- topics/tic-tac-toe/src/logic.rs | 36 +++++++ topics/tic-tac-toe/src/main.rs | 1 + topics/tic-tac-toe/src/player/ai_minmax.rs | 106 +++++++++++++++++++-- topics/tic-tac-toe/src/player/mod.rs | 4 +- topics/tic-tac-toe/src/player/terminal.rs | 2 +- topics/tic-tac-toe/src/types.rs | 1 + 6 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 topics/tic-tac-toe/src/logic.rs diff --git a/topics/tic-tac-toe/src/logic.rs b/topics/tic-tac-toe/src/logic.rs new file mode 100644 index 0000000..ec2ec22 --- /dev/null +++ b/topics/tic-tac-toe/src/logic.rs @@ -0,0 +1,36 @@ +use crate::types::{Grid, PlayerID}; + +pub fn is_there_a_win(grid: Grid) -> Option { + // Check rows + for row in 0..3 { + if grid[row * 3].is_some() + && grid[row * 3] == grid[row * 3 + 1] + && grid[row * 3 + 1] == grid[row * 3 + 2] + { + return grid[row * 3]; + } + } + + // Check columns + for col in 0..3 { + if grid[col].is_some() && grid[col] == grid[col + 3] && grid[col + 3] == grid[col + 6] { + return grid[col]; + } + } + + // Check diagonals + if grid[0].is_some() && grid[0] == grid[4] && grid[4] == grid[8] { + return grid[0]; + } + + if grid[2].is_some() && grid[2] == grid[4] && grid[4] == grid[6] { + return grid[2]; + } + + None // No winner +} + +/// Check if there are any moves left on the board +pub fn are_there_moves_left(grid: Grid) -> bool { + grid.iter().any(|cell| cell.is_none()) +} diff --git a/topics/tic-tac-toe/src/main.rs b/topics/tic-tac-toe/src/main.rs index fe3bfe9..16ca058 100644 --- a/topics/tic-tac-toe/src/main.rs +++ b/topics/tic-tac-toe/src/main.rs @@ -1,3 +1,4 @@ +pub mod logic; pub mod player; pub mod types; diff --git a/topics/tic-tac-toe/src/player/ai_minmax.rs b/topics/tic-tac-toe/src/player/ai_minmax.rs index 3bcba87..5124a1c 100644 --- a/topics/tic-tac-toe/src/player/ai_minmax.rs +++ b/topics/tic-tac-toe/src/player/ai_minmax.rs @@ -1,19 +1,113 @@ use crate::{ + logic, player::PlayerBehavior, - types::{Grid, Position}, + types::{Grid, PlayerID, Position}, }; /// A player simulated using the Min-Max algorithm -pub struct AIMinMax; +pub struct AIMinMax { + ai_player: PlayerID, // (me) +} + +impl AIMinMax { + /// Minimax algorithm implementation + fn minimax( + &self, + mut grid: Grid, + depth: i32, + is_maximizing: bool, + ai_player: PlayerID, + opponent: PlayerID, + ) -> i32 { + // Check if there is a winner yet + match crate::logic::is_there_a_win(grid) { + // If AI has won, return score minus depth to prefer quicker wins + Some(winner) if winner == ai_player => { + return 10 - depth; + } + // If opponent has won, return score plus depth to delay losses + Some(winner) if winner == opponent => { + return -10 + depth; + } + _ => {} + }; + + // If no moves left, it's a tie + if !logic::are_there_moves_left(grid) { + return 0; + } + + if is_maximizing { + let mut best = i32::MIN; + + for i in 0..9 { + if grid[i].is_none() { + grid[i] = Some(ai_player); + let value = self.minimax(grid, depth + 1, false, ai_player, opponent); + grid[i] = None; + best = best.max(value); + } + } + best + } else { + let mut best = i32::MAX; + + for i in 0..9 { + if grid[i].is_none() { + grid[i] = Some(opponent); + let value = self.minimax(grid, depth + 1, true, ai_player, opponent); + grid[i] = None; + best = best.min(value); + } + } + best + } + } + + /// Find the best move using minimax algorithm + fn find_best_move(&self, mut grid: Grid, ai_player: PlayerID) -> Option { + let mut best_val = i32::MIN; + let mut best_move = None; + + let opponent = match ai_player { + PlayerID::Player1 => PlayerID::Player2, + PlayerID::Player2 => PlayerID::Player1, + }; + + for i in 0..9 { + if grid[i].is_none() { + grid[i] = Some(ai_player); // Simulate AI move + // After AI move, it's opponent's turn (so start with minimizing) + let move_val = self.minimax(grid, 0, false, ai_player, opponent); + grid[i] = None; // Reset move + + if move_val > best_val { + best_move = Some(i as Position); + best_val = move_val; + } + } + } + + best_move + } +} impl PlayerBehavior for AIMinMax { - fn game_start(&self) {} + fn game_start(&mut self, me: PlayerID) { + self.ai_player = me; + } fn play(&self, grid: Grid) -> crate::Result { - todo!(); + if let Some(best_move) = self.find_best_move(grid, self.ai_player) { + Ok(best_move) + } else { + Err(crate::types::Error::Other( + "No valid moves available".to_string(), + )) + } } - fn game_ended(&self, grid: Grid, winner: bool) { - todo!() + fn game_ended(&self, _grid: Grid, _winner: bool) { + // AI doesn't need to do anything when game ends } } diff --git a/topics/tic-tac-toe/src/player/mod.rs b/topics/tic-tac-toe/src/player/mod.rs index 0eb0948..b35228b 100644 --- a/topics/tic-tac-toe/src/player/mod.rs +++ b/topics/tic-tac-toe/src/player/mod.rs @@ -1,11 +1,11 @@ -use crate::types::{Grid, Position}; +use crate::types::{Grid, PlayerID, Position}; pub mod ai_minmax; pub mod terminal; /// Represents a player that can play a [`crate::logic::game::Game`] pub trait PlayerBehavior { - fn game_start(&self); + fn game_start(&mut self, me: PlayerID); fn play(&self, grid: Grid) -> crate::Result; fn game_ended(&self, grid: Grid, winner: bool); } diff --git a/topics/tic-tac-toe/src/player/terminal.rs b/topics/tic-tac-toe/src/player/terminal.rs index 443dea9..adc7e69 100644 --- a/topics/tic-tac-toe/src/player/terminal.rs +++ b/topics/tic-tac-toe/src/player/terminal.rs @@ -24,7 +24,7 @@ fn read_position() -> crate::Result { } impl PlayerBehavior for TerminalPlayer { - fn game_start(&self) { + fn game_start(&mut self, _me: PlayerID) { println!("Game starts"); } diff --git a/topics/tic-tac-toe/src/types.rs b/topics/tic-tac-toe/src/types.rs index f420a78..446c49c 100644 --- a/topics/tic-tac-toe/src/types.rs +++ b/topics/tic-tac-toe/src/types.rs @@ -2,6 +2,7 @@ pub type Position = u8; pub type Grid = [Option; 9]; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PlayerID { Player1, Player2, From b5bf5157fba37ddcdc417db42540c1e6bf21093a Mon Sep 17 00:00:00 2001 From: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> Date: Sun, 2 Nov 2025 21:04:50 +0100 Subject: [PATCH 05/15] refactor: move grid logic to logic/grid.rs Signed-off-by: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> --- topics/tic-tac-toe/src/logic/game.rs | 0 topics/tic-tac-toe/src/{logic.rs => logic/grid.rs} | 0 topics/tic-tac-toe/src/logic/mod.rs | 2 ++ topics/tic-tac-toe/src/player/ai_minmax.rs | 6 +++--- 4 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 topics/tic-tac-toe/src/logic/game.rs rename topics/tic-tac-toe/src/{logic.rs => logic/grid.rs} (100%) create mode 100644 topics/tic-tac-toe/src/logic/mod.rs diff --git a/topics/tic-tac-toe/src/logic/game.rs b/topics/tic-tac-toe/src/logic/game.rs new file mode 100644 index 0000000..e69de29 diff --git a/topics/tic-tac-toe/src/logic.rs b/topics/tic-tac-toe/src/logic/grid.rs similarity index 100% rename from topics/tic-tac-toe/src/logic.rs rename to topics/tic-tac-toe/src/logic/grid.rs diff --git a/topics/tic-tac-toe/src/logic/mod.rs b/topics/tic-tac-toe/src/logic/mod.rs new file mode 100644 index 0000000..08ce052 --- /dev/null +++ b/topics/tic-tac-toe/src/logic/mod.rs @@ -0,0 +1,2 @@ +pub mod game; +pub mod grid; diff --git a/topics/tic-tac-toe/src/player/ai_minmax.rs b/topics/tic-tac-toe/src/player/ai_minmax.rs index 5124a1c..a4ac18f 100644 --- a/topics/tic-tac-toe/src/player/ai_minmax.rs +++ b/topics/tic-tac-toe/src/player/ai_minmax.rs @@ -1,5 +1,5 @@ use crate::{ - logic, + logic::grid, player::PlayerBehavior, types::{Grid, PlayerID, Position}, }; @@ -20,7 +20,7 @@ impl AIMinMax { opponent: PlayerID, ) -> i32 { // Check if there is a winner yet - match crate::logic::is_there_a_win(grid) { + match grid::is_there_a_win(grid) { // If AI has won, return score minus depth to prefer quicker wins Some(winner) if winner == ai_player => { return 10 - depth; @@ -33,7 +33,7 @@ impl AIMinMax { }; // If no moves left, it's a tie - if !logic::are_there_moves_left(grid) { + if !grid::are_there_moves_left(grid) { return 0; } From 7a7b8736bbe0c3c96b4fea2dd3f7f6b3c69d0f1d Mon Sep 17 00:00:00 2001 From: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> Date: Sun, 2 Nov 2025 21:17:59 +0100 Subject: [PATCH 06/15] feat: implement Game Signed-off-by: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> --- topics/tic-tac-toe/src/logic/game.rs | 66 ++++++++++++++++++++++ topics/tic-tac-toe/src/main.rs | 15 ++++- topics/tic-tac-toe/src/player/ai_minmax.rs | 12 +++- topics/tic-tac-toe/src/player/terminal.rs | 32 ++++++----- 4 files changed, 108 insertions(+), 17 deletions(-) diff --git a/topics/tic-tac-toe/src/logic/game.rs b/topics/tic-tac-toe/src/logic/game.rs index e69de29..ba5db21 100644 --- a/topics/tic-tac-toe/src/logic/game.rs +++ b/topics/tic-tac-toe/src/logic/game.rs @@ -0,0 +1,66 @@ +use crate::{ + logic::grid, + player::PlayerBehavior, + types::{Grid, PlayerID}, +}; + +pub struct Game { + pub grid: Grid, + pub player1: T1, + pub player2: T2, +} + +impl Game { + pub fn new(player1: T1, player2: T2) -> Self { + Game { + grid: [None; 9], + player1, + player2, + } + } + + pub fn play(mut self) -> crate::Result> { + self.player1.game_start(crate::types::PlayerID::Player1); + self.player2.game_start(crate::types::PlayerID::Player2); + + let mut current_player: &dyn PlayerBehavior = &self.player1; + let mut current_player_id = crate::types::PlayerID::Player1; + + loop { + // Make current player play + match current_player.play(self.grid) { + Ok(position) => { + // Validate move + if self.grid[position as usize].is_none() { + self.grid[position as usize] = Some(current_player_id); + + // Win + if let Some(winner) = grid::is_there_a_win(self.grid) { + return Ok(Some(winner)); + } + + // Tie + if !crate::logic::grid::are_there_moves_left(self.grid) { + return Ok(None); + } + + // Switch players + if current_player_id == crate::types::PlayerID::Player1 { + current_player = &self.player2; + current_player_id = crate::types::PlayerID::Player2; + } else { + current_player = &self.player1; + current_player_id = crate::types::PlayerID::Player1; + } + } else { + // If move is invalid, ask the same player to play again + continue; + } + } + Err(err) => { + return Err(err); + } + } + } + } +} diff --git a/topics/tic-tac-toe/src/main.rs b/topics/tic-tac-toe/src/main.rs index 16ca058..0117aae 100644 --- a/topics/tic-tac-toe/src/main.rs +++ b/topics/tic-tac-toe/src/main.rs @@ -5,5 +5,18 @@ pub mod types; pub use types::Result; fn main() { - println!("Hello, world!"); + let p1 = player::terminal::TerminalPlayer::new(); + let p2 = player::ai_minmax::AIMinMax::new(); + let game = logic::game::Game::new(p1, p2); + match game.play() { + Ok(Some(winner)) => { + println!("Player {:?} wins!", winner); + } + Ok(None) => { + println!("It's a tie!"); + } + Err(_e) => { + eprintln!("An error occurred"); + } + } } diff --git a/topics/tic-tac-toe/src/player/ai_minmax.rs b/topics/tic-tac-toe/src/player/ai_minmax.rs index a4ac18f..aad635c 100644 --- a/topics/tic-tac-toe/src/player/ai_minmax.rs +++ b/topics/tic-tac-toe/src/player/ai_minmax.rs @@ -6,10 +6,16 @@ use crate::{ /// A player simulated using the Min-Max algorithm pub struct AIMinMax { - ai_player: PlayerID, // (me) + ai_player: Option, // (me) } impl AIMinMax { + pub fn new() -> Self { + AIMinMax { + ai_player: None + } + } + /// Minimax algorithm implementation fn minimax( &self, @@ -94,11 +100,11 @@ impl AIMinMax { impl PlayerBehavior for AIMinMax { fn game_start(&mut self, me: PlayerID) { - self.ai_player = me; + self.ai_player = Some(me); } fn play(&self, grid: Grid) -> crate::Result { - if let Some(best_move) = self.find_best_move(grid, self.ai_player) { + if let Some(best_move) = self.find_best_move(grid, self.ai_player.expect("self.ai_player should be set by game_start()")) { Ok(best_move) } else { Err(crate::types::Error::Other( diff --git a/topics/tic-tac-toe/src/player/terminal.rs b/topics/tic-tac-toe/src/player/terminal.rs index adc7e69..00c40fc 100644 --- a/topics/tic-tac-toe/src/player/terminal.rs +++ b/topics/tic-tac-toe/src/player/terminal.rs @@ -8,18 +8,24 @@ use crate::{ /// A player that interacts via a terminal (stdout and stdin) pub struct TerminalPlayer; -fn read_position() -> crate::Result { - print!("Enter your move (0-8): "); - stdout().flush().map_err(|e| Error::Other(e.to_string()))?; - - let mut input = String::new(); - stdin() - .read_line(&mut input) - .map_err(|e| Error::Other(e.to_string()))?; - - match input.trim().parse::() { - Ok(num) if num < 9 => return Ok(num), - _ => Err(Error::InvalidInput), +impl TerminalPlayer { + pub fn new() -> Self { + TerminalPlayer + } + + fn read_position(&self) -> crate::Result { + print!("Enter your move (0-8): "); + stdout().flush().map_err(|e| Error::Other(e.to_string()))?; + + let mut input = String::new(); + stdin() + .read_line(&mut input) + .map_err(|e| Error::Other(e.to_string()))?; + + match input.trim().parse::() { + Ok(num) if num < 9 => return Ok(num), + _ => Err(Error::InvalidInput), + } } } @@ -30,7 +36,7 @@ impl PlayerBehavior for TerminalPlayer { fn play(&self, grid: Grid) -> crate::Result { loop { - match read_position() { + match self.read_position() { Ok(pos) => { if grid[pos as usize].is_none() { return Ok(pos); From db6b7de6544c4a8012375aa8a2f8e9fcab908b40 Mon Sep 17 00:00:00 2001 From: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> Date: Sun, 2 Nov 2025 21:24:48 +0100 Subject: [PATCH 07/15] feat: implement player UI Signed-off-by: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> --- topics/tic-tac-toe/src/player/terminal.rs | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/topics/tic-tac-toe/src/player/terminal.rs b/topics/tic-tac-toe/src/player/terminal.rs index 00c40fc..11df729 100644 --- a/topics/tic-tac-toe/src/player/terminal.rs +++ b/topics/tic-tac-toe/src/player/terminal.rs @@ -27,6 +27,31 @@ impl TerminalPlayer { _ => Err(Error::InvalidInput), } } + + fn reset_screen(&self) { + // Clear the terminal screen + print!("\x1B[2J\x1B[H"); + stdout().flush().unwrap(); + } + + fn print_grid(&self, grid: Grid) { + self.reset_screen(); + + println!("You are playing Tic-Tac-Toe!"); + println!("X = You | O = Other player | numbers = Available Positions"); + println!(); + + for i in 0..9 { + match grid[i] { + Some(PlayerID::Player1) => print!(" X "), + Some(PlayerID::Player2) => print!(" O "), + None => print!(" {} ", i), + } + if i % 3 == 2 { + println!(); + } + } + } } impl PlayerBehavior for TerminalPlayer { @@ -35,6 +60,7 @@ impl PlayerBehavior for TerminalPlayer { } fn play(&self, grid: Grid) -> crate::Result { + self.print_grid(grid); loop { match self.read_position() { Ok(pos) => { @@ -53,6 +79,7 @@ impl PlayerBehavior for TerminalPlayer { } fn game_ended(&self, grid: Grid, winner: bool) { + self.print_grid(grid); if winner { println!("You won!"); } else { From d24753f2c86548d95932b7668baee7c1bc8731f2 Mon Sep 17 00:00:00 2001 From: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> Date: Sun, 2 Nov 2025 21:44:09 +0100 Subject: [PATCH 08/15] feat: make terminal player have coloried output Signed-off-by: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> --- topics/tic-tac-toe/src/logic/game.rs | 6 +-- topics/tic-tac-toe/src/player/ai_minmax.rs | 4 +- topics/tic-tac-toe/src/player/mod.rs | 4 +- topics/tic-tac-toe/src/player/terminal.rs | 51 +++++++++++++++++----- 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/topics/tic-tac-toe/src/logic/game.rs b/topics/tic-tac-toe/src/logic/game.rs index ba5db21..fcddbb5 100644 --- a/topics/tic-tac-toe/src/logic/game.rs +++ b/topics/tic-tac-toe/src/logic/game.rs @@ -23,7 +23,7 @@ impl Game { self.player1.game_start(crate::types::PlayerID::Player1); self.player2.game_start(crate::types::PlayerID::Player2); - let mut current_player: &dyn PlayerBehavior = &self.player1; + let mut current_player: &mut dyn PlayerBehavior = &mut self.player1; let mut current_player_id = crate::types::PlayerID::Player1; loop { @@ -46,10 +46,10 @@ impl Game { // Switch players if current_player_id == crate::types::PlayerID::Player1 { - current_player = &self.player2; + current_player = &mut self.player2; current_player_id = crate::types::PlayerID::Player2; } else { - current_player = &self.player1; + current_player = &mut self.player1; current_player_id = crate::types::PlayerID::Player1; } } else { diff --git a/topics/tic-tac-toe/src/player/ai_minmax.rs b/topics/tic-tac-toe/src/player/ai_minmax.rs index aad635c..947fc46 100644 --- a/topics/tic-tac-toe/src/player/ai_minmax.rs +++ b/topics/tic-tac-toe/src/player/ai_minmax.rs @@ -103,7 +103,7 @@ impl PlayerBehavior for AIMinMax { self.ai_player = Some(me); } - fn play(&self, grid: Grid) -> crate::Result { + fn play(&mut self, grid: Grid) -> crate::Result { if let Some(best_move) = self.find_best_move(grid, self.ai_player.expect("self.ai_player should be set by game_start()")) { Ok(best_move) } else { @@ -113,7 +113,7 @@ impl PlayerBehavior for AIMinMax { } } - fn game_ended(&self, _grid: Grid, _winner: bool) { + fn game_ended(&mut self, _grid: Grid, _winner: bool) { // AI doesn't need to do anything when game ends } } diff --git a/topics/tic-tac-toe/src/player/mod.rs b/topics/tic-tac-toe/src/player/mod.rs index b35228b..e75502f 100644 --- a/topics/tic-tac-toe/src/player/mod.rs +++ b/topics/tic-tac-toe/src/player/mod.rs @@ -6,6 +6,6 @@ pub mod terminal; /// Represents a player that can play a [`crate::logic::game::Game`] pub trait PlayerBehavior { fn game_start(&mut self, me: PlayerID); - fn play(&self, grid: Grid) -> crate::Result; - fn game_ended(&self, grid: Grid, winner: bool); + fn play(&mut self, grid: Grid) -> crate::Result; + fn game_ended(&mut self, grid: Grid, winner: bool); } diff --git a/topics/tic-tac-toe/src/player/terminal.rs b/topics/tic-tac-toe/src/player/terminal.rs index 11df729..d04bb8f 100644 --- a/topics/tic-tac-toe/src/player/terminal.rs +++ b/topics/tic-tac-toe/src/player/terminal.rs @@ -5,12 +5,23 @@ use crate::{ types::{Error, Grid, PlayerID, Position}, }; +const COLOR_GREEN: &str = "\x1b[32m"; +const COLOR_RED: &str = "\x1b[31m"; +const COLOR_BOLD: &str = "\x1b[1m"; +const COLOR_RESET: &str = "\x1b[0m"; + /// A player that interacts via a terminal (stdout and stdin) -pub struct TerminalPlayer; +pub struct TerminalPlayer { + old_grid: Option, + me: Option, +} impl TerminalPlayer { pub fn new() -> Self { - TerminalPlayer + TerminalPlayer { + old_grid: None, + me: None, + } } fn read_position(&self) -> crate::Result { @@ -34,6 +45,27 @@ impl TerminalPlayer { stdout().flush().unwrap(); } + fn prepare_cell_for_print(&self, grid: Grid, index: usize) -> String { + let me = self.me.expect("self.me should be set by game_start()"); + let s = match grid[index] { + // me + Some(player) if player == me => format!("{}X{}", COLOR_GREEN, COLOR_RESET), + // other player + Some(_) => format!("{}O{}", COLOR_RED, COLOR_RESET), + // not player (early return) + None => return format!("{}", index), + }; + + // If it was just placed, make it bold + if let Some(old_grid) = self.old_grid + && old_grid[index].is_none() + { + format!("{}{}", COLOR_BOLD, s) + } else { + s + } + } + fn print_grid(&self, grid: Grid) { self.reset_screen(); @@ -42,11 +74,7 @@ impl TerminalPlayer { println!(); for i in 0..9 { - match grid[i] { - Some(PlayerID::Player1) => print!(" X "), - Some(PlayerID::Player2) => print!(" O "), - None => print!(" {} ", i), - } + print!(" {} ", self.prepare_cell_for_print(grid, i)); if i % 3 == 2 { println!(); } @@ -55,16 +83,19 @@ impl TerminalPlayer { } impl PlayerBehavior for TerminalPlayer { - fn game_start(&mut self, _me: PlayerID) { + fn game_start(&mut self, me: PlayerID) { println!("Game starts"); + self.me = Some(me); } - fn play(&self, grid: Grid) -> crate::Result { + fn play(&mut self, grid: Grid) -> crate::Result { self.print_grid(grid); loop { match self.read_position() { Ok(pos) => { if grid[pos as usize].is_none() { + // Position validated + self.old_grid = Some(grid); return Ok(pos); } else { println!("Position {} is already taken. Try again.", pos); @@ -78,7 +109,7 @@ impl PlayerBehavior for TerminalPlayer { } } - fn game_ended(&self, grid: Grid, winner: bool) { + fn game_ended(&mut self, grid: Grid, winner: bool) { self.print_grid(grid); if winner { println!("You won!"); From 8f98d320a5e7291af1cd9d32eec04f998ad359b5 Mon Sep 17 00:00:00 2001 From: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> Date: Sun, 2 Nov 2025 21:51:49 +0100 Subject: [PATCH 09/15] fix: actually call game_ended() Signed-off-by: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> --- topics/tic-tac-toe/src/logic/game.rs | 11 +++++++++++ topics/tic-tac-toe/src/player/ai_minmax.rs | 2 +- topics/tic-tac-toe/src/player/mod.rs | 2 +- topics/tic-tac-toe/src/player/terminal.rs | 10 +++++----- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/topics/tic-tac-toe/src/logic/game.rs b/topics/tic-tac-toe/src/logic/game.rs index fcddbb5..dad015b 100644 --- a/topics/tic-tac-toe/src/logic/game.rs +++ b/topics/tic-tac-toe/src/logic/game.rs @@ -19,10 +19,21 @@ impl Game { } } + /// Call handlers, and run tic-tac-toe logic pub fn play(mut self) -> crate::Result> { self.player1.game_start(crate::types::PlayerID::Player1); self.player2.game_start(crate::types::PlayerID::Player2); + let winner = self.play_inner()?; + + self.player1.game_ended(self.grid, winner); + self.player2.game_ended(self.grid, winner); + + Ok(winner) + } + + /// Actual play logic, without calling handlers + pub fn play_inner(&mut self) -> crate::Result> { let mut current_player: &mut dyn PlayerBehavior = &mut self.player1; let mut current_player_id = crate::types::PlayerID::Player1; diff --git a/topics/tic-tac-toe/src/player/ai_minmax.rs b/topics/tic-tac-toe/src/player/ai_minmax.rs index 947fc46..f490efe 100644 --- a/topics/tic-tac-toe/src/player/ai_minmax.rs +++ b/topics/tic-tac-toe/src/player/ai_minmax.rs @@ -113,7 +113,7 @@ impl PlayerBehavior for AIMinMax { } } - fn game_ended(&mut self, _grid: Grid, _winner: bool) { + fn game_ended(&mut self, _grid: Grid, _winner: Option) { // AI doesn't need to do anything when game ends } } diff --git a/topics/tic-tac-toe/src/player/mod.rs b/topics/tic-tac-toe/src/player/mod.rs index e75502f..96fe61b 100644 --- a/topics/tic-tac-toe/src/player/mod.rs +++ b/topics/tic-tac-toe/src/player/mod.rs @@ -7,5 +7,5 @@ pub mod terminal; pub trait PlayerBehavior { fn game_start(&mut self, me: PlayerID); fn play(&mut self, grid: Grid) -> crate::Result; - fn game_ended(&mut self, grid: Grid, winner: bool); + fn game_ended(&mut self, grid: Grid, winner: Option); } diff --git a/topics/tic-tac-toe/src/player/terminal.rs b/topics/tic-tac-toe/src/player/terminal.rs index d04bb8f..2247f0c 100644 --- a/topics/tic-tac-toe/src/player/terminal.rs +++ b/topics/tic-tac-toe/src/player/terminal.rs @@ -109,12 +109,12 @@ impl PlayerBehavior for TerminalPlayer { } } - fn game_ended(&mut self, grid: Grid, winner: bool) { + fn game_ended(&mut self, grid: Grid, winner: Option) { self.print_grid(grid); - if winner { - println!("You won!"); - } else { - println!("You lost!"); + match winner { + None => println!("It's a tie!"), + Some(winner_id) if Some(winner_id) == self.me => println!("You won!"), + Some(_) => println!("You lost!"), } } } From e4be3bff9a53dc46d5e9abd60c753c56c87f4834 Mon Sep 17 00:00:00 2001 From: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> Date: Sun, 2 Nov 2025 21:54:55 +0100 Subject: [PATCH 10/15] use thiserror to format error Signed-off-by: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> --- topics/tic-tac-toe/Cargo.lock | 58 +++++++++++++++++++++++++++++++++ topics/tic-tac-toe/Cargo.toml | 1 + topics/tic-tac-toe/src/main.rs | 2 +- topics/tic-tac-toe/src/types.rs | 5 +++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/topics/tic-tac-toe/Cargo.lock b/topics/tic-tac-toe/Cargo.lock index 32be7da..e434e2a 100644 --- a/topics/tic-tac-toe/Cargo.lock +++ b/topics/tic-tac-toe/Cargo.lock @@ -2,6 +2,64 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tic-tac-toe" version = "0.1.0" +dependencies = [ + "thiserror", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" diff --git a/topics/tic-tac-toe/Cargo.toml b/topics/tic-tac-toe/Cargo.toml index a579fbf..801727e 100644 --- a/topics/tic-tac-toe/Cargo.toml +++ b/topics/tic-tac-toe/Cargo.toml @@ -4,3 +4,4 @@ version = "0.1.0" edition = "2024" [dependencies] +thiserror = "2.0.17" diff --git a/topics/tic-tac-toe/src/main.rs b/topics/tic-tac-toe/src/main.rs index 0117aae..d64f267 100644 --- a/topics/tic-tac-toe/src/main.rs +++ b/topics/tic-tac-toe/src/main.rs @@ -16,7 +16,7 @@ fn main() { println!("It's a tie!"); } Err(_e) => { - eprintln!("An error occurred"); + eprintln!("An error occurred: {:?}", _e); } } } diff --git a/topics/tic-tac-toe/src/types.rs b/topics/tic-tac-toe/src/types.rs index 446c49c..43a0108 100644 --- a/topics/tic-tac-toe/src/types.rs +++ b/topics/tic-tac-toe/src/types.rs @@ -1,3 +1,5 @@ +use thiserror::Error; + pub type Position = u8; pub type Grid = [Option; 9]; @@ -9,7 +11,10 @@ pub enum PlayerID { } pub type Result = std::result::Result; +#[derive(Debug, Error)] pub enum Error { + #[error("{0}")] Other(String), + #[error("Invalid input")] InvalidInput, } From b056deecc4b59347132be492ecbd7a87d7a23572 Mon Sep 17 00:00:00 2001 From: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> Date: Sun, 2 Nov 2025 21:59:42 +0100 Subject: [PATCH 11/15] chore: remove useless parameters from minmax() Signed-off-by: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> --- topics/tic-tac-toe/src/player/ai_minmax.rs | 48 +++++++++++----------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/topics/tic-tac-toe/src/player/ai_minmax.rs b/topics/tic-tac-toe/src/player/ai_minmax.rs index f490efe..6977ce6 100644 --- a/topics/tic-tac-toe/src/player/ai_minmax.rs +++ b/topics/tic-tac-toe/src/player/ai_minmax.rs @@ -11,28 +11,31 @@ pub struct AIMinMax { impl AIMinMax { pub fn new() -> Self { - AIMinMax { - ai_player: None + AIMinMax { ai_player: None } + } + + fn ai_player(&self) -> PlayerID { + self.ai_player + .expect("self.ai_player should be set by game_start()") + } + + fn opponent(&self) -> PlayerID { + match self.ai_player() { + PlayerID::Player1 => PlayerID::Player2, + PlayerID::Player2 => PlayerID::Player1, } } /// Minimax algorithm implementation - fn minimax( - &self, - mut grid: Grid, - depth: i32, - is_maximizing: bool, - ai_player: PlayerID, - opponent: PlayerID, - ) -> i32 { + fn minimax(&self, mut grid: Grid, depth: i32, is_maximizing: bool) -> i32 { // Check if there is a winner yet match grid::is_there_a_win(grid) { // If AI has won, return score minus depth to prefer quicker wins - Some(winner) if winner == ai_player => { + Some(winner) if winner == self.ai_player() => { return 10 - depth; } // If opponent has won, return score plus depth to delay losses - Some(winner) if winner == opponent => { + Some(_) => { return -10 + depth; } _ => {} @@ -48,8 +51,8 @@ impl AIMinMax { for i in 0..9 { if grid[i].is_none() { - grid[i] = Some(ai_player); - let value = self.minimax(grid, depth + 1, false, ai_player, opponent); + grid[i] = self.ai_player; + let value = self.minimax(grid, depth + 1, false); grid[i] = None; best = best.max(value); } @@ -60,8 +63,8 @@ impl AIMinMax { for i in 0..9 { if grid[i].is_none() { - grid[i] = Some(opponent); - let value = self.minimax(grid, depth + 1, true, ai_player, opponent); + grid[i] = Some(self.opponent()); + let value = self.minimax(grid, depth + 1, true); grid[i] = None; best = best.min(value); } @@ -71,20 +74,15 @@ impl AIMinMax { } /// Find the best move using minimax algorithm - fn find_best_move(&self, mut grid: Grid, ai_player: PlayerID) -> Option { + fn find_best_move(&self, mut grid: Grid) -> Option { let mut best_val = i32::MIN; let mut best_move = None; - let opponent = match ai_player { - PlayerID::Player1 => PlayerID::Player2, - PlayerID::Player2 => PlayerID::Player1, - }; - for i in 0..9 { if grid[i].is_none() { - grid[i] = Some(ai_player); // Simulate AI move + grid[i] = self.ai_player; // Simulate AI move // After AI move, it's opponent's turn (so start with minimizing) - let move_val = self.minimax(grid, 0, false, ai_player, opponent); + let move_val = self.minimax(grid, 0, false); grid[i] = None; // Reset move if move_val > best_val { @@ -104,7 +102,7 @@ impl PlayerBehavior for AIMinMax { } fn play(&mut self, grid: Grid) -> crate::Result { - if let Some(best_move) = self.find_best_move(grid, self.ai_player.expect("self.ai_player should be set by game_start()")) { + if let Some(best_move) = self.find_best_move(grid) { Ok(best_move) } else { Err(crate::types::Error::Other( From 4bfdbc5aab89cdad4fb17329081b8b9c946561a6 Mon Sep 17 00:00:00 2001 From: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> Date: Sun, 2 Nov 2025 22:00:05 +0100 Subject: [PATCH 12/15] chore: implement Default for players for linting Signed-off-by: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> --- topics/tic-tac-toe/src/player/ai_minmax.rs | 6 ++++++ topics/tic-tac-toe/src/player/terminal.rs | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/topics/tic-tac-toe/src/player/ai_minmax.rs b/topics/tic-tac-toe/src/player/ai_minmax.rs index 6977ce6..fb288a8 100644 --- a/topics/tic-tac-toe/src/player/ai_minmax.rs +++ b/topics/tic-tac-toe/src/player/ai_minmax.rs @@ -9,6 +9,12 @@ pub struct AIMinMax { ai_player: Option, // (me) } +impl Default for AIMinMax { + fn default() -> Self { + Self::new() + } +} + impl AIMinMax { pub fn new() -> Self { AIMinMax { ai_player: None } diff --git a/topics/tic-tac-toe/src/player/terminal.rs b/topics/tic-tac-toe/src/player/terminal.rs index 2247f0c..2bbf16e 100644 --- a/topics/tic-tac-toe/src/player/terminal.rs +++ b/topics/tic-tac-toe/src/player/terminal.rs @@ -16,6 +16,12 @@ pub struct TerminalPlayer { me: Option, } +impl Default for TerminalPlayer { + fn default() -> Self { + Self::new() + } +} + impl TerminalPlayer { pub fn new() -> Self { TerminalPlayer { @@ -34,7 +40,7 @@ impl TerminalPlayer { .map_err(|e| Error::Other(e.to_string()))?; match input.trim().parse::() { - Ok(num) if num < 9 => return Ok(num), + Ok(num) if num < 9 => Ok(num), _ => Err(Error::InvalidInput), } } From d51e7e055fce2fd34efce3a8317a16f290648fd4 Mon Sep 17 00:00:00 2001 From: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> Date: Sun, 2 Nov 2025 22:19:21 +0100 Subject: [PATCH 13/15] doc: add documentation markdown files Signed-off-by: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> --- topics/tic-tac-toe/docs/architecture.md | 16 ++++++++++++++++ topics/tic-tac-toe/docs/usage.md | 11 +++++++++++ 2 files changed, 27 insertions(+) create mode 100644 topics/tic-tac-toe/docs/architecture.md create mode 100644 topics/tic-tac-toe/docs/usage.md diff --git a/topics/tic-tac-toe/docs/architecture.md b/topics/tic-tac-toe/docs/architecture.md new file mode 100644 index 0000000..ca7911d --- /dev/null +++ b/topics/tic-tac-toe/docs/architecture.md @@ -0,0 +1,16 @@ +# Architecture + +## File and folder structure +- `src/logic/`: Logic code of tic-tac-toe. It contains the player iteration logic in `game.rs`, and tic-tac-toe grid logic in `grid.rs` +- `src/player`: Different implementations of players that can play the tic-tac-toe game. Currently, it contains a terminal player, which will be controlled by a player by their terminal, and an AI player, using the MinMax algorithm. +- `src/types.rs`: Contains pure data types used by the library + +# Objects +The main objects that will interact together in this library are the `Game` object, and `PlayerBehavior` objects. a Game will call methods on PlayerBehaviors, which simulate method. PlayerBehaviors may act on these methods to run diverse actions (e.g. printing information to the terminal), or communicate back to the `Game` (e.g. `play()` return value). + +An interesting property of Rust I've tried to use here is passing-by-movement. You will notice that the `Game` constructor takes ownership of players, and that `play()` takes ownership of `Game`. This allows me to enforce that players or games won't be re-used, and allow me to skip creating state checks to ensure these objects would behave correctly when misused this way. + +## Error handling +This project uses `thiserror` to help with error handling. This crate has been chosen because it allows us to create semantic error types, while not leaking itself into the interface provided by this library. + +Error handling is very limited due to the few cases in which returning an error would be acceptable. In a more complex library, we could use an associated object in `PlayerBehavior`, and make `Game::play` return either an associated object error from Player1 or Player2 using generics. diff --git a/topics/tic-tac-toe/docs/usage.md b/topics/tic-tac-toe/docs/usage.md new file mode 100644 index 0000000..46a733d --- /dev/null +++ b/topics/tic-tac-toe/docs/usage.md @@ -0,0 +1,11 @@ +# Integrating this into your application + +Note for Mr. Ortiz: this project wasn't decoupled into a separate library and binary crate because doing so would create an big commit with lots of files moving around, but clear contracts have been established. I am going to assume below that all files but main.rs are part of a library. + +You can use this library into your application by creating two players, (e.g. a `player::ai_minmax::AIMinMax` and a `player::ai_minmax::TerminalPlayer`), creating a game with `logic::game::Game::new()`, anv invoking `Game::play()` on your `Game` instance. A sample code is available [here](../src/main.rs) + +# Using the sample application provided +You can run a sample application by building the project with `cargo build --release`, and running the binary at `target/release/tic-tac-toe` +When run in this way, you will run against an AI using the MinMax algorithm to win. + +In the grid presented to you, numbers represent free cells that you can play in. Cells with X or O (which will be colored) represent cells controlled by you or the AI. You can play by entering the number corresponding to the cell you want to play in. From b336c4316de2bea1813dbf601ab3c12d73ac4aec Mon Sep 17 00:00:00 2001 From: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> Date: Sun, 2 Nov 2025 22:29:28 +0100 Subject: [PATCH 14/15] doc: add documentation to PlayerBehavior methods Signed-off-by: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> --- topics/tic-tac-toe/src/player/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/topics/tic-tac-toe/src/player/mod.rs b/topics/tic-tac-toe/src/player/mod.rs index 96fe61b..4c5439f 100644 --- a/topics/tic-tac-toe/src/player/mod.rs +++ b/topics/tic-tac-toe/src/player/mod.rs @@ -5,7 +5,11 @@ pub mod terminal; /// Represents a player that can play a [`crate::logic::game::Game`] pub trait PlayerBehavior { + /// Called by Game() when the game starts fn game_start(&mut self, me: PlayerID); + /// Called by Game() to get the player's next move. Implementation should return + /// a valid (free and 0-8) position, or play() will be called again with the same grid. fn play(&mut self, grid: Grid) -> crate::Result; + /// Called by Game() when the game ends fn game_ended(&mut self, grid: Grid, winner: Option); } From 942b547eed93760c99364d41bad91be5687adaa9 Mon Sep 17 00:00:00 2001 From: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> Date: Sun, 2 Nov 2025 22:59:17 +0100 Subject: [PATCH 15/15] test: add tests for MinMax AI Signed-off-by: Thomas Rubini <74205383+ThomasRubini@users.noreply.github.com> --- topics/tic-tac-toe/src/player/ai_minmax.rs | 81 ++++++++++++++++++++-- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/topics/tic-tac-toe/src/player/ai_minmax.rs b/topics/tic-tac-toe/src/player/ai_minmax.rs index fb288a8..90dd48d 100644 --- a/topics/tic-tac-toe/src/player/ai_minmax.rs +++ b/topics/tic-tac-toe/src/player/ai_minmax.rs @@ -1,8 +1,6 @@ -use crate::{ - logic::grid, - player::PlayerBehavior, - types::{Grid, PlayerID, Position}, -}; +use crate::types::{Grid, PlayerID}; + +use crate::{logic::grid, player::PlayerBehavior, types::Position}; /// A player simulated using the Min-Max algorithm pub struct AIMinMax { @@ -121,3 +119,76 @@ impl PlayerBehavior for AIMinMax { // AI doesn't need to do anything when game ends } } +#[cfg(test)] +mod tests { + use super::*; + + fn empty_grid() -> Grid { + [None; 9] + } + + #[test] + fn test_ai_chooses_winning_move() { + let mut ai = AIMinMax::new(); + ai.game_start(PlayerID::Player1); + let mut grid = empty_grid(); + grid[0] = Some(PlayerID::Player1); + grid[1] = Some(PlayerID::Player1); + grid[4] = Some(PlayerID::Player2); + let mv = ai.play(grid).unwrap(); + assert_eq!(mv, 2); + } + + #[test] + fn test_ai_blocks_opponent_win() { + // AI is Player2, must block Player1 at position 2 + let mut ai = AIMinMax::new(); + ai.game_start(PlayerID::Player2); + let mut grid = empty_grid(); + grid[0] = Some(PlayerID::Player1); + grid[1] = Some(PlayerID::Player1); + grid[4] = Some(PlayerID::Player2); + let mv = ai.play(grid).unwrap(); + assert_eq!(mv, 2); + } + + #[test] + fn test_ai_handles_full_board() { + // Board is full, no moves left + let mut ai = AIMinMax::new(); + ai.game_start(PlayerID::Player1); + let grid = [ + Some(PlayerID::Player1), + Some(PlayerID::Player2), + Some(PlayerID::Player1), + Some(PlayerID::Player1), + Some(PlayerID::Player2), + Some(PlayerID::Player1), + Some(PlayerID::Player2), + Some(PlayerID::Player1), + Some(PlayerID::Player2), + ]; + let res = ai.play(grid); + assert!(res.is_err()); + } + + #[test] + fn test_ai_chooses_draw_if_no_win_possible() { + // AI is Player1, only move left leads to draw + let mut ai = AIMinMax::new(); + ai.game_start(PlayerID::Player1); + let grid = [ + Some(PlayerID::Player1), + Some(PlayerID::Player2), + Some(PlayerID::Player1), + Some(PlayerID::Player1), + Some(PlayerID::Player2), + Some(PlayerID::Player2), + Some(PlayerID::Player2), + Some(PlayerID::Player1), + None, + ]; + let mv = ai.play(grid).unwrap(); + assert_eq!(mv, 8); + } +}