From af2bc841119a6751c240dec95dd5511d4ee31d36 Mon Sep 17 00:00:00 2001 From: Kai Stevenson Date: Wed, 7 May 2025 23:08:15 -0700 Subject: init ; most of tic tac toe done --- tic_tac_toe/Cargo.lock | 7 ++ tic_tac_toe/Cargo.toml | 6 ++ tic_tac_toe/src/ai.rs | 111 +++++++++++++++++++++++++ tic_tac_toe/src/board.rs | 67 +++++++++++++++ tic_tac_toe/src/console_helpers.rs | 11 +++ tic_tac_toe/src/main.rs | 162 +++++++++++++++++++++++++++++++++++++ 6 files changed, 364 insertions(+) create mode 100644 tic_tac_toe/Cargo.lock create mode 100644 tic_tac_toe/Cargo.toml create mode 100644 tic_tac_toe/src/ai.rs create mode 100644 tic_tac_toe/src/board.rs create mode 100644 tic_tac_toe/src/console_helpers.rs create mode 100644 tic_tac_toe/src/main.rs (limited to 'tic_tac_toe') diff --git a/tic_tac_toe/Cargo.lock b/tic_tac_toe/Cargo.lock new file mode 100644 index 0000000..30a9d90 --- /dev/null +++ b/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/tic_tac_toe/Cargo.toml b/tic_tac_toe/Cargo.toml new file mode 100644 index 0000000..a6c26ae --- /dev/null +++ b/tic_tac_toe/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "tic_tac_toe" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/tic_tac_toe/src/ai.rs b/tic_tac_toe/src/ai.rs new file mode 100644 index 0000000..e2d8918 --- /dev/null +++ b/tic_tac_toe/src/ai.rs @@ -0,0 +1,111 @@ +use crate::board::{Board, Coord, Tile}; +use crate::{GameState, check_state}; + +#[derive(Debug)] +pub struct BestMove { + pub coord: Coord, + eval: i8, + depth: u32, +} + +fn eval_for_player(board: &Board, player: Tile) -> i8 { + match player { + Tile::PlayerOne => { + return match check_state(board) { + GameState::PlayerTwoWin => -100, + GameState::Draw => -10, + GameState::InProgress => 0, + GameState::PlayerOneWin => 100, + }; + } + Tile::PlayerTwo => { + return match check_state(board) { + GameState::PlayerOneWin => -100, + GameState::Draw => -10, + GameState::InProgress => 0, + GameState::PlayerTwoWin => 100, + }; + } + Tile::Unowned => { + return match check_state(board) { + GameState::PlayerOneWin => -100, + GameState::PlayerTwoWin => -100, + GameState::InProgress => 0, + GameState::Draw => 100, + }; + } + } +} + +pub fn get_best_move(board: &Board, player: Tile, is_tl: bool, depth: u32) -> BestMove { + //base case + //game is over, return eval + let eval = eval_for_player(board, player); + + //eval 0 means the game is in progress + if eval != 0 { + return BestMove { + coord: (99, 99), + eval, + depth, + }; + } + + //get all possible moves + let possible_moves: Vec = (0..9) + .map(|i| { + return (i % 3, i / 3); + }) + .filter(|coord| { + return board.get_at_coord(*coord) == Tile::Unowned; + }) + .collect(); + + let mut cur_best = BestMove { + eval: i8::min_value(), + coord: possible_moves[0], + depth, + }; + + for p_move in possible_moves { + let mut new_board = board.clone(); + new_board.set_at_coord(p_move, player); + + let other_player = match player { + Tile::PlayerOne => Tile::PlayerTwo, + Tile::PlayerTwo => Tile::PlayerOne, + //this allows the AI to set as player 'unowned', which will cause it to try to draw + //todo: fix this + Tile::Unowned => Tile::PlayerOne, + }; + + let response_move = get_best_move(&new_board, other_player, false, depth + 1); + if response_move.coord.0 != 99 { + new_board.set_at_coord(response_move.coord, other_player); + } + + //this is the inverse of the response eval + let response_eval = -response_move.eval; + + if is_tl { + println!("AI > If I make move {p_move:?}, the best response is {response_move:?}"); + } + + //slightly bias for the centre to beat weak players + let centre_bias = if p_move == (1, 1) { 1 } else { 0 }; + + if response_eval > cur_best.eval { + cur_best = BestMove { + coord: p_move, + eval: response_eval + centre_bias, + depth: response_move.depth, + }; + } + } + + if is_tl { + println!("AI > I'll play {cur_best:?}") + } + + return cur_best; +} diff --git a/tic_tac_toe/src/board.rs b/tic_tac_toe/src/board.rs new file mode 100644 index 0000000..5ad23ad --- /dev/null +++ b/tic_tac_toe/src/board.rs @@ -0,0 +1,67 @@ +use std::fmt; + +#[derive(Copy, Clone, PartialEq, Debug)] +pub enum Tile { + PlayerOne, + PlayerTwo, + Unowned, +} + +pub enum GameState { + PlayerOneWin, + PlayerTwoWin, + Draw, + InProgress, +} + +impl fmt::Display for Tile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let sym = match self { + Tile::PlayerOne => "X", + Tile::PlayerTwo => "O", + Tile::Unowned => "_", + }; + + write!(f, "{sym}") + } +} + +pub type Coord = (usize, usize); + +pub fn parse_coord(val: &str) -> Result { + let split = val.split(","); + + let res: Vec = split + .map(|v| v.trim().parse::().expect("Couldn't parse!")) + .collect(); + + if res.len() != 2 { + return Err("Must provide exactly two dimensional coordinates"); + } + + if res[0] < 1 || res[0] > 3 || res[1] < 1 || res[1] > 3 { + return Err("Coordinates must be between 1 and 3"); + } + + return Ok((res[0] - 1, res[1] - 1)); +} + +#[derive(Clone, Debug)] +pub struct Board { + pub state: [[Tile; 3]; 3], +} + +impl Board { + pub fn get_at_coord(&self, coord: Coord) -> Tile { + self.state[coord.0][coord.1] + } + pub fn set_at_coord(&mut self, coord: Coord, tile: Tile) -> () { + self.state[coord.0][coord.1] = tile; + } +} + +pub fn init_board() -> Board { + return Board { + state: [[Tile::Unowned; 3]; 3], + }; +} diff --git a/tic_tac_toe/src/console_helpers.rs b/tic_tac_toe/src/console_helpers.rs new file mode 100644 index 0000000..ef293a5 --- /dev/null +++ b/tic_tac_toe/src/console_helpers.rs @@ -0,0 +1,11 @@ +pub fn write_header(header: &str) { + println!("--------{header}--------") +} + +pub fn write_space() { + println!("\n\n") +} + +pub fn clear_scrn() { + print!("\x1B[2J"); +} diff --git a/tic_tac_toe/src/main.rs b/tic_tac_toe/src/main.rs new file mode 100644 index 0000000..19285a2 --- /dev/null +++ b/tic_tac_toe/src/main.rs @@ -0,0 +1,162 @@ +mod ai; +mod board; +mod console_helpers; + +use ai::get_best_move; +use board::{Board, Coord, GameState, Tile, init_board, parse_coord}; +use console_helpers::{clear_scrn, write_header}; + +use std::io; + +fn render_board(board: &Board) -> () { + println!(" 1 2 3"); + let mut y = 1; + for row_i in 0..3 { + let row: Vec = (0..3).map(|col_i| board.state[col_i][row_i]).collect(); + print!("{y} "); + for tile in row { + print!(" {tile} ") + } + println!("\n"); + y += 1; + } +} + +fn get_coord_input() -> Coord { + loop { + let mut entered = String::new(); + match io::stdin().read_line(&mut entered) { + Ok(_) => {} + Err(_) => { + println!("Couldn't read input!"); + continue; + } + }; + + let coord = match parse_coord(&entered) { + Ok(c) => c, + Err(e) => { + println!("Couldn't parse input '{entered}'! ({e})"); + continue; + } + }; + + return coord; + } +} + +fn check_state(board: &Board) -> GameState { + //check row + let mut has_space = false; + for row_i in 0..3 { + let row: Vec = (0..3) + .map(|col_i| { + if !has_space && board.state[col_i][row_i] == Tile::Unowned { + has_space = true + } + return board.state[col_i][row_i]; + }) + .collect(); + if row[0] != Tile::Unowned && row[0] == row[1] && row[1] == row[2] { + return match row[0] { + Tile::PlayerOne => GameState::PlayerOneWin, + Tile::PlayerTwo => GameState::PlayerTwoWin, + Tile::Unowned => panic!("Impossible state"), + }; + } + } + + //check draw + if !has_space { + return GameState::Draw; + } + + //check col + for col in board.state { + if col[0] != Tile::Unowned && col[0] == col[1] && col[1] == col[2] { + return match col[0] { + Tile::PlayerOne => GameState::PlayerOneWin, + Tile::PlayerTwo => GameState::PlayerTwoWin, + Tile::Unowned => panic!("Impossible state"), + }; + } + } + + //check diagonal + if board.state[0][0] != Tile::Unowned + && board.state[0][0] == board.state[1][1] + && board.state[1][1] == board.state[2][2] + { + return match board.state[0][0] { + Tile::PlayerOne => GameState::PlayerOneWin, + Tile::PlayerTwo => GameState::PlayerTwoWin, + Tile::Unowned => panic!("Impossible state"), + }; + } + + if board.state[2][0] != Tile::Unowned + && board.state[2][0] == board.state[1][1] + && board.state[1][1] == board.state[0][2] + { + return match board.state[2][0] { + Tile::PlayerOne => GameState::PlayerOneWin, + Tile::PlayerTwo => GameState::PlayerTwoWin, + Tile::Unowned => panic!("Impossible state"), + }; + } + + //no wincon + return GameState::InProgress; +} + +fn main() { + let mut board = init_board(); + loop { + // clear_scrn(); + write_header("Current state"); + render_board(&board); + + println!("Enter a move (e.g., 1,2) >"); + let coord = get_coord_input(); + board.set_at_coord(coord, Tile::PlayerOne); + + match check_state(&board) { + GameState::InProgress => (), + GameState::Draw => { + println!("Draw!"); + break; + } + GameState::PlayerOneWin => { + println!("Player one wins!"); + break; + } + GameState::PlayerTwoWin => { + println!("Player two wins!"); + break; + } + } + + //get AI move + let ai_move = get_best_move(&board, Tile::PlayerTwo, true, 0); + board.set_at_coord(ai_move.coord, Tile::PlayerTwo); + + match check_state(&board) { + GameState::InProgress => (), + GameState::Draw => { + println!("Draw!"); + break; + } + GameState::PlayerOneWin => { + println!("Player one wins!"); + break; + } + GameState::PlayerTwoWin => { + println!("Player two wins!"); + break; + } + } + } + + render_board(&board); + println!("Game over!") +} -- cgit v1.2.3-70-g09d2