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..56d7f5c --- /dev/null +++ b/topics/tic-tac-toe/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "tic-tac-toe" +version = "0.1.0" +edition = "2021" + +[dependencies] diff --git a/topics/tic-tac-toe/clippy.toml b/topics/tic-tac-toe/clippy.toml new file mode 100644 index 0000000..bbf47e2 --- /dev/null +++ b/topics/tic-tac-toe/clippy.toml @@ -0,0 +1,6 @@ +# Clippy configuration for strict linting + +# Deny common issues +cognitive-complexity-threshold = 15 +too-many-arguments-threshold = 5 +type-complexity-threshold = 150 diff --git a/topics/tic-tac-toe/docs/architecture.md b/topics/tic-tac-toe/docs/architecture.md new file mode 100644 index 0000000..945c2b4 --- /dev/null +++ b/topics/tic-tac-toe/docs/architecture.md @@ -0,0 +1,254 @@ +# Tic-Tac-Toe AI Agent - Architecture Documentation + +## 1. Project Definition + +### Overview + +A command-line tic-tac-toe game featuring an unbeatable AI opponent powered by the Minimax algorithm. The AI plays optimally, ensuring it never loses—the best outcome for a human player is a draw. + +### Goals + +1. **Optimal AI**: Minimax algorithm for mathematically perfect play +2. **Clean Architecture**: Modular design with clear separation of concerns +3. **Quality Code**: Zero warnings, comprehensive testing, proper documentation +4. **User Experience**: Interactive terminal interface with visual board representation + +### Technical Stack + +- **Language**: Rust +- **Game Logic**: 3×3 grid, 1D array representation +- **AI**: Minimax with depth-based scoring +- **Interface**: CLI with Unicode box-drawing +- **Testing**: Comprehensive unit and integration tests + +## 2. Components and Modules + +### Architecture Overview + +Layered architecture with clear separation between data, logic, AI, and presentation: + +``` +┌─────────────────────────────────────────────────┐ +│ main.rs │ +│ (Entry Point & CLI Wiring) │ +└──────────┬──────────────────────────────────────┘ + │ + ├──────────┬──────────┬──────────┐ + ▼ ▼ ▼ ▼ + ┌──────────┐ ┌────────┐ ┌────────┐ ┌──────────┐ + │ board/ │ │ game.rs│ │ ai/ │ │ render/ │ + │ │ │ │ │ │ │ │ + │ - mod.rs │ │ Rules │ │minimax │ │terminal │ + │ - lines │ │ Win │ │ Best │ │ Display │ + │ │ │ Check │ │ Move │ │ Input │ + └──────────┘ └────────┘ └────────┘ └──────────┘ + ▲ ▲ ▲ │ + │ │ │ │ + └────────────┴──────────┴───────────┘ + Dependencies +``` + +### Module Descriptions + +#### board/ - Core Data Structures + +**Purpose**: Game state representation + +**Components**: +- `mod.rs`: Core types (`Player`, `Cell`, `Board`) +- `lines.rs`: 8 winning combinations (3 rows, 3 cols, 2 diagonals) + +The board is represented as a fixed-size 1D array of 9 cells, where each cell can be empty or marked by a player (X/O). + +**Rationale**: +- **1D Array**: Simpler indexing, better cache locality than 2D +- **Copy Semantics**: Efficient cloning for Minimax tree exploration +- **Type Safety**: Enums prevent invalid states + +#### game.rs - Game Rules + +**Purpose**: Win detection and state management + +**Components**: +- `Outcome` enum: `InProgress | Win(Player) | Draw` +- `check_game_state()`: Evaluates board against 8 winning lines +- Pure functions with no side effects + +**Algorithm**: O(1) check of 8 pre-defined winning patterns + +#### ai/ - Minimax Algorithm + +**Purpose**: Optimal move selection + +**Implementation**: Classic Minimax with depth-first search + +``` +Minimax Recursion: + If terminal → return score + If maximizing (AI): + For each move: score = max(scores) + Else (Human): + For each move: score = min(scores) +``` + +**Scoring**: +- Win: `+10 - depth` (prefer faster wins) +- Loss: `depth - 10` (prefer slower losses) +- Draw: `0` + +**Complexity**: The search explores a small, finite game tree. For tic-tac-toe, the AI responds near-instantly. + +**Rationale**: +- State space is small enough for complete exploration +- Guarantees optimal play (AI never loses) +- Depth adjustment ensures strategic preference for shorter paths +- Alpha-beta pruning omitted (acceptable given performance); can be added as future work + +#### render/ - Terminal Interface + +**Purpose**: User interaction completely decoupled from logic + +**Components**: +- Board display with Unicode box-drawing (╔══╗) +- Input validation and error handling +- Screen management (clear, animations) + +**Rationale**: +- Easy to add alternative interfaces (web, GUI) +- Testable in isolation +- No game logic mixed with presentation + +### Module Interactions + +**Game Turn Flow**: +``` +1. Display board (render) +2. Check win condition (game) +3. Get human move (render → board) +4. Check win condition (game) +5. Calculate AI move (ai → board) +6. Loop to step 1 +``` + +### Design Justifications + +**Why This Architecture?** + +1. **Testability**: Each module tested independently + - 10 tests for board operations + - 12 tests for game rules (all win conditions) + - 10 tests for AI strategy + +2. **Maintainability**: Clear boundaries enable isolated changes + - Add alpha-beta pruning? Only touch `ai/minimax.rs` + - Add web UI? Create new renderer, reuse core logic + +3. **Reusability**: Core logic (board + game) is interface-agnostic + +4. **Rust Best Practices**: + - Small, focused modules (~100-200 lines) + - Public API clearly defined in `lib.rs` + - Private implementation hidden + +**Why 1D Array?** +- Simpler user input mapping (1-9 positions) +- Better cache locality +- Natural indexing + +**Why Separate `lines.rs`?** +- Configuration data separated from logic +- Easy to extend for different board sizes +- Keeps `board/mod.rs` focused + +## 3. Usage + +```bash +cargo run --release # Build and run +cargo test # Run all tests +cargo clippy # Check code quality +``` + +The game presents a 3×3 grid with numbered positions (1-9). Players enter their move, and the AI responds immediately with its optimal counter-move. + +## 4. Testing Strategy + +Each logical layer has its own unit tests: +- **board** – verifies win detection, legal moves, immutability +- **game** – ensures correct transitions between InProgress/Win/Draw +- **ai** – validates that the AI never loses and blocks human threats + +Integration tests combine these modules through full game scenarios. + +## 5. Quality & PR Standards + +### Quality Gates + +- `cargo fmt --all --check` — consistent formatting +- `cargo clippy --all-targets -- -D warnings` — zero lint warnings +- `cargo test --all` — full suite passing + +### Contribution & PR Standards + +The project follows Git best practices for submission: +- **Commit Messages**: Imperative mood, clear description of changes +- **Linear History**: No merge commits (rebase workflow) +- **Logical Narrative**: Commits represent coherent development steps +- **PR Structure**: Single pull request with all deliverables under `topics/tic-tac-toe/` + +### Implementation Notes + +**Rust Features Leveraged**: +- Enums with data for type-safe game state +- Pattern matching for clean logic flow +- Copy trait for efficient Minimax cloning +- Iterators for lazy evaluation +- Zero-cost abstractions (no runtime overhead) + +**Performance**: AI performs near-instantly for all moves + +## 6. Limitations and Future Work + +### Current Limitations + +1. **No Alpha-Beta Pruning**: Full tree exploration (acceptable for tic-tac-toe size) +2. **No Transposition Tables**: Repeated states re-evaluated +3. **Single-threaded**: Could parallelize top-level move exploration + +### Potential Enhancements + +1. **Alpha-Beta Pruning**: Reduce explored nodes by ~50% +2. **Move Ordering**: Evaluate center/corners first for better pruning +3. **Transposition Tables**: Cache evaluated positions +4. **Difficulty Levels**: Add intentionally sub-optimal modes +5. **Game History**: Move undo and replay functionality +6. **Statistics**: Track performance across multiple games + +### Architectural Extensibility + +The modular design makes future enhancements straightforward: +- **New AI algorithms**: Add to `ai/` alongside Minimax +- **Alternative interfaces**: Add to `render/` (web, TUI, GUI) +- **Different game variants**: Extend `board/` and `game.rs` + +All while preserving the core architecture and existing tests. + +--- + +**Project Repository Structure**: +``` +tic-tac-toe/ +├── Cargo.toml +├── clippy.toml # Strict linting configuration +├── src/ +│ ├── lib.rs # Public API surface +│ ├── main.rs # Entry point +│ ├── game.rs # Rules +│ ├── board/ # Game state +│ ├── ai/ # Minimax +│ └── render/ # Terminal UI +├── tests/ # Integration tests +└── docs/ + └── architecture.md # This document +``` + +This project illustrates how sound software engineering principles, modularity, testability, and separation of concerns can be applied to a classic AI problem within a concise Rust implementation. diff --git a/topics/tic-tac-toe/src/ai/minimax.rs b/topics/tic-tac-toe/src/ai/minimax.rs new file mode 100644 index 0000000..cb55d0d --- /dev/null +++ b/topics/tic-tac-toe/src/ai/minimax.rs @@ -0,0 +1,101 @@ +use crate::board::{Board, Player}; +use crate::game::{check_game_state, GameState}; + +/// Finds the best move for the AI using the Minimax algorithm +/// +/// The Minimax algorithm explores all possible game states to find the optimal move. +/// It assumes the opponent plays perfectly and chooses moves that minimize the +/// maximum possible loss. +/// +/// # Arguments +/// +/// * `board` - The current game board +/// * `ai_player` - The player for which to find the best move +/// +/// # Returns +/// +/// * `Some(position)` - The best position to play (0-8) +/// * `None` - If the board is full +pub fn find_best_move(board: &Board, ai_player: Player) -> Option { + let mut best_score = i32::MIN; + let mut best_move = None; + + for position in board.empty_positions() { + let mut board_copy = *board; + board_copy.set(position, ai_player); + + let score = minimax(&board_copy, 0, false, ai_player); + + if score > best_score { + best_score = score; + best_move = Some(position); + } + } + + best_move +} + +/// Minimax algorithm with depth tracking +/// +/// Recursively evaluates all possible game states to find the optimal move. +/// +/// # Scoring +/// +/// * +10 to -10: AI win (faster wins get higher scores) +/// * 0: Draw +/// * -10 to +10: Human win (slower losses get higher scores) +/// +/// # Arguments +/// +/// * `board` - Current board state +/// * `depth` - Current search depth +/// * `is_maximizing` - True if maximizing (AI's turn), false if minimizing (human's turn) +/// * `ai_player` - The AI player +/// +/// # Returns +/// +/// The score of the best move from this position +fn minimax(board: &Board, depth: i32, is_maximizing: bool, ai_player: Player) -> i32 { + let state = check_game_state(board); + + match state { + GameState::Won(player) => { + if player == ai_player { + return 10 - depth; // Prefer faster wins + } else { + return depth - 10; // Prefer slower losses + } + } + GameState::Draw => return 0, + GameState::InProgress => {} + } + + if is_maximizing { + // AI's turn - maximize score + let mut best_score = i32::MIN; + + for position in board.empty_positions() { + let mut board_copy = *board; + board_copy.set(position, ai_player); + + let score = minimax(&board_copy, depth + 1, false, ai_player); + best_score = best_score.max(score); + } + + best_score + } else { + // Human's turn - minimize score + let mut best_score = i32::MAX; + let human_player = ai_player.opponent(); + + for position in board.empty_positions() { + let mut board_copy = *board; + board_copy.set(position, human_player); + + let score = minimax(&board_copy, depth + 1, true, ai_player); + best_score = best_score.min(score); + } + + best_score + } +} diff --git a/topics/tic-tac-toe/src/ai/mod.rs b/topics/tic-tac-toe/src/ai/mod.rs new file mode 100644 index 0000000..1154136 --- /dev/null +++ b/topics/tic-tac-toe/src/ai/mod.rs @@ -0,0 +1,3 @@ +pub mod minimax; + +pub use minimax::find_best_move; diff --git a/topics/tic-tac-toe/src/board/lines.rs b/topics/tic-tac-toe/src/board/lines.rs new file mode 100644 index 0000000..adadf95 --- /dev/null +++ b/topics/tic-tac-toe/src/board/lines.rs @@ -0,0 +1,15 @@ +/// All possible winning combinations on a tic-tac-toe board +/// Includes rows, columns, and diagonals +pub const WINNING_LINES: [[usize; 3]; 8] = [ + // Rows + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + // Columns + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + // Diagonals + [0, 4, 8], + [2, 4, 6], +]; diff --git a/topics/tic-tac-toe/src/board/mod.rs b/topics/tic-tac-toe/src/board/mod.rs new file mode 100644 index 0000000..8ab1b98 --- /dev/null +++ b/topics/tic-tac-toe/src/board/mod.rs @@ -0,0 +1,123 @@ +pub mod lines; + +/// Represents a player in the game +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Player { + Human, + Ai, +} + +impl Player { + pub fn opponent(self) -> Self { + match self { + Player::Human => Player::Ai, + Player::Ai => Player::Human, + } + } + + /// Returns the symbol for this player + pub fn symbol(self) -> char { + match self { + Player::Human => 'X', + Player::Ai => 'O', + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Cell { + Empty, + Occupied(Player), +} + +impl Cell { + /// Returns true if the cell is empty + pub fn is_empty(self) -> bool { + matches!(self, Cell::Empty) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Board { + cells: [Cell; 9], +} + +impl Board { + pub fn new() -> Self { + Self { + cells: [Cell::Empty; 9], + } + } + + /// Returns the cell at the given position (0-8) + pub fn get(&self, position: usize) -> Option { + self.cells.get(position).copied() + } + + /// Sets the cell at the given position + /// Returns true if the move was valid (cell was empty), false otherwise + pub fn set(&mut self, position: usize, player: Player) -> bool { + if position >= 9 { + return false; + } + + if self.cells[position].is_empty() { + self.cells[position] = Cell::Occupied(player); + true + } else { + false + } + } + + /// Returns an iterator over all empty positions on the board + pub fn empty_positions(&self) -> impl Iterator + '_ { + self.cells + .iter() + .enumerate() + .filter_map(|(i, cell)| if cell.is_empty() { Some(i) } else { None }) + } + + /// Returns true if the board is full + pub fn is_full(&self) -> bool { + self.cells.iter().all(|cell| !cell.is_empty()) + } + + /// Displays the board (simple ASCII version) + #[allow(dead_code)] + pub fn display(&self) { + println!( + " {} | {} | {}", + self.cell_char(0), + self.cell_char(1), + self.cell_char(2) + ); + println!("-----------"); + println!( + " {} | {} | {}", + self.cell_char(3), + self.cell_char(4), + self.cell_char(5) + ); + println!("-----------"); + println!( + " {} | {} | {}", + self.cell_char(6), + self.cell_char(7), + self.cell_char(8) + ); + } + + /// Returns the character to display for a cell + fn cell_char(&self, position: usize) -> char { + match self.cells[position] { + Cell::Empty => (b'1' + position as u8) as char, + Cell::Occupied(player) => player.symbol(), + } + } +} + +impl Default for Board { + fn default() -> Self { + Self::new() + } +} diff --git a/topics/tic-tac-toe/src/game.rs b/topics/tic-tac-toe/src/game.rs new file mode 100644 index 0000000..e36d7c8 --- /dev/null +++ b/topics/tic-tac-toe/src/game.rs @@ -0,0 +1,39 @@ +use crate::board::{lines::WINNING_LINES, Board, Cell, Player}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GameState { + InProgress, + Won(Player), + Draw, +} + +pub fn check_game_state(board: &Board) -> GameState { + for line in &WINNING_LINES { + if let Some(winner) = check_line(board, line) { + return GameState::Won(winner); + } + } + + if board.is_full() { + return GameState::Draw; + } + + GameState::InProgress +} + +/// Checks if a line (row, column, or diagonal) has three matching occupied cells +/// Returns the player who won, or None if no winner on this line +fn check_line(board: &Board, line: &[usize; 3]) -> Option { + let cells = [ + board.get(line[0])?, + board.get(line[1])?, + board.get(line[2])?, + ]; + + match cells { + [Cell::Occupied(p1), Cell::Occupied(p2), Cell::Occupied(p3)] if p1 == p2 && p2 == p3 => { + Some(p1) + } + _ => None, + } +} diff --git a/topics/tic-tac-toe/src/lib.rs b/topics/tic-tac-toe/src/lib.rs new file mode 100644 index 0000000..6386328 --- /dev/null +++ b/topics/tic-tac-toe/src/lib.rs @@ -0,0 +1,17 @@ +//! Tic-Tac-Toe game library +//! +//! This library provides the core game logic for tic-tac-toe, +//! including board representation, game rules, AI with Minimax algorithm, +//! and terminal rendering. +//! +//! # Modules +//! +//! - [`board`] - Board representation and game state +//! - [`game`] - Game logic and win detection +//! - [`ai`] - AI algorithms (Minimax) +//! - [`render`] - Terminal UI rendering + +pub mod ai; +pub mod board; +pub mod game; +pub mod render; diff --git a/topics/tic-tac-toe/src/main.rs b/topics/tic-tac-toe/src/main.rs new file mode 100644 index 0000000..44461cc --- /dev/null +++ b/topics/tic-tac-toe/src/main.rs @@ -0,0 +1,71 @@ +/// Tic-Tac-Toe game with AI opponent using Minimax algorithm +/// +/// This program allows a human player to play against an AI opponent +/// that uses the Minimax algorithm to play optimally. +use tic_tac_toe::ai::find_best_move; +use tic_tac_toe::board::{Board, Player}; +use tic_tac_toe::game::{check_game_state, GameState}; +use tic_tac_toe::render::{ + ask_play_again, clear_screen, display_ai_thinking, display_board, display_game_status, + display_position_guide, display_title, get_player_move, +}; + +fn main() { + loop { + clear_screen(); + display_title(); + + if !run_game() { + break; + } + } + + println!(" Thanks for playing!"); + println!(); +} + +/// Runs a single game session +/// Returns true if the player wants to play again, false otherwise +fn run_game() -> bool { + let mut board = Board::new(); + let mut first_turn = true; + + loop { + clear_screen(); + display_title(); + + if first_turn { + display_position_guide(); + first_turn = false; + } + + display_board(&board); + + let state = check_game_state(&board); + if state != GameState::InProgress { + display_game_status(state); + return ask_play_again(); + } + + let position = get_player_move(&board); + board.set(position, Player::Human); + + let state = check_game_state(&board); + if state != GameState::InProgress { + clear_screen(); + display_title(); + display_board(&board); + display_game_status(state); + return ask_play_again(); + } + + clear_screen(); + display_title(); + display_board(&board); + display_ai_thinking(); + + if let Some(ai_position) = find_best_move(&board, Player::Ai) { + board.set(ai_position, Player::Ai); + } + } +} diff --git a/topics/tic-tac-toe/src/render/mod.rs b/topics/tic-tac-toe/src/render/mod.rs new file mode 100644 index 0000000..f43bbf9 --- /dev/null +++ b/topics/tic-tac-toe/src/render/mod.rs @@ -0,0 +1,3 @@ +pub mod terminal; + +pub use terminal::*; diff --git a/topics/tic-tac-toe/src/render/terminal.rs b/topics/tic-tac-toe/src/render/terminal.rs new file mode 100644 index 0000000..8862979 --- /dev/null +++ b/topics/tic-tac-toe/src/render/terminal.rs @@ -0,0 +1,183 @@ +use crate::board::{Board, Cell, Player}; +use crate::game::GameState; +use std::io::{self, Write}; + +/// Clears the terminal screen using ANSI escape codes +pub fn clear_screen() { + print!("\x1B[2J\x1B[1;1H"); + io::stdout().flush().unwrap(); +} + +/// Displays the game title banner +pub fn display_title() { + println!("╔═══════════════════════════════════════╗"); + println!("║ ║"); + println!("║ TIC-TAC-TOE ║"); + println!("║ ║"); + println!("╚═══════════════════════════════════════╝"); + println!(); +} + +/// Displays the current board state with Unicode box-drawing characters +pub fn display_board(board: &Board) { + println!(); + println!(" ╔═══╦═══╦═══╗"); + + for row in 0..3 { + print!(" ║"); + for col in 0..3 { + let pos = row * 3 + col; + let symbol = get_cell_display(board, pos); + print!(" {} ║", symbol); + } + println!(); + + if row < 2 { + println!(" ╠═══╬═══╬═══╣"); + } + } + + println!(" ╚═══╩═══╩═══╝"); + println!(); +} + +/// Displays the position guide to help players +pub fn display_position_guide() { + println!(" How to play: Enter a number from 1 to 9"); + println!(); + println!(" ╔═══╦═══╦═══╗"); + println!(" ║ 1 ║ 2 ║ 3 ║"); + println!(" ╠═══╬═══╬═══╣"); + println!(" ║ 4 ║ 5 ║ 6 ║"); + println!(" ╠═══╬═══╬═══╣"); + println!(" ║ 7 ║ 8 ║ 9 ║"); + println!(" ╚═══╩═══╩═══╝"); + println!(); +} + +/// Returns the display character for a cell +fn get_cell_display(board: &Board, position: usize) -> String { + match board.get(position) { + Some(Cell::Empty) => " ".to_string(), + Some(Cell::Occupied(Player::Human)) => "X".to_string(), + Some(Cell::Occupied(Player::Ai)) => "O".to_string(), + None => " ".to_string(), + } +} + +/// Prompts the player for their move and validates input +pub fn get_player_move(board: &Board) -> usize { + loop { + print!(" > Enter your move (1-9): "); + io::stdout().flush().unwrap(); + + let mut input = String::new(); + io::stdin() + .read_line(&mut input) + .expect("Failed to read input"); + + let position: usize = match input.trim().parse::() { + Ok(num) if (1..=9).contains(&num) => num - 1, + _ => { + println!(" Please enter a number between 1 and 9."); + continue; + } + }; + + if let Some(cell) = board.get(position) { + if cell.is_empty() { + return position; + } else { + println!(" That position is already occupied. Try another one."); + continue; + } + } + } +} + +/// Displays the game outcome +pub fn display_game_status(state: GameState) { + println!(); + match state { + GameState::InProgress => { + println!(" Game in progress..."); + } + GameState::Won(Player::Human) => { + println!(" ╔═══════════════════════════════════════╗"); + println!(" ║ ║"); + println!(" ║ You win! Great job! ║"); + println!(" ║ ║"); + println!(" ╚═══════════════════════════════════════╝"); + } + GameState::Won(Player::Ai) => { + println!(" ╔═══════════════════════════════════════╗"); + println!(" ║ ║"); + println!(" ║ You lost. Try again! ║"); + println!(" ║ ║"); + println!(" ╚═══════════════════════════════════════╝"); + } + GameState::Draw => { + println!(" ╔═══════════════════════════════════════╗"); + println!(" ║ ║"); + println!(" ║ Draw! Well played. ║"); + println!(" ║ ║"); + println!(" ╚═══════════════════════════════════════╝"); + } + } + println!(); +} + +/// Displays a move notification (unused but available) +#[allow(dead_code)] +pub fn display_move(player: Player, position: usize) { + let symbol = player.symbol(); + let player_name = match player { + Player::Human => "You", + Player::Ai => "Opponent", + }; + println!( + " {} placed {} at position {}", + player_name, + symbol, + position + 1 + ); + println!(); +} + +/// Asks if the player wants to play again +pub fn ask_play_again() -> bool { + loop { + print!(" > Play again? (y/n): "); + io::stdout().flush().unwrap(); + + let mut input = String::new(); + io::stdin() + .read_line(&mut input) + .expect("Failed to read input"); + + match input.trim().to_lowercase().as_str() { + "y" | "yes" => return true, + "n" | "no" => return false, + _ => println!(" Please enter 'y' or 'n'."), + } + } +} + +/// Displays a separator line (unused but available) +#[allow(dead_code)] +pub fn display_separator() { + println!(" ─────────────────────────────────────────"); +} + +/// Displays an animation while the AI is thinking +pub fn display_ai_thinking() { + print!(" Opponent is playing"); + io::stdout().flush().unwrap(); + + for _ in 0..3 { + std::thread::sleep(std::time::Duration::from_millis(200)); + print!("."); + io::stdout().flush().unwrap(); + } + println!(); +} diff --git a/topics/tic-tac-toe/tests/ai_tests.rs b/topics/tic-tac-toe/tests/ai_tests.rs new file mode 100644 index 0000000..d8de2fd --- /dev/null +++ b/topics/tic-tac-toe/tests/ai_tests.rs @@ -0,0 +1,129 @@ +use tic_tac_toe::ai::find_best_move; +use tic_tac_toe::board::{Board, Player}; +use tic_tac_toe::game::{check_game_state, GameState}; + +#[test] +fn test_ai_wins_when_possible() { + let mut board = Board::new(); + board.set(0, Player::Ai); + board.set(1, Player::Ai); + board.set(3, Player::Human); + board.set(4, Player::Human); + + let best_move = find_best_move(&board, Player::Ai); + assert_eq!(best_move, Some(2)); + + board.set(2, Player::Ai); + assert_eq!(check_game_state(&board), GameState::Won(Player::Ai)); +} + +#[test] +fn test_ai_blocks_human_win() { + let mut board = Board::new(); + board.set(0, Player::Human); + board.set(1, Player::Human); + board.set(3, Player::Ai); + + let best_move = find_best_move(&board, Player::Ai); + assert_eq!(best_move, Some(2)); +} + +#[test] +fn test_ai_takes_center_on_empty_board() { + let board = Board::new(); + + let best_move = find_best_move(&board, Player::Ai); + assert!(best_move.is_some()); + + let position = best_move.unwrap(); + assert!(position < 9); +} + +#[test] +fn test_ai_blocks_diagonal_win() { + let mut board = Board::new(); + board.set(0, Player::Human); + board.set(4, Player::Human); + + let best_move = find_best_move(&board, Player::Ai); + assert_eq!(best_move, Some(8)); +} + +#[test] +fn test_ai_creates_fork() { + let mut board = Board::new(); + board.set(0, Player::Human); + board.set(4, Player::Ai); + + let best_move = find_best_move(&board, Player::Ai); + assert!(best_move.is_some()); +} + +#[test] +fn test_ai_prioritizes_immediate_win_over_block() { + let mut board = Board::new(); + board.set(0, Player::Ai); + board.set(1, Player::Ai); + board.set(3, Player::Human); + board.set(4, Player::Human); + + let best_move = find_best_move(&board, Player::Ai); + assert_eq!(best_move, Some(2)); +} + +#[test] +fn test_ai_blocks_vertical_win() { + let mut board = Board::new(); + board.set(0, Player::Human); + board.set(3, Player::Human); + board.set(5, Player::Ai); + + let best_move = find_best_move(&board, Player::Ai); + assert_eq!(best_move, Some(6)); +} + +#[test] +fn test_ai_finds_move_on_almost_full_board() { + let mut board = Board::new(); + board.set(0, Player::Human); + board.set(1, Player::Ai); + board.set(2, Player::Human); + board.set(3, Player::Ai); + board.set(4, Player::Human); + board.set(5, Player::Ai); + board.set(6, Player::Ai); + board.set(7, Player::Human); + + let best_move = find_best_move(&board, Player::Ai); + assert_eq!(best_move, Some(8)); +} + +#[test] +fn test_ai_returns_none_on_full_board() { + let mut board = Board::new(); + for i in 0..9 { + board.set( + i, + if i % 2 == 0 { + Player::Human + } else { + Player::Ai + }, + ); + } + + let best_move = find_best_move(&board, Player::Ai); + assert_eq!(best_move, None); +} + +#[test] +fn test_ai_wins_anti_diagonal() { + let mut board = Board::new(); + board.set(2, Player::Ai); + board.set(4, Player::Ai); + board.set(3, Player::Human); + board.set(5, Player::Human); + + let best_move = find_best_move(&board, Player::Ai); + assert_eq!(best_move, Some(6)); +} diff --git a/topics/tic-tac-toe/tests/board_tests.rs b/topics/tic-tac-toe/tests/board_tests.rs new file mode 100644 index 0000000..e1f5bd0 --- /dev/null +++ b/topics/tic-tac-toe/tests/board_tests.rs @@ -0,0 +1,85 @@ +use tic_tac_toe::board::{Board, Cell, Player}; + +#[test] +fn test_new_board_is_empty() { + let board = Board::new(); + for i in 0..9 { + assert_eq!(board.get(i), Some(Cell::Empty)); + } +} + +#[test] +fn test_set_and_get() { + let mut board = Board::new(); + assert!(board.set(0, Player::Human)); + assert_eq!(board.get(0), Some(Cell::Occupied(Player::Human))); +} + +#[test] +fn test_cannot_overwrite_cell() { + let mut board = Board::new(); + assert!(board.set(0, Player::Human)); + assert!(!board.set(0, Player::Ai)); + assert_eq!(board.get(0), Some(Cell::Occupied(Player::Human))); +} + +#[test] +fn test_empty_positions() { + let mut board = Board::new(); + board.set(0, Player::Human); + board.set(4, Player::Ai); + + let empty: Vec = board.empty_positions().collect(); + assert_eq!(empty, vec![1, 2, 3, 5, 6, 7, 8]); +} + +#[test] +fn test_is_full() { + let mut board = Board::new(); + assert!(!board.is_full()); + + for i in 0..9 { + board.set( + i, + if i % 2 == 0 { + Player::Human + } else { + Player::Ai + }, + ); + } + assert!(board.is_full()); +} + +#[test] +fn test_player_opponent() { + assert_eq!(Player::Human.opponent(), Player::Ai); + assert_eq!(Player::Ai.opponent(), Player::Human); +} + +#[test] +fn test_player_symbol() { + assert_eq!(Player::Human.symbol(), 'X'); + assert_eq!(Player::Ai.symbol(), 'O'); +} + +#[test] +fn test_cell_is_empty() { + assert!(Cell::Empty.is_empty()); + assert!(!Cell::Occupied(Player::Human).is_empty()); + assert!(!Cell::Occupied(Player::Ai).is_empty()); +} + +#[test] +fn test_board_get_out_of_bounds() { + let board = Board::new(); + assert_eq!(board.get(9), None); + assert_eq!(board.get(100), None); +} + +#[test] +fn test_board_set_out_of_bounds() { + let mut board = Board::new(); + assert!(!board.set(9, Player::Human)); + assert!(!board.set(100, Player::Human)); +} diff --git a/topics/tic-tac-toe/tests/game_tests.rs b/topics/tic-tac-toe/tests/game_tests.rs new file mode 100644 index 0000000..27fa7e9 --- /dev/null +++ b/topics/tic-tac-toe/tests/game_tests.rs @@ -0,0 +1,128 @@ +use tic_tac_toe::board::{Board, Player}; +use tic_tac_toe::game::{check_game_state, GameState}; + +#[test] +fn test_initial_state() { + let board = Board::new(); + assert_eq!(check_game_state(&board), GameState::InProgress); +} + +#[test] +fn test_horizontal_win_top() { + let mut board = Board::new(); + board.set(0, Player::Human); + board.set(1, Player::Human); + board.set(2, Player::Human); + + assert_eq!(check_game_state(&board), GameState::Won(Player::Human)); +} + +#[test] +fn test_horizontal_win_middle() { + let mut board = Board::new(); + board.set(3, Player::Ai); + board.set(4, Player::Ai); + board.set(5, Player::Ai); + + assert_eq!(check_game_state(&board), GameState::Won(Player::Ai)); +} + +#[test] +fn test_horizontal_win_bottom() { + let mut board = Board::new(); + board.set(6, Player::Human); + board.set(7, Player::Human); + board.set(8, Player::Human); + + assert_eq!(check_game_state(&board), GameState::Won(Player::Human)); +} + +#[test] +fn test_vertical_win_left() { + let mut board = Board::new(); + board.set(0, Player::Ai); + board.set(3, Player::Ai); + board.set(6, Player::Ai); + + assert_eq!(check_game_state(&board), GameState::Won(Player::Ai)); +} + +#[test] +fn test_vertical_win_middle() { + let mut board = Board::new(); + board.set(1, Player::Human); + board.set(4, Player::Human); + board.set(7, Player::Human); + + assert_eq!(check_game_state(&board), GameState::Won(Player::Human)); +} + +#[test] +fn test_vertical_win_right() { + let mut board = Board::new(); + board.set(2, Player::Ai); + board.set(5, Player::Ai); + board.set(8, Player::Ai); + + assert_eq!(check_game_state(&board), GameState::Won(Player::Ai)); +} + +#[test] +fn test_diagonal_win_main() { + let mut board = Board::new(); + board.set(0, Player::Human); + board.set(4, Player::Human); + board.set(8, Player::Human); + + assert_eq!(check_game_state(&board), GameState::Won(Player::Human)); +} + +#[test] +fn test_diagonal_win_anti() { + let mut board = Board::new(); + board.set(2, Player::Ai); + board.set(4, Player::Ai); + board.set(6, Player::Ai); + + assert_eq!(check_game_state(&board), GameState::Won(Player::Ai)); +} + +#[test] +fn test_draw() { + let mut board = Board::new(); + board.set(0, Player::Human); + board.set(1, Player::Ai); + board.set(2, Player::Human); + board.set(3, Player::Human); + board.set(4, Player::Ai); + board.set(5, Player::Ai); + board.set(6, Player::Ai); + board.set(7, Player::Human); + board.set(8, Player::Human); + + assert_eq!(check_game_state(&board), GameState::Draw); +} + +#[test] +fn test_in_progress() { + let mut board = Board::new(); + board.set(0, Player::Human); + board.set(1, Player::Ai); + + assert_eq!(check_game_state(&board), GameState::InProgress); +} + +#[test] +fn test_in_progress_almost_full() { + let mut board = Board::new(); + board.set(0, Player::Human); + board.set(1, Player::Ai); + board.set(2, Player::Human); + board.set(3, Player::Ai); + board.set(4, Player::Human); + board.set(5, Player::Ai); + board.set(6, Player::Ai); + board.set(7, Player::Human); + + assert_eq!(check_game_state(&board), GameState::InProgress); +}