diff --git a/Cargo.lock b/Cargo.lock index 7b8e029..32c5308 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,6 +35,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + [[package]] name = "bumpalo" version = "3.19.0" @@ -53,6 +59,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -105,6 +122,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "criterion" version = "0.7.0" @@ -176,15 +202,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] -name = "getrandom" -version = "0.3.4" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core", "wasip2", + "wasip3", ] [[package]] @@ -198,6 +238,45 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + [[package]] name = "itertools" version = "0.13.0" @@ -223,12 +302,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + [[package]] name = "memchr" version = "2.7.6" @@ -295,12 +386,13 @@ dependencies = [ ] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "zerocopy", + "proc-macro2", + "syn", ] [[package]] @@ -323,38 +415,26 @@ dependencies = [ [[package]] name = "r-efi" -version = "5.3.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" 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", + "chacha20", + "getrandom", "rand_core", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rayon" @@ -426,6 +506,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -496,6 +582,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "walkdir" version = "2.5.0" @@ -512,7 +604,16 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -560,6 +661,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.82" @@ -600,6 +735,94 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "zerocopy" version = "0.8.27" diff --git a/Cargo.toml b/Cargo.toml index 8c03c52..14cecab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,11 +13,11 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.100" +rand = "0.10.1" 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 3efa2c2..ae9a2ea 100644 --- a/src/ai.rs +++ b/src/ai.rs @@ -142,106 +142,6 @@ 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::*; diff --git a/src/board/mod.rs b/src/board/mod.rs index 1c19372..a6bfebe 100644 --- a/src/board/mod.rs +++ b/src/board/mod.rs @@ -6,6 +6,7 @@ use std::fmt::{Debug, Display}; use crate::{ board::view::{Overlay, View}, game::Team, + zobrist::{ZOBRIST_TABLE, ZOBRIST_TURN}, }; pub mod view; @@ -446,6 +447,23 @@ impl BitBoard { self.boards[1].count_ones() as u8, ) } + + pub fn compute_hash(&self, playing: Team) -> u64 { + let mut hash = 0; + for (player, board) in self.boards.iter().enumerate() { + for offset in 0..64 as u64 { + if (1 << offset) & board > 0 { + hash ^= ZOBRIST_TABLE[player][offset as usize]; + } + } + } + + if matches!(playing, Team::White) { + hash ^= *ZOBRIST_TURN; + } + + hash + } } pub mod squares { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a8267ad..a433765 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,7 +1,7 @@ use std::io; use othello::{ - ai::{alphabeta, alphabeta_with_printing}, + ai::alphabeta, board::{ Board, Score, view::{Overlay, View}, @@ -106,10 +106,6 @@ pub fn run() -> anyhow::Result<()> { 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; } diff --git a/src/game.rs b/src/game.rs index 800e7eb..61d38b0 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,4 +1,7 @@ -use crate::board::{BitBoard, Board, Score}; +use crate::{ + board::{BitBoard, Board, Score}, + zobrist::{ZOBRIST_TABLE, ZOBRIST_TURN}, +}; #[repr(u8)] #[derive(Copy, Clone, Debug, PartialEq, Default)] @@ -19,16 +22,31 @@ impl Team { } } -#[derive(Default, Clone)] -// TODO: Look into potentially better memory alignment for this +#[derive(Clone)] pub struct Game { pub current_team: Team, + hash: u64, board: BitBoard, } +impl Default for Game { + fn default() -> Self { + let board = BitBoard::default(); + let team = Team::default(); + Self { + current_team: team, + board: Default::default(), + hash: board.compute_hash(team), + } + } +} + impl Game { /// Play a move. Automatically transitions state to next player. pub fn play(&mut self, player_move: Board) { + self.hash ^= + ZOBRIST_TABLE[self.current_team as usize][player_move.trailing_zeros() as usize]; + self.hash ^= *ZOBRIST_TURN; self.board.play(self.current_team, player_move); self.current_team = self.current_team.next(); } @@ -45,6 +63,7 @@ impl Game { /// Skip the current player pub fn skip(&mut self) { + self.hash ^= *ZOBRIST_TURN; self.current_team = self.current_team.next(); } @@ -76,6 +95,7 @@ impl Game { pub(crate) fn from_parts(current_team: Team, board: BitBoard) -> Self { Self { current_team, + hash: board.compute_hash(current_team), board, } } @@ -85,6 +105,13 @@ impl Game { mod tests { use super::*; use crate::board::squares::*; + + #[test] + fn game_inits_with_hash() { + let game = Game::default(); + assert_ne!(game.hash, 0); + } + #[test] fn play_switches_team() { let mut game = Game::default(); diff --git a/src/lib.rs b/src/lib.rs index e68829a..f2a7749 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ pub mod ai; pub mod board; pub mod game; +mod table; +mod zobrist; diff --git a/src/table.rs b/src/table.rs new file mode 100644 index 0000000..fdfedd7 --- /dev/null +++ b/src/table.rs @@ -0,0 +1,32 @@ +use std::collections::HashMap; + +use crate::{board::BitBoard, game::Game, zobrist::ZOBRIST_TABLE}; + +pub enum Bound { + Exact, + Lower, + Upper, +} + +pub struct TTEntry { + bound: Bound, + evaluation: i8, + depth: u8, +} + +#[derive(Default)] +pub struct TTable { + // replace with `DashMap` if we utilize concurrency + inner: HashMap, +} + +impl TTable { + pub fn new() -> Self { + Self::default() + } + + pub fn upsert(&mut self, game: &Game) { + //self.inner.entry(key).or_insert(default) + todo!() + } +} diff --git a/src/zobrist.rs b/src/zobrist.rs new file mode 100644 index 0000000..d7572d2 --- /dev/null +++ b/src/zobrist.rs @@ -0,0 +1,27 @@ +use std::sync::LazyLock; + +use rand::{RngExt, SeedableRng, rngs::StdRng}; + +pub static ZOBRIST_TABLE: LazyLock<[[u64; 64]; 2]> = LazyLock::new(generate_zobrist); +pub static ZOBRIST_TURN: LazyLock = LazyLock::new(generate_zobrist_turn); + +fn generate_zobrist() -> [[u64; 64]; 2] { + let seed: u64 = std::env::var("ZOBRIST_SEED") + .unwrap_or("42".into()) + .parse() + .expect("Zobrist seed must be a valid unsigned integer"); + + let mut rng = StdRng::seed_from_u64(seed); + + std::array::from_fn(|_| std::array::from_fn(|_| rng.random())) +} + +fn generate_zobrist_turn() -> u64 { + let seed: u64 = std::env::var("ZOBRIST_TURN_SEED") + .unwrap_or("24".into()) + .parse() + .expect("Zobrist turn seed must be a valid unsigned integer"); + + let mut rng = StdRng::seed_from_u64(seed); + rng.random() +}