diff --git a/topics/tic-tac-toe/Cargo.lock b/topics/tic-tac-toe/Cargo.lock new file mode 100644 index 0000000..e434e2a --- /dev/null +++ b/topics/tic-tac-toe/Cargo.lock @@ -0,0 +1,65 @@ +# This file is automatically @generated by Cargo. +# 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 new file mode 100644 index 0000000..801727e --- /dev/null +++ b/topics/tic-tac-toe/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "tic-tac-toe" +version = "0.1.0" +edition = "2024" + +[dependencies] +thiserror = "2.0.17" 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. 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..dad015b --- /dev/null +++ b/topics/tic-tac-toe/src/logic/game.rs @@ -0,0 +1,77 @@ +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, + } + } + + /// 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; + + 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 = &mut self.player2; + current_player_id = crate::types::PlayerID::Player2; + } else { + current_player = &mut 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/logic/grid.rs b/topics/tic-tac-toe/src/logic/grid.rs new file mode 100644 index 0000000..ec2ec22 --- /dev/null +++ b/topics/tic-tac-toe/src/logic/grid.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/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/main.rs b/topics/tic-tac-toe/src/main.rs new file mode 100644 index 0000000..d64f267 --- /dev/null +++ b/topics/tic-tac-toe/src/main.rs @@ -0,0 +1,22 @@ +pub mod logic; +pub mod player; +pub mod types; + +pub use types::Result; + +fn main() { + 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: {:?}", _e); + } + } +} 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..90dd48d --- /dev/null +++ b/topics/tic-tac-toe/src/player/ai_minmax.rs @@ -0,0 +1,194 @@ +use crate::types::{Grid, PlayerID}; + +use crate::{logic::grid, player::PlayerBehavior, types::Position}; + +/// A player simulated using the Min-Max algorithm +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 } + } + + 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) -> 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 == self.ai_player() => { + return 10 - depth; + } + // If opponent has won, return score plus depth to delay losses + Some(_) => { + return -10 + depth; + } + _ => {} + }; + + // If no moves left, it's a tie + if !grid::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] = self.ai_player; + let value = self.minimax(grid, depth + 1, false); + 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(self.opponent()); + let value = self.minimax(grid, depth + 1, true); + grid[i] = None; + best = best.min(value); + } + } + best + } + } + + /// Find the best move using minimax algorithm + fn find_best_move(&self, mut grid: Grid) -> Option { + let mut best_val = i32::MIN; + let mut best_move = None; + + for i in 0..9 { + if grid[i].is_none() { + 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); + 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(&mut self, me: PlayerID) { + self.ai_player = Some(me); + } + + fn play(&mut self, grid: Grid) -> crate::Result { + if let Some(best_move) = self.find_best_move(grid) { + Ok(best_move) + } else { + Err(crate::types::Error::Other( + "No valid moves available".to_string(), + )) + } + } + + fn game_ended(&mut self, _grid: Grid, _winner: Option) { + // 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); + } +} 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..4c5439f --- /dev/null +++ b/topics/tic-tac-toe/src/player/mod.rs @@ -0,0 +1,15 @@ +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 { + /// 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); +} 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..2bbf16e --- /dev/null +++ b/topics/tic-tac-toe/src/player/terminal.rs @@ -0,0 +1,126 @@ +use std::io::{Write, stdin, stdout}; + +use crate::{ + player::PlayerBehavior, + 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 { + old_grid: Option, + me: Option, +} + +impl Default for TerminalPlayer { + fn default() -> Self { + Self::new() + } +} + +impl TerminalPlayer { + pub fn new() -> Self { + TerminalPlayer { + old_grid: None, + me: None, + } + } + + 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 => Ok(num), + _ => Err(Error::InvalidInput), + } + } + + fn reset_screen(&self) { + // Clear the terminal screen + print!("\x1B[2J\x1B[H"); + 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(); + + println!("You are playing Tic-Tac-Toe!"); + println!("X = You | O = Other player | numbers = Available Positions"); + println!(); + + for i in 0..9 { + print!(" {} ", self.prepare_cell_for_print(grid, i)); + if i % 3 == 2 { + println!(); + } + } + } +} + +impl PlayerBehavior for TerminalPlayer { + fn game_start(&mut self, me: PlayerID) { + println!("Game starts"); + self.me = Some(me); + } + + 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); + } + } + Err(Error::InvalidInput) => { + println!("Invalid input. Please enter a number between 0 and 8."); + } + Err(e) => return Err(e), + } + } + } + + fn game_ended(&mut self, grid: Grid, winner: Option) { + self.print_grid(grid); + match winner { + None => println!("It's a tie!"), + Some(winner_id) if Some(winner_id) == self.me => println!("You won!"), + Some(_) => println!("You lost!"), + } + } +} diff --git a/topics/tic-tac-toe/src/types.rs b/topics/tic-tac-toe/src/types.rs new file mode 100644 index 0000000..43a0108 --- /dev/null +++ b/topics/tic-tac-toe/src/types.rs @@ -0,0 +1,20 @@ +use thiserror::Error; + +pub type Position = u8; + +pub type Grid = [Option; 9]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlayerID { + Player1, + Player2, +} + +pub type Result = std::result::Result; +#[derive(Debug, Error)] +pub enum Error { + #[error("{0}")] + Other(String), + #[error("Invalid input")] + InvalidInput, +}