From b788bd2e4dd7ccbbd9773696e86d17c6e4ea55f4 Mon Sep 17 00:00:00 2001 From: jackjohn7 <70782491+jackjohn7@users.noreply.github.com> Date: Tue, 11 Nov 2025 04:10:06 -0600 Subject: [PATCH] cleaned up, added random AI for testing --- Cargo.lock | 78 +++++++++++++++++++++++ Cargo.toml | 1 + src/ai.rs | 163 +++++++++++++++++++++++++++++++++++++++++++++---- src/cli/mod.rs | 18 ++++-- src/main.rs | 6 +- 5 files changed, 245 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f8f0fe6..7b8e029 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,6 +175,18 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "half" version = "2.7.1" @@ -211,6 +223,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + [[package]] name = "memchr" version = "2.7.6" @@ -244,6 +262,7 @@ version = "0.1.0" dependencies = [ "anyhow", "criterion", + "rand", "regex", ] @@ -275,6 +294,15 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -293,6 +321,41 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom", +] + [[package]] name = "rayon" version = "1.11.0" @@ -443,6 +506,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.105" @@ -522,6 +594,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "zerocopy" version = "0.8.27" diff --git a/Cargo.toml b/Cargo.toml index 420cd65..8c03c52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ regex = "1.12.2" [dev-dependencies] criterion = "0.7.0" +rand = "0.9.2" [[bench]] name = "generation" diff --git a/src/ai.rs b/src/ai.rs index bfad9a9..401b6e8 100644 --- a/src/ai.rs +++ b/src/ai.rs @@ -142,13 +142,123 @@ pub fn alphabeta(mut game: Game, depth: u8, mut alpha: i8, mut beta: i8) -> (Boa } } +/// Using alpha-beta pruning with printing of intermediate data for debugging +/// and monitoring purposes. +pub fn alphabeta_with_printing(game: Game, depth: u8) -> (Board, i8) { + let (b, moves, eval) = alphabeta_with_printing_inner(game, depth, i8::MIN + 1, i8::MAX - 1); + println!("beep. boop. we assessed {} moves.", moves); + (b, eval) +} + +/// Using alpha-beta pruning and the minimax algorithm, determine the best move +/// for a game with a recursion depth of `depth`. +/// +/// We use a very simple evaluation heuristic: (Black squares - White squares). +fn alphabeta_with_printing_inner( + mut game: Game, + depth: u8, + mut alpha: i8, + mut beta: i8, +) -> (Board, usize, i8) { + // if we reach our maximum recursion depth, return evaluation + if depth == 0 { + return (0, 0, game.score().diff()); + } + let moves = game.available(); + if moves == 0 { + // if no move, skip and continue recursion + // this seems to technically introduce a bias against move-chains + // that include skips. I haven't found it to be a big deal in play. + game.skip(); + return ( + 0, + 0, + alphabeta_with_printing_inner(game, depth - 1, alpha, beta).2, + ); + } + + // just initially assume that the best move is no move at all. This will + // inevitably be corrected. + let mut best_move: Board = 0; + // we initially rank moves based on a couple basic heuristics: + // - corner pieces are best + // - edge pieces are great + // - others considered last + // This just allows us to prune the tree a bit more aggressively + // since we're considering the "best" moves first. + // We do this by mapping moves to ranked moves and then sorting. + let mut moves = explode_board(moves).map(MoveRank::from).collect::>(); + moves.sort(); + let moves = moves + .into_iter() + .map(MoveRank::into_inner) + .collect::>(); + let mut num_moves = moves.len(); + // I just establish a convention of maximizing for black and minimizing for white. + // I'm not sure if that's conventional or not, but it's what I chose. + match game.current_team { + Team::Black => { + for mv in moves { + let mut g = game.clone(); + g.play(mv); + // maximize for the evaluation of subsequent moves + let (_, num_moves_prime, evaluation) = + alphabeta_with_printing_inner(g, depth - 1, alpha, beta); + num_moves += num_moves_prime; + // if our evaluated move is superior to the alpha, update + // it. + if evaluation > alpha { + alpha = evaluation; + best_move = mv; + }; + // if our beta is less than alpha, prune the node. + if beta <= alpha { + break; + } + } + (best_move, num_moves, alpha) + } + Team::White => { + for mv in moves { + let mut g = game.clone(); + g.play(mv); + // minimize for the evaluation of subsequent moves + let (_, num_moves_prime, evaluation) = + alphabeta_with_printing_inner(g, depth - 1, alpha, beta); + num_moves += num_moves_prime; + // if our evaluated move produces lower eval than the beta, + // update beta. + if evaluation < beta { + beta = evaluation; + best_move = mv; + }; + // if our beta is less than alpha, prune the node. + if beta <= alpha { + break; + } + } + (best_move, num_moves, beta) + } + } +} + #[cfg(test)] mod tests { use super::*; - use crate::board::BitBoard; use crate::board::view::View; + use crate::board::{BitBoard, Board, Score}; use crate::game::Game; + use rand::prelude::IndexedRandom; + use rand::rngs::StdRng; + use rand::{Rng, SeedableRng}; + + /// An AI player that only makes random moves + fn random_move(game: &Game, rng: &mut impl Rng) -> Board { + let moves = explode_board(game.available()).collect::>(); + *moves.choose(rng).unwrap() + } + #[test] // just a sanity check to ensure that my AI performs up to snuff with another popular engine fn opening() { @@ -171,21 +281,48 @@ mod tests { // I found that, despite the AI clobbering me, the AI could not // compete with itself very well. I'm honestly not quite sure why that is. #[test] - #[should_panic] // disabled until I fix whatever causes the AI not to tie - fn ai_ties_ai() { - // just play through a game letting AI make all the moves. - let mut game = Game::default(); - while !game.is_complete() { - if game.available() == 0 { - game.skip(); - } else { - let (mv, _) = alphabeta(game.clone(), 8, i8::MIN + 1, i8::MAX - 1); + fn ai_beats_random() { + // just contains pairings of starting_team and seed value + let cases = vec![ + (Team::Black, 1231293), + (Team::White, 491823), + (Team::White, 12931), + (Team::Black, 982983713), + (Team::Black, 123), + (Team::White, 87132895), + ]; + + for (team, seed) in cases { + let mut rng = StdRng::seed_from_u64(seed); + let mut game = Game::default(); + if team != Team::Black { + let mv = random_move(&game, &mut rng); game.play(mv); } - } - // one would assume the AI would compete rather closely against itself. - assert!(dbg!(game.score()).diff().abs() < 3); + while !game.is_complete() { + if game.available() == 0 { + game.skip(); + continue; + } + let mv = if game.current_team == team { + alphabeta(game.clone(), 8, i8::MIN + 1, i8::MAX - 1).0 + } else { + random_move(&game, &mut rng) + }; + game.play(mv); + } + + assert!( + match (team, game.score()) { + (Team::Black, Score(b, w)) => b - w, + (Team::White, Score(b, w)) => w - b, + } > 4, + "game with seed {} and team {:?} failed to win by 4 points or more.", + seed, + team + ); + } } #[test] diff --git a/src/cli/mod.rs b/src/cli/mod.rs index bc8bc15..a8267ad 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,7 +1,7 @@ use std::io; use othello::{ - ai::alphabeta, + ai::{alphabeta, alphabeta_with_printing}, board::{ Board, Score, view::{Overlay, View}, @@ -15,7 +15,9 @@ use regex::Regex; enum Action { Play(Board), Skip, - Ai, + /// Lets AI play. The boolean value indicates whether or not + /// to enable tracing. + Ai(bool), } /// Regex to match on valid play expressions. The file and rank are captured. @@ -68,7 +70,8 @@ pub fn run() -> anyhow::Result<()> { // match raw strings for other options match raw_input.as_str().trim() { "skip" => break Action::Skip, - "ai" => break Action::Ai, + "ai" => break Action::Ai(false), + "ai-trace" => break Action::Ai(true), _ => println!("Invalid input"), } }; @@ -95,14 +98,18 @@ pub fn run() -> anyhow::Result<()> { game.skip() } } - Action::Ai => { + Action::Ai(tracing) => { if legal_moves == 0 { println!("beep. boop. no legal moves. skipping turn"); game.skip(); - } else { + } else if !tracing { let (mv, eval) = alphabeta(game.clone(), 14, i8::MIN + 1, i8::MAX - 1); println!("beep. boop. eval = {eval}"); game.play(mv); + } else { + let (mv, eval) = alphabeta_with_printing(game.clone(), 14); + println!("beep. boop. eval = {eval}"); + game.play(mv); } board_changed = true; } @@ -141,6 +148,7 @@ fn create_move(file: &str, rank: u8) -> Board { mod tests { use super::*; use othello::board::squares::*; + #[test] fn create_move_works() { // validate that we can create moves from diff --git a/src/main.rs b/src/main.rs index eaf15f8..df3315b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ -///! Student: Jack Branch - 103-93-063 -///! Prof: Dr. Mike O'Neal -///! Class: Artificial Intelligence +// Student: Jack Branch - 103-93-063 +// Prof: Dr. Mike O'Neal +// Class: Artificial Intelligence (CS 4753) mod cli; fn main() -> anyhow::Result<()> {