diff --git a/Cargo.lock b/Cargo.lock index bed7c0d..e58f1e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,7 @@ version = "0.1.0" dependencies = [ "chessfriend_bitboard", "chessfriend_core", + "rand", "thiserror", ] diff --git a/board/Cargo.toml b/board/Cargo.toml index 098d764..90de6eb 100644 --- a/board/Cargo.toml +++ b/board/Cargo.toml @@ -8,4 +8,5 @@ edition = "2021" [dependencies] chessfriend_bitboard = { path = "../bitboard" } chessfriend_core = { path = "../core" } +rand = "0.9.1" thiserror = "2" diff --git a/board/src/board.rs b/board/src/board.rs index 9c465e0..67f7f81 100644 --- a/board/src/board.rs +++ b/board/src/board.rs @@ -4,10 +4,12 @@ use crate::{ castle, display::DiagramFormatter, piece_sets::{PlacePieceError, PlacePieceStrategy}, + zobrist::{ZobristHash, ZobristState}, PieceSet, }; use chessfriend_bitboard::BitBoard; use chessfriend_core::{Color, Piece, Shape, Square, Wing}; +use std::sync::Arc; pub type HalfMoveClock = u32; pub type FullMoveClock = u32; @@ -20,18 +22,26 @@ pub struct Board { en_passant_target: Option, pub half_move_clock: HalfMoveClock, pub full_move_number: FullMoveClock, + zobrist_hash: Option, } impl Board { /// An empty board #[must_use] - pub fn empty() -> Self { - Board::default() + pub fn empty(zobrist: Option>) -> Self { + let mut board = Self { + zobrist_hash: zobrist.map(ZobristHash::new), + ..Default::default() + }; + + board.recompute_zobrist_hash(); + + board } /// The starting position #[must_use] - pub fn starting() -> Self { + pub fn starting(zobrist: Option>) -> Self { const BLACK_PIECES: [BitBoard; Shape::NUM] = [ BitBoard::new(0b0000_0000_1111_1111 << 48), BitBoard::new(0b0100_0010_0000_0000 << 48), @@ -50,10 +60,15 @@ impl Board { BitBoard::new(0b0000_0000_0001_0000), ]; - Self { + let mut board = Self { pieces: PieceSet::new([WHITE_PIECES, BLACK_PIECES]), + zobrist_hash: zobrist.map(ZobristHash::new), ..Default::default() - } + }; + + board.recompute_zobrist_hash(); + + board } } @@ -69,7 +84,12 @@ impl Board { } self.active_color = color; + + if let Some(zobrist) = self.zobrist_hash.as_mut() { + zobrist.update_setting_active_color(color); + } } +} impl Board { #[must_use] @@ -78,7 +98,13 @@ impl Board { } pub fn set_castling_rights(&mut self, rights: castle::Rights) { + if rights == self.castling_rights { + return; + } + + let old_rights = self.castling_rights; self.castling_rights = rights; + self.update_zobrist_hash_castling_rights(old_rights); } #[must_use] @@ -92,15 +118,32 @@ impl Board { } pub fn grant_castling_right(&mut self, color: Color, wing: Wing) { + let old_rights = self.castling_rights; self.castling_rights.grant(color, wing); + self.update_zobrist_hash_castling_rights(old_rights); } pub fn revoke_all_castling_rights(&mut self) { + let old_rights = self.castling_rights; self.castling_rights.revoke_all(); + self.update_zobrist_hash_castling_rights(old_rights); } pub fn revoke_castling_right(&mut self, color: Color, wing: Wing) { + let old_rights = self.castling_rights; self.castling_rights.revoke(color, wing); + self.update_zobrist_hash_castling_rights(old_rights); + } + + fn update_zobrist_hash_castling_rights(&mut self, old_rights: castle::Rights) { + let new_rights = self.castling_rights; + if old_rights == new_rights { + return; + } + + if let Some(zobrist) = self.zobrist_hash.as_mut() { + zobrist.update_modifying_castling_rights(new_rights, old_rights); + } } } @@ -116,11 +159,27 @@ impl Board { } pub fn set_en_passant_target_option(&mut self, square: Option) { + let old_target = self.en_passant_target; self.en_passant_target = square; + self.update_zobrist_hash_en_passant_target(old_target); } pub fn clear_en_passant_target(&mut self) { + let old_target = self.en_passant_target; self.en_passant_target = None; + self.update_zobrist_hash_en_passant_target(old_target); + } + + fn update_zobrist_hash_en_passant_target(&mut self, old_target: Option) { + let new_target = self.en_passant_target; + + if old_target == new_target { + return; + } + + if let Some(zobrist) = self.zobrist_hash.as_mut() { + zobrist.update_setting_en_passant_target(old_target, new_target); + } } } @@ -148,11 +207,28 @@ impl Board { square: Square, strategy: PlacePieceStrategy, ) -> Result, PlacePieceError> { - self.pieces.place(piece, square, strategy) + let place_result = self.pieces.place(piece, square, strategy); + + if let Ok(Some(existing_piece)) = place_result.as_ref() { + if let Some(zobrist) = self.zobrist_hash.as_mut() { + zobrist.update_removing_piece(square, *existing_piece); + zobrist.update_adding_piece(square, piece); + } + } + + place_result } pub fn remove_piece(&mut self, square: Square) -> Option { - self.pieces.remove(square) + let removed_piece = self.pieces.remove(square); + + if let Some(piece) = removed_piece { + if let Some(zobrist) = self.zobrist_hash.as_mut() { + zobrist.update_removing_piece(square, piece); + } + } + + removed_piece } } @@ -209,6 +285,29 @@ impl Board { } } +impl Board { + pub fn zobrist_hash(&self) -> Option { + self.zobrist_hash.as_ref().map(ZobristHash::hash_value) + } + + pub fn recompute_zobrist_hash(&mut self) { + // Avoid overlapping borrows when borrowing zobrist_hash.as_mut() and + // then also borrowing self to update the board hash by computing the + // hash with the static function first, and then setting the hash value + // on the zobrist instance. Unfortuantely this requires unwrapping + // self.zobrist_hash twice. C'est la vie. + + let new_hash = self.zobrist_hash.as_ref().map(|zobrist| { + let state = zobrist.state(); + ZobristHash::compute_board_hash(self, state.as_ref()) + }); + + if let (Some(new_hash), Some(zobrist)) = (new_hash, self.zobrist_hash.as_mut()) { + zobrist.set_hash_value(new_hash); + } + } +} + impl Board { pub fn display(&self) -> DiagramFormatter<'_> { DiagramFormatter::new(self) diff --git a/board/src/castle/rights.rs b/board/src/castle/rights.rs index ccd56c6..b65461b 100644 --- a/board/src/castle/rights.rs +++ b/board/src/castle/rights.rs @@ -30,6 +30,12 @@ impl Rights { } } +impl Rights { + pub(crate) fn as_index(&self) -> usize { + self.0 as usize + } +} + impl Rights { fn flag_offset(color: Color, wing: Wing) -> usize { ((color as usize) << 1) + wing as usize diff --git a/board/src/fen.rs b/board/src/fen.rs index 470675b..12f59ed 100644 --- a/board/src/fen.rs +++ b/board/src/fen.rs @@ -185,7 +185,7 @@ impl FromFenStr for Board { type Error = FromFenStrError; fn from_fen_str(string: &str) -> Result { - let mut board = Board::empty(); + let mut board = Board::empty(None); let mut fields = string.split(' '); @@ -334,7 +334,7 @@ mod tests { #[test] fn from_starting_fen() { let board = fen!("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 0").unwrap(); - let expected = Board::starting(); + let expected = Board::starting(None); assert_eq!(board, expected, "{board:#?}\n{expected:#?}"); } } diff --git a/board/src/lib.rs b/board/src/lib.rs index 451bc23..ae80da8 100644 --- a/board/src/lib.rs +++ b/board/src/lib.rs @@ -8,6 +8,7 @@ pub mod fen; pub mod macros; pub mod movement; pub mod sight; +pub mod zobrist; mod board_provider; mod check; @@ -18,5 +19,6 @@ pub use board_provider::BoardProvider; pub use castle::Parameters as CastleParameters; pub use castle::Rights as CastleRights; pub use piece_sets::{PlacePieceError, PlacePieceStrategy}; +pub use zobrist::ZobristState; use piece_sets::PieceSet; diff --git a/board/src/macros.rs b/board/src/macros.rs index b3baf85..e2786e2 100644 --- a/board/src/macros.rs +++ b/board/src/macros.rs @@ -4,7 +4,7 @@ macro_rules! test_board { ($to_move:ident, [ $($color:ident $shape:ident on $square:ident),* $(,)? ], $en_passant:ident) => { { - let mut board = $crate::Board::empty(); + let mut board = $crate::Board::empty(Some($crate::test_zobrist!())); $(let _ = board.place_piece( chessfriend_core::Piece::new( chessfriend_core::Color::$color, @@ -23,7 +23,7 @@ macro_rules! test_board { }; ($to_move:ident, [ $($color:ident $shape:ident on $square:ident),* $(,)? ]) => { { - let mut board = $crate::Board::empty(); + let mut board = $crate::Board::empty(Some($crate::test_zobrist!())); $(let _ = board.place_piece( chessfriend_core::Piece::new( chessfriend_core::Color::$color, @@ -41,7 +41,7 @@ macro_rules! test_board { }; ($($color:ident $shape:ident on $square:ident),* $(,)?) => { { - let mut board = $crate::Board::empty(); + let mut board = $crate::Board::empty(Some($crate::test_zobrist!())); $(let _ = board.place_piece( chessfriend_core::Piece::new( chessfriend_core::Color::$color, @@ -58,16 +58,25 @@ macro_rules! test_board { }; (empty) => { { - let board = Board::empty(); + let board = Board::empty(Some($crate::test_zobrist!())); println!("{}", board.display()); board } }; (starting) => { { - let board = Board::starting(); + let board = Board::starting(Some($crate::test_zobrist!())); println!("{}", board.display()); board } }; } + +#[macro_export] +macro_rules! test_zobrist { + () => {{ + let mut rng = chessfriend_core::random::RandomNumberGenerator::default(); + let state = $crate::zobrist::ZobristState::new(&mut rng); + std::sync::Arc::new(state) + }}; +} diff --git a/board/src/sight.rs b/board/src/sight.rs index 9a84f7e..81fb120 100644 --- a/board/src/sight.rs +++ b/board/src/sight.rs @@ -228,7 +228,13 @@ mod tests { } }; ($test_name:ident, $piece:expr, $square:expr, $bitboard:expr) => { - sight_test! {$test_name, $crate::Board::empty(), $piece, $square, $bitboard} + sight_test! { + $test_name, + $crate::Board::empty(Some($crate::test_zobrist!())), + $piece, + $square, + $bitboard + } }; } diff --git a/board/src/zobrist.rs b/board/src/zobrist.rs new file mode 100644 index 0000000..39eee98 --- /dev/null +++ b/board/src/zobrist.rs @@ -0,0 +1,239 @@ +// Eryn Wells + +//! This module implements facilities for computing hash values of board +//! positions via the Zobrist hashing algorithm. +//! +//! ## See Also +//! +//! * The Chess Programming Wiki page on [Zobrist Hashing][1] +//! +//! [1]: https://www.chessprogramming.org/Zobrist_Hashing + +use crate::{castle, Board}; +use chessfriend_core::{random::RandomNumberGenerator, Color, File, Piece, Shape, Square, Wing}; +use rand::Fill; +use std::sync::Arc; + +const NUM_SQUARE_PIECE_VALUES: usize = Shape::NUM * Color::NUM * Square::NUM; +const NUM_CASTLING_RIGHTS_VALUES: usize = 16; + +type HashValue = u64; + +type SquarePieceValues = [[[HashValue; Color::NUM]; Shape::NUM]; Square::NUM]; +type CastlingRightsValues = [HashValue; NUM_CASTLING_RIGHTS_VALUES]; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ZobristHash { + // TODO: Keep this field in mind if ChessFriend grows threads. It may also + // need a Mutex<>. + state: Arc, + + /// The current hash value. + hash_value: HashValue, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ZobristState { + square_piece_values: SquarePieceValues, + black_to_move_value: HashValue, + castling_rights_values: CastlingRightsValues, + en_passant_file_values: [HashValue; File::NUM], +} + +impl ZobristState { + #[must_use] + pub fn new(rng: &mut RandomNumberGenerator) -> Self { + let square_piece_values = { + let mut values = [[[0; Color::NUM]; Shape::NUM]; Square::NUM]; + for square in Square::ALL { + for shape in Shape::ALL { + for color in Color::ALL { + values[square as usize][shape as usize][color as usize] = rng.next_u64(); + } + } + } + + values + }; + + let mut castling_rights_values: CastlingRightsValues = [0; NUM_CASTLING_RIGHTS_VALUES]; + castling_rights_values.fill(rng.rand()); + + let mut en_passant_file_values = [0; File::NUM]; + en_passant_file_values.fill(rng.rand()); + + Self { + square_piece_values, + black_to_move_value: rng.next_u64(), + castling_rights_values, + en_passant_file_values, + } + } +} + +impl ZobristHash { + #[must_use] + pub fn new(state: Arc) -> Self { + Self { + state, + hash_value: 0, + } + } + + #[must_use] + pub fn hash_value(&self) -> HashValue { + self.hash_value + } + + pub(crate) fn set_hash_value(&mut self, value: HashValue) { + self.hash_value = value; + } + + #[must_use] + pub fn state(&self) -> Arc { + self.state.clone() + } + + #[must_use] + pub fn compute_board_hash(board: &Board, state: &ZobristState) -> HashValue { + let mut hash_value: HashValue = 0; + + for (square, piece) in board.iter() { + hash_value ^= state.square_piece_values[square as usize][piece.shape as usize] + [piece.color as usize]; + } + + if board.active_color() == Color::Black { + hash_value ^= state.black_to_move_value; + } + + if let Some(square) = board.en_passant_target() { + hash_value ^= state.en_passant_file_values[square.file().as_index()]; + } + + hash_value + } + + pub fn recompute_hash(&mut self, board: &Board) -> HashValue { + self.hash_value = Self::compute_board_hash(board, self.state.as_ref()); + self.hash_value + } + + pub fn update_adding_piece(&mut self, square: Square, piece: Piece) -> HashValue { + self.hash_value ^= self.xor_piece_square_operand(square, piece.shape, piece.color); + self.hash_value + } + + pub fn update_removing_piece(&mut self, square: Square, piece: Piece) -> HashValue { + self.hash_value ^= self.xor_piece_square_operand(square, piece.shape, piece.color); + self.hash_value + } + + pub fn update_setting_active_color(&mut self, _color: Color) -> HashValue { + self.hash_value ^= self.state.black_to_move_value; + self.hash_value + } + + pub fn update_modifying_castling_rights( + &mut self, + new_rights: castle::Rights, + old_rights: castle::Rights, + ) -> HashValue { + let state = self.state.as_ref(); + self.hash_value ^= state.castling_rights_values[new_rights.as_index()]; + self.hash_value ^= state.castling_rights_values[old_rights.as_index()]; + self.hash_value + } + + pub fn update_setting_en_passant_target( + &mut self, + old_target: Option, + new_target: Option, + ) -> HashValue { + let state = self.state.as_ref(); + + if let Some(old_target) = old_target { + self.hash_value ^= state.en_passant_file_values[old_target.file().as_index()]; + } + if let Some(new_target) = new_target { + self.hash_value ^= state.en_passant_file_values[new_target.file().as_index()]; + } + + self.hash_value + } + + fn xor_piece_square_operand(&self, square: Square, shape: Shape, color: Color) -> HashValue { + let square = square as usize; + let shape = shape as usize; + let color = color as usize; + self.state.square_piece_values[square][shape][color] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_state() -> ZobristState { + let mut rng = RandomNumberGenerator::default(); + ZobristState::new(&mut rng) + } + + fn test_hash() -> ZobristHash { + ZobristHash::new(Arc::new(test_state())) + } + + #[test] + fn hash_empty_board_ai_claude() { + let state = test_state(); + let board = Board::empty(None); + let hash = ZobristHash::compute_board_hash(&board, &state); + + // Empty board with white to move should only contribute active color + assert_eq!(hash, 0); + } + + #[test] + fn hash_board_with_black_to_move_ai_claude() { + let state = test_state(); + + let mut board = Board::empty(None); + board.set_active_color(Color::Black); + + let hash = ZobristHash::compute_board_hash(&board, &state); + assert_eq!(hash, state.black_to_move_value); + } + + #[test] + fn hash_different_en_passant_files_ai_claude() { + let mut board1 = Board::empty(None); + let mut board2 = Board::empty(None); + + // Different file should produce different hash values, even if ranks + // are the same. + board1.set_en_passant_target(Square::A3); + board2.set_en_passant_target(Square::H3); + + let state = test_state(); + let hash1 = ZobristHash::compute_board_hash(&board1, &state); + let hash2 = ZobristHash::compute_board_hash(&board2, &state); + + assert_ne!(hash1, hash2); + } + + #[test] + fn hash_en_passant_same_file_different_rank_ai_claude() { + let mut board1 = Board::empty(None); + let mut board2 = Board::empty(None); + + // Same file, different ranks should produce same hash (only file matters) + board1.set_en_passant_target(Square::E3); + board2.set_en_passant_target(Square::E6); + + let state = test_state(); + let hash1 = ZobristHash::compute_board_hash(&board1, &state); + let hash2 = ZobristHash::compute_board_hash(&board2, &state); + + assert_eq!(hash1, hash2); + } +} diff --git a/explorer/src/main.rs b/explorer/src/main.rs index 6fc13cf..59b790d 100644 --- a/explorer/src/main.rs +++ b/explorer/src/main.rs @@ -2,14 +2,16 @@ mod make_command; +use chessfriend_board::ZobristState; use chessfriend_board::{fen::FromFenStr, Board}; +use chessfriend_core::random::RandomNumberGenerator; use chessfriend_core::{Color, Piece, Shape, Square}; use chessfriend_moves::{Builder as MoveBuilder, GeneratedMove, MakeMove, ValidateMove}; use chessfriend_position::{fen::ToFenStr, PlacePieceStrategy, Position}; - use clap::{Arg, Command}; use rustyline::error::ReadlineError; use rustyline::DefaultEditor; +use std::sync::Arc; use thiserror::Error; struct CommandResult { @@ -28,6 +30,7 @@ impl Default for CommandResult { struct State { position: Position, + zobrist: Arc, } fn command_line() -> Command { @@ -171,10 +174,6 @@ fn respond(line: &str, state: &mut State) -> anyhow::Result { } Some(("moves", matches)) => result = do_moves_command(state, matches)?, Some(("movement", matches)) => result = do_movement_command(state, matches)?, - Some(("starting", _matches)) => { - let starting_position = Position::starting(); - state.position = starting_position; - } Some(("reset", matches)) => result = do_reset_command(state, matches)?, Some((name, _matches)) => unimplemented!("{name}"), None => unreachable!("Subcommand required"), @@ -188,8 +187,8 @@ fn do_reset_command( matches: &clap::ArgMatches, ) -> anyhow::Result { match matches.subcommand() { - None | Some(("clear", _)) => state.position = Position::empty(), - Some(("starting", _)) => state.position = Position::starting(), + None | Some(("clear", _)) => state.position = Position::empty(Some(state.zobrist.clone())), + Some(("starting", _)) => state.position = Position::starting(Some(state.zobrist.clone())), Some(("fen", matches)) => { let fen = matches .get_one::("fen") @@ -273,9 +272,13 @@ fn do_movement_command( fn main() -> Result<(), String> { let mut editor = DefaultEditor::new().map_err(|err| format!("Error: {err}"))?; - let starting_position = Position::starting(); + let mut rng = RandomNumberGenerator::default(); + let zobrist_state = Arc::new(ZobristState::new(&mut rng)); + + let starting_position = Position::starting(Some(zobrist_state.clone())); let mut state = State { position: starting_position, + zobrist: zobrist_state, }; let mut should_print_position = true; diff --git a/position/src/position.rs b/position/src/position.rs index 8b08bce..30afe0d 100644 --- a/position/src/position.rs +++ b/position/src/position.rs @@ -15,9 +15,10 @@ use captures::CapturesList; use chessfriend_bitboard::BitBoard; use chessfriend_board::{ display::DiagramFormatter, fen::ToFenStr, Board, PlacePieceError, PlacePieceStrategy, + ZobristState, }; use chessfriend_core::{Color, Piece, Shape, Square}; -use std::fmt; +use std::{fmt, sync::Arc}; #[must_use] #[derive(Clone, Debug, Default, Eq)] @@ -28,16 +29,13 @@ pub struct Position { } impl Position { - pub fn empty() -> Self { - Position::default() + pub fn empty(zobrist: Option>) -> Self { + Self::new(Board::empty(zobrist)) } /// Return a starting position. - pub fn starting() -> Self { - Self { - board: Board::starting(), - ..Default::default() - } + pub fn starting(zobrist: Option>) -> Self { + Self::new(Board::starting(zobrist)) } pub fn new(board: Board) -> Self {