diff --git a/Cargo.lock b/Cargo.lock index 59a53ad..317164f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -514,7 +514,7 @@ dependencies = [ [[package]] name = "chusst-gen" -version = "0.10.0" +version = "0.11.0" dependencies = [ "anyhow", "atty", diff --git a/bump_version.sh b/bump_version.sh index dba53c2..2e9e75f 100755 --- a/bump_version.sh +++ b/bump_version.sh @@ -22,8 +22,7 @@ echo Updating package.json npm version --allow-same-version --commit-hooks false --git-tag-version false "$new_ver" echo Updating Cargo.toml -cd src-tauri -cargo bump "$new_ver" +cargo set-version -p chusst-gen "$new_ver" echo Updating tauri.conf.json -sed -E -i'' "s/\"version\": \"[0-9.]+\"/\"version\": \"$new_ver\"/" tauri.conf.json +sed -E -i'' "s/\"version\": \"[0-9.]+\"/\"version\": \"$new_ver\"/" src-tauri/tauri.conf.json diff --git a/chusst-gen/Cargo.toml b/chusst-gen/Cargo.toml index c0d7bec..9d1202f 100644 --- a/chusst-gen/Cargo.toml +++ b/chusst-gen/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chusst-gen" -version = "0.10.0" +version = "0.11.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/chusst-gen/benches/search.rs b/chusst-gen/benches/search.rs index 81f9125..9789e29 100644 --- a/chusst-gen/benches/search.rs +++ b/chusst-gen/benches/search.rs @@ -1,15 +1,16 @@ use divan::Bencher; -use chusst_gen::eval::{Game, SilentSearchFeedback}; +use chusst_gen::eval::{Game, GameHistory, SilentSearchFeedback}; use chusst_gen::game::BitboardGame; #[divan::bench] fn search(bench: Bencher) { bench.bench_local(|| { let game = BitboardGame::new(); + let history = GameHistory::new(); let best_branch = game - .get_best_move_recursive(4, &mut (), &mut SilentSearchFeedback::default()) + .get_best_move_recursive(4, &history, &mut (), &mut SilentSearchFeedback::default()) .unwrap(); best_branch.searched @@ -123,9 +124,11 @@ fn game_benchmark() -> u64 { // use std::io::Write; let mut game = BitboardGame::new(); - let get_best_move_helper = |game: &mut BitboardGame| { + let mut history = GameHistory::new(); + + let get_best_move_helper = |game: &mut BitboardGame, history: &mut GameHistory| { let best_branch = game - .get_best_move_recursive(3, &mut (), &mut SilentSearchFeedback::default()) + .get_best_move_recursive(3, history, &mut (), &mut SilentSearchFeedback::default()) .unwrap(); (best_branch.searched, best_branch.moves.first().unwrap().mv) @@ -147,14 +150,14 @@ fn game_benchmark() -> u64 { // print!("{}. ", turn); // std::io::stdout().flush().unwrap(); - let (white_searched, white_move) = get_best_move_helper(&mut game); + let (white_searched, white_move) = get_best_move_helper(&mut game, &mut history); // let white_move_name = move_name(&game.board, &game.last_move, &game.player, &white_move); game.do_move(&white_move); // print!("{} ", white_move_name); // std::io::stdout().flush().unwrap(); - let (black_searched, black_move) = get_best_move_helper(&mut game); + let (black_searched, black_move) = get_best_move_helper(&mut game, &mut history); // let black_move_name = move_name(&game.board, &game.last_move, &game.player, &black_move); game.do_move(&black_move); diff --git a/chusst-gen/src/board.rs b/chusst-gen/src/board.rs index 0522d28..284c881 100644 --- a/chusst-gen/src/board.rs +++ b/chusst-gen/src/board.rs @@ -555,6 +555,51 @@ pub trait Board: Some(board) } + + fn to_fen(&self) -> String { + let mut fen = String::new(); + + for rank in (0..8).rev() { + let mut empty = 0; + for file in 0..8 { + let piece = self.at(&pos!(rank, file)); + match piece { + Some(Piece { piece, player }) => { + if empty > 0 { + fen.push_str(&empty.to_string()); + empty = 0; + } + let piece_char = match (player, piece) { + (Player::White, PieceType::Pawn) => 'P', + (Player::White, PieceType::Knight) => 'N', + (Player::White, PieceType::Bishop) => 'B', + (Player::White, PieceType::Rook) => 'R', + (Player::White, PieceType::Queen) => 'Q', + (Player::White, PieceType::King) => 'K', + (Player::Black, PieceType::Pawn) => 'p', + (Player::Black, PieceType::Knight) => 'n', + (Player::Black, PieceType::Bishop) => 'b', + (Player::Black, PieceType::Rook) => 'r', + (Player::Black, PieceType::Queen) => 'q', + (Player::Black, PieceType::King) => 'k', + }; + fen.push(piece_char); + } + None => { + empty += 1; + } + } + } + if empty > 0 { + fen.push_str(&empty.to_string()); + } + if rank > 0 { + fen.push('/'); + } + } + + fen + } } fn get_unicode_piece(piece: PieceType, player: Player) -> char { diff --git a/chusst-gen/src/eval.rs b/chusst-gen/src/eval.rs index 28d0d13..e987e1a 100644 --- a/chusst-gen/src/eval.rs +++ b/chusst-gen/src/eval.rs @@ -1,27 +1,31 @@ mod check; mod conditions; mod feedback; +mod history; mod iter; mod play; #[cfg(test)] mod tests; -pub use self::iter::dir; - use self::check::{only_empty_and_safe, SafetyChecks}; pub use self::feedback::{ EngineFeedback, EngineFeedbackMessage, EngineMessage, SilentSearchFeedback, StdoutFeedback, }; use self::feedback::{PeriodicalSearchFeedback, SearchFeedback}; +pub use self::history::GameHistory; +use self::history::HashedHistory; +pub use self::iter::dir; use self::iter::piece_into_iter; use self::play::PlayableGame; -use crate::board::{Board, Direction, Piece, PieceType, Position, PositionIterator, Ranks}; +use crate::board::{Board, Direction, Piece, PieceType, Player, Position, PositionIterator, Ranks}; use crate::game::{GameState, ModifiableGame, Move, MoveAction, MoveActionType, PromotionPieces}; use crate::{mv, mva, pos}; -use core::panic; +use anyhow::{bail, Result}; +use core::{fmt, panic}; use serde::Serialize; + use std::collections::HashMap; use std::time::Instant; @@ -55,9 +59,26 @@ pub type BoardCaptures = Ranks>; #[derive(PartialEq, Default, Eq, PartialOrd, Ord, Copy, Clone)] pub struct Score(i32); +// Value in centipawns impl Score { pub const MAX: Self = Self(i32::MAX); pub const MIN: Self = Self(-i32::MAX); // -i32::MIN > i32::MAX + + pub fn piece_value(piece: PieceType) -> Score { + match piece { + PieceType::Pawn => Score::from(100), + PieceType::Knight => Score::from(300), + PieceType::Bishop => Score::from(300), + PieceType::Rook => Score::from(500), + PieceType::Queen => Score::from(900), + PieceType::King => Score::MAX, + } + } + + // Score lower than losing any piece, but higher than stalemate + pub fn stalemate() -> Score { + Score::from(Self::MIN.0 / 2) + } } impl From for Score { @@ -128,11 +149,33 @@ pub struct WeightedMove { pub score: Score, } +#[derive(Copy, Clone, PartialEq)] +pub enum GameResult { + Win(Player), + Draw, +} + #[derive(Default)] pub struct Branch { pub moves: Vec, pub score: Score, pub searched: u32, + pub result: Option, +} + +impl fmt::Display for Branch { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{} = {:+}", + self.moves + .iter() + .map(|mv| format!("{}{}{:+}", mv.mv.mv.source, mv.mv.mv.target, mv.score)) + .collect::>() + .join(" "), + self.score + ) + } } struct SearchResult { @@ -156,18 +199,6 @@ impl Default for SearchScores { } } -// Value in centipawns -fn get_piece_value(piece: PieceType) -> Score { - match piece { - PieceType::Pawn => Score::from(100), - PieceType::Knight => Score::from(300), - PieceType::Bishop => Score::from(300), - PieceType::Rook => Score::from(500), - PieceType::Queen => Score::from(900), - PieceType::King => Score::MAX, - } -} - impl PlayableGame for GameState { fn as_ref(&self) -> &GameState { self @@ -188,6 +219,7 @@ trait GamePrivate: PlayableGame + ModifiableGame &self, move_action: &MoveAction, king_position: &Position, + reset_hash: bool, ) -> Option> { let mv = &move_action.mv; let is_king = mv.source == *king_position; @@ -225,7 +257,7 @@ trait GamePrivate: PlayableGame + ModifiableGame } // Move - let new_game = self.clone_and_move(move_action).ok()?; + let new_game = self.clone_and_move(move_action, reset_hash).ok()?; // After moving, check if the king is in check @@ -307,7 +339,7 @@ trait GamePrivate: PlayableGame + ModifiableGame self.get_possible_moves_no_checks(position) .iter() .filter(|mv| { - self.clone_and_move_with_checks(mv, &king_position) + self.clone_and_move_with_checks(mv, &king_position, true) .is_some() }) .copied() @@ -334,6 +366,7 @@ trait GamePrivate: PlayableGame + ModifiableGame current_depth: u32, max_depth: u32, scores: SearchScores, + history: &mut HashedHistory, stop_signal: &mut impl HasStopSignal, feedback: &mut impl SearchFeedback, ) -> SearchResult { @@ -371,30 +404,38 @@ trait GamePrivate: PlayableGame + ModifiableGame break 'main_loop; } + let mut cutoff = false; + let mv = &possible_move.mv; let possible_position = &mv.target; searched_moves += 1; // Evaluate this move locally let local_score = match &board.at(possible_position) { - Some(piece) => get_piece_value(piece.piece), + Some(piece) => Score::piece_value(piece.piece), None => { match possible_move.move_type { MoveActionType::Promotion(promotion_piece) => { // Promotion - get_piece_value(promotion_piece.into()) + Score::piece_value(promotion_piece.into()) } _ => Score::from(0), } } }; - let Some(recursive_game) = - self.clone_and_move_with_checks(&possible_move, &king_position) + let Some(mut recursive_game) = + self.clone_and_move_with_checks(&possible_move, &king_position, false) else { continue; }; + // Threefold repetition + let hash = recursive_game.hash(); + history.push(possible_move, hash); + let repetition_count = history.count(&hash); + let threefold_repetition = repetition_count >= 3; + let mut branch = Branch { moves: vec![WeightedMove { mv: possible_move, @@ -403,6 +444,7 @@ trait GamePrivate: PlayableGame + ModifiableGame // Negamax: negate score from previous move score: local_score - scores.parent, searched: 0, + result: None, }; feedback.update(current_depth, searched_moves, branch.score.into()); @@ -411,19 +453,23 @@ trait GamePrivate: PlayableGame + ModifiableGame { let _ = writeln!( feedback, - "{}{} {} {:+} α: {}, β: {}{}", + "{}{{\"{}\": \"{} {:+} α: {}, β: {}\"{}", indent(current_depth), player, mv, branch.score, local_alpha, scores.beta, - if !is_leaf_node { " {" } else { "" }, + if !is_leaf_node { ", \"s\": [" } else { "}," }, ); } // Recursion - if !is_leaf_node { + if threefold_repetition { + // Enforce draw + branch.score = Score::stalemate(); + branch.result = Some(GameResult::Draw); + } else if !is_leaf_node { let mut search_result = recursive_game.get_best_move_recursive_alpha_beta( current_depth + 1, max_depth, @@ -433,6 +479,7 @@ trait GamePrivate: PlayableGame + ModifiableGame alpha: -scores.beta, beta: -local_alpha, }, + history, stop_signal, feedback, ); @@ -457,91 +504,103 @@ trait GamePrivate: PlayableGame + ModifiableGame { let _ = writeln!( feedback, - "{}Best child: {}", + "{}{{\"best child\": \"{}\"}},", indent(current_depth + 1), next_moves_opt .as_ref() .map_or("".to_string(), |sub_branch| { - format!( - "{}{:+}", - sub_branch.moves.first().unwrap().mv.mv, - sub_branch.score - ) + format!("{}", sub_branch) }) ); } - let is_stale_mate = next_moves_opt.is_none() && !is_check_mate; - - if is_check_mate { - branch.score = branch.score + get_piece_value(PieceType::King); - } else if !is_stale_mate { - let next_moves = next_moves_opt.as_mut().unwrap(); - + if let Some(next_moves) = next_moves_opt { branch.moves.append(&mut next_moves.moves); - branch.score = -next_moves.score; // notice the score of the next move is negated + branch.score = -next_moves.score; // notice the score of the child branch is negated branch.searched = next_moves.searched; + branch.result = next_moves.result; + } else if is_check_mate { + branch.score = branch.score + Score::piece_value(PieceType::King); + branch.result = Some(GameResult::Win(player)); + } else { + // Stalemate + branch.score = Score::stalemate(); + branch.result = Some(GameResult::Draw); } searched_moves += branch.searched; #[cfg(feature = "verbose-search")] { - let _ = writeln!(feedback, "{}}}", indent(current_depth)); - } - - if branch.score >= scores.beta && branch.score < Score::MAX { - // Fail hard beta cutoff - - #[cfg(feature = "verbose-search")] - { - let _ = writeln!( - feedback, - "{}β cutoff: {} >= {}", - indent(current_depth), - branch.score, - scores.beta - ); - } - - if best_move.as_ref().is_none() { - best_move = Some(branch); - } - - best_move.as_mut().unwrap().score = scores.beta; - - break 'main_loop; + let _ = writeln!(feedback, "{}],", indent(current_depth)); } } + history.pop().unwrap(); + match &best_move { Some(current_best_move) => { if branch.score > current_best_move.score || (branch.score == current_best_move.score && branch.moves.len() < current_best_move.moves.len()) { + #[cfg(feature = "verbose-search")] + { + let _ = writeln!( + feedback, + "{}{{\"new best move\": \"{} > {}\"}},", + indent(current_depth), + branch, + current_best_move, + ); + } best_move = Some(branch); } } None => { + #[cfg(feature = "verbose-search")] + { + let _ = writeln!( + feedback, + "{}{{\"new best move\": \"{}\"}},", + indent(current_depth), + branch, + ); + } best_move = Some(branch); } - } + }; - // This will be the beta for the next move - local_alpha = best_move.as_ref().unwrap().score; + if let Some(best_move_score) = best_move.as_ref().map(|branch| branch.score) { + if best_move_score >= scores.beta { + // Fail hard beta cutoff - if stopped { + #[cfg(feature = "verbose-search")] + { + let _ = writeln!( + feedback, + "{}{{\"β cutoff\": \"{} >= {}\"}},", + indent(current_depth), + best_move_score, + scores.beta + ); + } + + cutoff = true; + } + + // This will be the beta of the next recursion + local_alpha = best_move_score; + } + + if stopped || cutoff { break 'main_loop; } - } - } + } // possible moves loop + } // main loop - match &mut best_move { - Some(best_move) => { - best_move.searched = searched_moves; - } - None => (), + if let Some(best_move) = best_move.as_mut() { + best_move.searched = searched_moves; } SearchResult { @@ -555,6 +614,7 @@ trait GamePrivate: PlayableGame + ModifiableGame 0, 0, SearchScores::default(), + &mut HashedHistory::default(), &mut (), &mut SilentSearchFeedback::default(), ) @@ -595,7 +655,8 @@ pub trait Game: GamePrivate { return vec![]; }; - let mut game = self.as_ref().clone(); + let mut game = self.as_ref().clone_unhashed(); + // Play as the color of the position game.update_player(player); @@ -622,13 +683,13 @@ pub trait Game: GamePrivate { moves } - fn move_name(&self, move_action: &MoveAction) -> Option { + fn move_name(&self, move_action: &MoveAction) -> Result { let board = self.board(); let player = &self.player(); let mv = &move_action.mv; let mut name = String::new(); let Some(src_piece) = board.at(&mv.source) else { - return None; + bail!("No piece at {}", mv.source); }; let is_castling = @@ -721,7 +782,7 @@ pub trait Game: GamePrivate { // Is promotion? if is_pawn && mv.target.rank == B::promotion_rank(player) { let MoveActionType::Promotion(promotion_piece) = move_action.move_type else { - return None; + bail!("Promotion piece not specified in {}", move_action.mv); }; name.push('='); name.push(piece_char(&promotion_piece.into()).unwrap()); @@ -729,7 +790,7 @@ pub trait Game: GamePrivate { } // Is check? - let mut new_game = self.as_ref().clone(); + let mut new_game = self.as_ref().clone_unhashed(); if Game::do_move(&mut new_game, move_action).is_some() { let enemy_king_position = new_game.board().find_king(&!*player); @@ -740,22 +801,28 @@ pub trait Game: GamePrivate { name.push(if is_checkmate { '#' } else { '+' }); } - Some(name) + Ok(name) } else { - None + bail!("Invalid move {}", move_action.mv); } } fn get_best_move_recursive( &self, search_depth: u32, + history: &GameHistory, stop_signal: &mut impl HasStopSignal, feedback: &mut impl SearchFeedback, ) -> Option { + let mut hashed_history = HashedHistory::from(history).ok()?; + + hashed_history.reserve(search_depth as usize); + self.get_best_move_recursive_alpha_beta( 0, search_depth, SearchScores::default(), + &mut hashed_history, stop_signal, feedback, ) @@ -777,6 +844,7 @@ pub trait Game: GamePrivate { fn get_best_move_with_logger( &self, search_depth: u32, + history: &GameHistory, stop_signal: &mut impl HasStopSignal, engine_feedback: &mut impl EngineFeedback, ) -> GameMove { @@ -784,7 +852,8 @@ pub trait Game: GamePrivate { let start_time = Instant::now(); let mut feedback = PeriodicalSearchFeedback::new(std::time::Duration::from_millis(500), engine_feedback); - let best_branch = self.get_best_move_recursive(search_depth, stop_signal, &mut feedback); + let best_branch = + self.get_best_move_recursive(search_depth, history, stop_signal, &mut feedback); let duration = (Instant::now() - start_time).as_secs_f64(); if best_branch.is_none() { @@ -832,10 +901,10 @@ pub trait Game: GamePrivate { total_score, total_moves, std::iter::zip( - &best_branch.as_ref().unwrap().moves, - self.move_branch_names(&branch_moves) + self.move_branch_names(&branch_moves), + &best_branch.as_ref().unwrap().moves ) - .map(|(move_info, move_name)| format!("{}{:+}", move_name, move_info.score)) + .map(|(move_name, move_info)| format!("{}{:+}", move_name, move_info.score)) .collect::>() .join(" ") ); @@ -843,8 +912,13 @@ pub trait Game: GamePrivate { GameMove::Normal(**branch_moves.first().unwrap()) } - fn get_best_move(&self, search_depth: u32) -> GameMove { - self.get_best_move_with_logger(search_depth, &mut (), &mut StdoutFeedback::default()) + fn get_best_move(&self, history: &GameHistory, search_depth: u32) -> GameMove { + self.get_best_move_with_logger( + search_depth, + history, + &mut (), + &mut StdoutFeedback::default(), + ) } fn is_mate(&self) -> Option { diff --git a/chusst-gen/src/eval/history.rs b/chusst-gen/src/eval/history.rs new file mode 100644 index 0000000..a17bb06 --- /dev/null +++ b/chusst-gen/src/eval/history.rs @@ -0,0 +1,71 @@ +use crate::game::{GameHash, GameHashBuilder, MoveAction, SimpleGame}; +use anyhow::{Context, Result}; +use std::collections::HashMap; + +use super::Game; + +pub type GameHistory = Vec; + +pub struct HashedHistory { + moves: Vec<(MoveAction, GameHash)>, + hashes: HashMap, GameHashBuilder>, + // hashes: HashMap>, +} + +impl Default for HashedHistory { + fn default() -> Self { + Self { + moves: Vec::new(), + hashes: HashMap::with_hasher(GameHashBuilder), + // hashes: HashMap::new(), + } + } +} + +impl HashedHistory { + pub fn from(moves: &GameHistory) -> Result { + let mut game = SimpleGame::new(); + let mut history = Self::default(); + + history.reserve(moves.len() + 1); + + // Hash of the initial position is always added, even if no moves are made + history.hashes.insert(game.hash(), Vec::new()); + + for mv in moves { + let hash = game.hash(); + history.push(*mv, hash); + game.do_move(mv) + .context(format!("Invalid move {}", mv.mv))?; + } + + Ok(history) + } + + pub fn reserve(&mut self, additional: usize) { + self.moves.reserve(additional); + self.hashes.reserve(additional); + } + + pub fn push(&mut self, mv: MoveAction, hash: GameHash) { + self.moves.push((mv, hash)); + self.hashes + .entry(hash) + .or_insert(Vec::with_capacity(2)) + .push(self.moves.len() - 1); + } + + pub fn pop(&mut self) -> Result { + let (mv, hash) = self.moves.pop().context("Empty")?; + self.hashes + .get_mut(&hash) + .context("Hash not found")? + .pop() + .context("No moves for this hash")?; + Ok(mv) + } + + pub fn count(&self, hash: &GameHash) -> usize { + self.hashes.get(hash).map_or(0, |v| v.len()) + } +} diff --git a/chusst-gen/src/eval/play.rs b/chusst-gen/src/eval/play.rs index b432bde..f66e679 100644 --- a/chusst-gen/src/eval/play.rs +++ b/chusst-gen/src/eval/play.rs @@ -12,8 +12,12 @@ where fn as_ref(&self) -> &GameState; fn as_mut(&mut self) -> &mut GameState; - fn clone_and_move(&self, mv: &MoveAction) -> Result> { - let mut new_game = self.as_ref().clone(); + fn clone_and_move(&self, mv: &MoveAction, reset_hash: bool) -> Result> { + let mut new_game = if reset_hash { + self.as_ref().clone_unhashed() + } else { + self.as_ref().clone() + }; PlayableGame::do_move_no_checks(&mut new_game, mv)?; Ok(new_game) } diff --git a/chusst-gen/src/eval/tests.rs b/chusst-gen/src/eval/tests.rs index 299c30b..e88502f 100644 --- a/chusst-gen/src/eval/tests.rs +++ b/chusst-gen/src/eval/tests.rs @@ -8,87 +8,218 @@ use crate::game::{ }; use crate::{mva, p, pos}; -struct PiecePosition { - piece: Option, - position: Position, +enum Check { + PiecePosition { + piece: Option, + position: Position, + }, + Action(Box), +} + +impl Check { + pub fn from_action(action: impl Fn(&mut TestGame) + 'static) -> Check { + Check::Action(Box::new(action)) + } } macro_rules! pp { ($piece:ident @ $pos:ident) => { - PiecePosition { + Check::PiecePosition { piece: p!($piece), position: pos!($pos), } }; ($pos:ident) => { - PiecePosition { + Check::PiecePosition { piece: None, position: pos!($pos), } }; } +#[derive(Clone, Copy)] +#[allow(dead_code)] +enum TestMove { + AsMove(MoveAction), + AsString(&'static str), +} + +macro_rules! tm { + ($src:ident => $tgt:ident) => { + TestMove::AsMove(mva!($src => $tgt)) + }; + ($src:ident => $tgt:ident, $promote:ident) => { + TestMove::AsMove(mva!($src => $tgt, $promote)) + }; + ($str:expr) => { + TestMove::AsString($str) + }; +} + +fn from_test_move(game: &TestGame, mv: &TestMove) -> MoveAction { + match mv { + TestMove::AsMove(mv) => *mv, + TestMove::AsString(mv_str) => { + let all_moves = game.get_all_possible_moves(); + let Some(mv) = all_moves + .iter() + .find(|mv| game.move_name(mv).unwrap().as_str() == *mv_str) + else { + panic!( + "move {} not found:\n{}\npossible moves: {}", + mv_str, + game.board(), + all_moves + .iter() + .map(|mv| game.move_name(mv).unwrap()) + .collect::>() + .join(", ") + ); + }; + mv.to_owned() + } + } +} + struct TestBoard<'a> { board: Option<&'a str>, - initial_moves: Vec, + player: Player, + initial_moves: Vec, mv: MoveAction, - checks: Vec, + checks: Vec, } -type TestGame = SimpleGame; +struct GameTestCase { + initial_moves: Vec, + mv: MoveAction, + checks: Vec, + game: TestGame, +} + +impl GameTestCase { + pub fn new(params: TestBoard) -> GameTestCase { + let mut game: TestGame = Self::custom_game(¶ms.board, params.player); + game.update_player(params.player); + + GameTestCase { + initial_moves: params.initial_moves, + mv: params.mv, + checks: params.checks, + game, + } + } + pub fn do_initial_moves(&mut self) { + for tm in &self.initial_moves { + let mv = &from_test_move(&self.game, tm); + assert!( + self.game.do_move(mv).is_some(), + "move {} failed:\n{}", + mv.mv, + self.game.board() + ); + } + } -fn custom_game(board_opt: &Option<&str>, player: Player) -> GameState { - let mut game = match board_opt { - Some(board_str) => { - let mut board = B::default(); - - let mut rank = 8usize; - for line in board_str.lines() { - match line.find('[') { - Some(position) => { - rank -= 1; - - for (file, piece_char) in line - .chars() - .skip(position) - .filter(|c| *c != '[' && *c != ']') - .enumerate() - { - let piece = match piece_char { - '♙' => p!(pw), - '♘' => p!(nw), - '♗' => p!(bw), - '♖' => p!(rw), - '♕' => p!(qw), - '♔' => p!(kw), - '♟' => p!(pb), - '♞' => p!(nb), - '♝' => p!(bb), - '♜' => p!(rb), - '♛' => p!(qb), - '♚' => p!(kb), - ' ' => p!(), - _ => { - panic!( - "unexpected character '\\u{:x}' in board line: {}", - piece_char as u32, line - ) - } - }; - board.update(&pos!(rank, file), piece); + pub fn make_checks(&mut self) { + for check in &self.checks { + match check { + Check::PiecePosition { piece, position } => { + assert_eq!( + &self.game.at(position), + piece, + "expected {} in {}, found {}:\n{}", + piece.map_or("nothing".to_string(), |piece| format!("{}", piece.piece)), + position, + self.game + .as_ref() + .at(position) + .map_or("nothing".to_string(), |piece| format!("{}", piece.piece)), + self.game.board(), + ); + } + Check::Action(action) => { + action(&mut self.game); + } + } + } + } + + fn custom_game(board_opt: &Option<&str>, player: Player) -> GameState { + let mut game = match board_opt { + Some(board_str) => { + let mut board = B::default(); + + let mut rank = 8usize; + for line in board_str.lines() { + match line.find('[') { + Some(position) => { + rank -= 1; + + for (file, piece_char) in line + .chars() + .skip(position) + .filter(|c| *c != '[' && *c != ']') + .enumerate() + { + let piece = match piece_char { + '♙' => p!(pw), + '♘' => p!(nw), + '♗' => p!(bw), + '♖' => p!(rw), + '♕' => p!(qw), + '♔' => p!(kw), + '♟' => p!(pb), + '♞' => p!(nb), + '♝' => p!(bb), + '♜' => p!(rb), + '♛' => p!(qb), + '♚' => p!(kb), + ' ' => p!(), + _ => { + panic!( + "unexpected character '\\u{:x}' in board line: {}", + piece_char as u32, line + ) + } + }; + board.update(&pos!(rank, file), piece); + } } + None => continue, } - None => continue, } + GameState::from(board) } - GameState::from(board) - } - None => GameState::from(B::NEW_BOARD), - }; + None => GameState::from(B::NEW_BOARD), + }; + + game.update_player(player); + + game + } +} + +type TestGame = SimpleGame; - game.update_player(player); +struct MoveChain<'a> { + game: &'a mut TestGame, +} + +impl<'a> MoveChain<'a> { + pub fn new(game: &'a mut TestGame) -> MoveChain<'a> { + MoveChain { game } + } - game + pub fn do_move(&mut self, mv: TestMove) -> &mut Self { + let mv = &from_test_move(self.game, &mv); + assert!( + self.game.do_move(mv).is_some(), + "move {} failed:\n{}", + mv.mv, + self.game.board() + ); + self + } } fn game_from_fen(fen: &str) -> TestGame { @@ -243,6 +374,7 @@ fn move_reversable() { // Advance pawn TestBoard { board: None, + player: Player::White, initial_moves: vec![], mv: mva!(e2 => e3), checks: vec![pp!(pw @ e3), pp!(e2)], @@ -250,6 +382,7 @@ fn move_reversable() { // Pass pawn TestBoard { board: None, + player: Player::White, initial_moves: vec![], mv: mva!(e2 => e4), checks: vec![pp!(pw @ e4), pp!(e2)], @@ -257,34 +390,32 @@ fn move_reversable() { // Pawn capturing TestBoard { board: None, - initial_moves: vec![mva!(e2 => e4), mva!(d7 => d5)], + player: Player::White, + initial_moves: vec![tm!(e2 => e4), tm!(d7 => d5)], mv: mva!(e4 => d5), checks: vec![pp!(pw @ d5), pp!(e4)], }, // Pawn capturing en passant TestBoard { board: None, - initial_moves: vec![ - mva!(e2 => e4), - mva!(a7 => a6), - mva!(e4 => e5), - mva!(d7 => d5), - ], + player: Player::White, + initial_moves: vec![tm!(e2 => e4), tm!(a7 => a6), tm!(e4 => e5), tm!(d7 => d5)], mv: mva!(e5 => d6), checks: vec![pp!(pw @ d6), pp!(e5), pp!(d5)], }, // Pawn promotion to knight TestBoard { board: None, + player: Player::White, initial_moves: vec![ - mva!(h2 => h4), - mva!(g7 => g6), - mva!(h4 => h5), - mva!(a7 => a6), - mva!(h5 => g6), - mva!(a6 => a5), - mva!(g6 => g7), - mva!(a5 => a4), + tm!(h2 => h4), + tm!(g7 => g6), + tm!(h4 => h5), + tm!(a7 => a6), + tm!(h5 => g6), + tm!(a6 => a5), + tm!(g6 => g7), + tm!(a5 => a4), ], mv: mva!(g7 => h8, PromotionPieces::Knight), checks: vec![pp!(nw @ h8)], @@ -292,15 +423,16 @@ fn move_reversable() { // Pawn promotion to bishop TestBoard { board: None, + player: Player::White, initial_moves: vec![ - mva!(h2 => h4), - mva!(g7 => g6), - mva!(h4 => h5), - mva!(a7 => a6), - mva!(h5 => g6), - mva!(a6 => a5), - mva!(g6 => g7), - mva!(a5 => a4), + tm!(h2 => h4), + tm!(g7 => g6), + tm!(h4 => h5), + tm!(a7 => a6), + tm!(h5 => g6), + tm!(a6 => a5), + tm!(g6 => g7), + tm!(a5 => a4), ], mv: mva!(g7 => h8, PromotionPieces::Bishop), checks: vec![pp!(bw @ h8)], @@ -308,15 +440,16 @@ fn move_reversable() { // Pawn promotion to rook TestBoard { board: None, + player: Player::White, initial_moves: vec![ - mva!(h2 => h4), - mva!(g7 => g6), - mva!(h4 => h5), - mva!(a7 => a6), - mva!(h5 => g6), - mva!(a6 => a5), - mva!(g6 => g7), - mva!(a5 => a4), + tm!(h2 => h4), + tm!(g7 => g6), + tm!(h4 => h5), + tm!(a7 => a6), + tm!(h5 => g6), + tm!(a6 => a5), + tm!(g6 => g7), + tm!(a5 => a4), ], mv: mva!(g7 => h8, PromotionPieces::Rook), checks: vec![pp!(rw @ h8)], @@ -324,15 +457,16 @@ fn move_reversable() { // Pawn promotion to queen TestBoard { board: None, + player: Player::White, initial_moves: vec![ - mva!(h2 => h4), - mva!(g7 => g6), - mva!(h4 => h5), - mva!(a7 => a6), - mva!(h5 => g6), - mva!(a6 => a5), - mva!(g6 => g7), - mva!(a5 => a4), + tm!(h2 => h4), + tm!(g7 => g6), + tm!(h4 => h5), + tm!(a7 => a6), + tm!(h5 => g6), + tm!(a6 => a5), + tm!(g6 => g7), + tm!(a5 => a4), ], mv: mva!(g7 => h8, PromotionPieces::Queen), checks: vec![pp!(qw @ h8)], @@ -340,13 +474,14 @@ fn move_reversable() { // Kingside castling TestBoard { board: None, + player: Player::White, initial_moves: vec![ - mva!(e2 => e3), - mva!(a7 => a6), - mva!(f1 => e2), - mva!(b7 => b6), - mva!(g1 => h3), - mva!(c7 => c6), + tm!(e2 => e3), + tm!(a7 => a6), + tm!(f1 => e2), + tm!(b7 => b6), + tm!(g1 => h3), + tm!(c7 => c6), ], mv: mva!(e1 => g1), checks: vec![pp!(kw @ g1), pp!(rw @ f1)], @@ -354,58 +489,38 @@ fn move_reversable() { // Queenside castling TestBoard { board: None, + player: Player::White, initial_moves: vec![ - mva!(d2 => d4), - mva!(a7 => a6), - mva!(d1 => d3), - mva!(b7 => b6), - mva!(c1 => d2), - mva!(c7 => c6), - mva!(b1 => c3), - mva!(d7 => d6), + tm!(d2 => d4), + tm!(a7 => a6), + tm!(d1 => d3), + tm!(b7 => b6), + tm!(c1 => d2), + tm!(c7 => c6), + tm!(b1 => c3), + tm!(d7 => d6), ], mv: mva!(e1 => c1), checks: vec![pp!(kw @ c1), pp!(rw @ d1)], }, ]; - for test_board in &test_boards { - // Prepare board - let mut game: TestGame = custom_game(&test_board.board, Player::White); + for test_board in test_boards { + let mut test_case = GameTestCase::new(test_board); // Do setup moves - for mv in &test_board.initial_moves { - assert!( - game.do_move(mv).is_some(), - "move {} failed:\n{}", - mv.mv, - game.board() - ); - } + test_case.do_initial_moves(); + let game = &mut test_case.game; // Do move assert!( - game.do_move_with_checks(&test_board.mv), + game.do_move_with_checks(&test_case.mv), "failed to make legal move {} in:\n{}", - test_board.mv.mv, + test_case.mv.mv, game.board() ); - for check in &test_board.checks { - assert_eq!( - game.as_ref().at(&check.position), - check.piece, - "expected {} in {}, found {}:\n{}", - check - .piece - .map_or("nothing".to_string(), |piece| format!("{}", piece.piece)), - check.position, - game.as_ref() - .at(&check.position) - .map_or("nothing".to_string(), |piece| format!("{}", piece.piece)), - game.board(), - ); - } + test_case.make_checks(); } } @@ -426,6 +541,7 @@ fn check_mate() { 2 [ ][ ][♛][ ][ ][ ][ ][ ]\n\ 1 [♔][ ][ ][ ][ ][ ][ ][ ]", ), + player: Player::Black, initial_moves: vec![], mv: mva!(b3 => b2), checks: vec![], @@ -442,6 +558,7 @@ fn check_mate() { 2 [♟][ ][ ][ ][ ][ ][ ][ ]\n\ 1 [♔][ ][ ][ ][ ][ ][ ][ ]", ), + player: Player::Black, initial_moves: vec![], mv: mva!(b3 => b2), checks: vec![], @@ -458,6 +575,7 @@ fn check_mate() { 2 [ ][ ][ ][ ][ ][ ][ ][ ]\n\ 1 [♔][ ][ ][ ][ ][ ][ ][ ]", ), + player: Player::Black, initial_moves: vec![], mv: mva!(b8 => a8), checks: vec![], @@ -474,6 +592,7 @@ fn check_mate() { 2 [ ][ ][ ][ ][ ][ ][ ][ ]\n\ 1 [♔][ ][ ][ ][ ][ ][ ][ ]", ), + player: Player::Black, initial_moves: vec![], mv: mva!(f8 => g7), checks: vec![], @@ -490,6 +609,7 @@ fn check_mate() { 2 [ ][ ][ ][ ][ ][ ][ ][ ]\n\ 1 [♔][ ][ ][♞][ ][ ][ ][ ]", ), + player: Player::Black, initial_moves: vec![], mv: mva!(a5 => b3), checks: vec![], @@ -498,38 +618,41 @@ fn check_mate() { for test_board in test_boards { // Prepare board - let mut game: TestGame = custom_game(&test_board.board, Player::Black); + let mut test_case = GameTestCase::new(test_board); - game.disable_castle_kingside(Player::White); - game.disable_castle_kingside(Player::Black); - game.disable_castle_queenside(Player::White); - game.disable_castle_queenside(Player::Black); + test_case.game.disable_castle_kingside(Player::White); + test_case.game.disable_castle_kingside(Player::Black); + test_case.game.disable_castle_queenside(Player::White); + test_case.game.disable_castle_queenside(Player::Black); // Do setup moves - for mv in &test_board.initial_moves { - assert!( - game.do_move(mv).is_some(), - "move {} failed:\n{}", - mv.mv, - game.board() - ); - } + test_case.do_initial_moves(); + + let game = &mut test_case.game; - let name = game.move_name(&test_board.mv).unwrap(); + let name = match game.move_name(&test_case.mv) { + Ok(name) => name, + Err(err) => panic!( + "no move name for {}: {}\n{}", + test_case.mv.mv, + err, + game.board() + ), + }; assert!( name.ends_with('#'), "notation `{}` for move {} doesn't show checkmate sign # in:\n{}", name, - test_board.mv.mv, + test_case.mv.mv, game.board() ); // Do move assert!( - game.do_move_with_checks(&test_board.mv), + game.do_move_with_checks(&test_case.mv), "invalid move {}:\n{}", - test_board.mv.mv, + test_case.mv.mv, game.board() ); @@ -542,11 +665,133 @@ fn check_mate() { possible_moves.first().unwrap().mv, game.board() ); + + test_case.make_checks(); } } #[test] -fn fen_parsing() { +fn zobrist() { + // Deterministic hash + assert_eq!(TestGame::new().hash(), TestGame::new().hash()); + + let test_boards = [ + // Hash changes after move + TestBoard { + board: None, + player: Player::White, + initial_moves: vec![], + mv: mva!(e2 => e3), + checks: vec![Check::from_action(|game| { + assert_ne!( + game.hash(), + TestGame::new().hash(), + "hash should change after move:\n{}", + game.board() + ); + })], + }, + // Castling rights + TestBoard { + board: None, + player: Player::White, + initial_moves: vec![tm!("Nf3")], + mv: mva!(a7 => a6), + checks: vec![Check::from_action(|game| { + let hash_before = game.hash(); + game.do_move(&mva!(h1 => g1)); // moving rook loses kingside castling rights + game.do_move(&mva!(b7 => b6)); + game.do_move(&mva!(g1 => h1)); // return rook to original position + + assert_ne!( + game.hash(), + hash_before, + "hash should change after castling:\n{}", + game.board() + ); + })], + }, + ]; + + for test_board in test_boards { + let mut test_case = GameTestCase::new(test_board); + + // Do setup moves + test_case.do_initial_moves(); + let game = &mut test_case.game; + + // Do move + assert!( + game.do_move_with_checks(&test_case.mv), + "invalid move {}:\n{}", + test_case.mv.mv, + game.board() + ); + + test_case.make_checks(); + } + + // Order of moves doesn't matter + let mut game1 = TestGame::new(); + let mut game2 = TestGame::new(); + MoveChain::new(&mut game1) + .do_move(tm!("e3")) + .do_move(tm!("e6")) + .do_move(tm!("d3")); + MoveChain::new(&mut game2) + .do_move(tm!("d3")) + .do_move(tm!("e6")) + .do_move(tm!("e3")); + assert_eq!( + game1.hash(), + game2.hash(), + "hash should be the same after same moves in different order:\n{}\n{}", + game1.board(), + game2.board(), + ); + + // En passant + let mut game1 = TestGame::new(); + let mut game2 = TestGame::new(); + MoveChain::new(&mut game1) + .do_move(tm!("e4")) // en passant active at file e + .do_move(tm!("e6")) // en passant deactivated + .do_move(tm!("d3")); // no en passant file + MoveChain::new(&mut game2) + .do_move(tm!("d3")) // no en passant file + .do_move(tm!("e6")) // no en passant file + .do_move(tm!("e4")); // en passant active at file e + assert_ne!( + game1.hash(), + game2.hash(), + "hash should be different with different en passant files active:\n{}\n{}", + game1.board(), + game2.board(), + ); + + // Getting the hash after making the moves should be the same + let mut game1 = TestGame::new(); + let mut game2 = TestGame::new(); + + // game1 has a hash from the beginning + game1.hash(); + + MoveChain::new(&mut game1).do_move(tm!("e4")); + MoveChain::new(&mut game2).do_move(tm!("e4")); + let hash1 = game1.hash(); + let hash2 = game2.hash(); // game2 gets the hash from scratch after making the moves + assert_eq!( + hash1, + hash2, + "hash should be the same after if obtained after making the moves:\n{}\n{}", + game1.board(), + game2.board(), + ); +} + +#[test] +fn fen() { + // Parsing let start_pos_fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; let parsed_game = TestGame::try_from_fen( start_pos_fen @@ -557,6 +802,15 @@ fn fen_parsing() { assert!(parsed_game.is_some(), "Failed to parse FEN string"); let game = parsed_game.unwrap(); assert_eq!(game, TestGame::new(), "\n{}", game.board()); + + // Generating + assert_eq!( + game.to_fen(), + start_pos_fen, + "FEN string should match the original:\n{}\n{}", + start_pos_fen, + game.to_fen() + ); } fn perft_impl(force_comparison: bool) { @@ -658,6 +912,7 @@ fn perft_slow() { fn quick_test() { // White: ♙ ♘ ♗ ♖ ♕ ♔ // Black: ♟ ♞ ♝ ♜ ♛ ♚ + #[rustfmt::skip] let test_boards = [TestBoard { board: Some( " a b c d e f g h \n\ @@ -670,6 +925,7 @@ fn quick_test() { 2 [ ][ ][♙][♕][ ][ ][ ][ ]\n\ 1 [♖][ ][ ][ ][♔][ ][ ][♖]", ), + player: Player::White, initial_moves: vec![], mv: mva!(e1 => c1), checks: vec![], @@ -677,26 +933,21 @@ fn quick_test() { for test_board in test_boards { // Prepare board - let mut game: TestGame = custom_game(&test_board.board, Player::White); + let mut test_case = GameTestCase::new(test_board); - game.disable_castle_kingside(Player::White); - game.disable_castle_kingside(Player::Black); + test_case.game.disable_castle_kingside(Player::White); + test_case.game.disable_castle_kingside(Player::Black); // Do setup moves - for mv in &test_board.initial_moves { - assert!( - game.do_move(mv).is_some(), - "move {} failed:\n{}", - mv.mv, - game.board() - ); - } + test_case.do_initial_moves(); + + let game = &mut test_case.game; // Do move assert!( - game.do_move_with_checks(&test_board.mv), + game.do_move_with_checks(&test_case.mv), "invalid move {}:\n{}", - test_board.mv.mv, + test_case.mv.mv, game.as_ref().board() ); } diff --git a/chusst-gen/src/game.rs b/chusst-gen/src/game.rs index 4ff68f9..4ce1014 100644 --- a/chusst-gen/src/game.rs +++ b/chusst-gen/src/game.rs @@ -1,12 +1,18 @@ mod play; +mod zobrist; + +use std::fmt; -use crate::board::{Board, ModifiableBoard, Piece, PieceType, Player, Position, SimpleBoard}; -use crate::{mv, pos}; use anyhow::Result; use serde::ser::SerializeMap; use serde::Serialize; -use std::fmt; +use crate::board::{Board, ModifiableBoard, Piece, PieceType, Player, Position, SimpleBoard}; +use crate::{mv, pos}; +pub use zobrist::ZobristHash as GameHash; +pub use zobrist::ZobristHashBuilder as GameHashBuilder; + +// Exports pub use play::ModifiableGame; #[derive(Copy, Clone, Debug, PartialEq, Serialize)] @@ -252,6 +258,7 @@ pub struct GameMobilityData { player: Player, last_move: Option, info: GameInfo, + hash: Option, } #[derive(Clone, Debug, PartialEq)] @@ -282,6 +289,7 @@ impl From for GameState { player: Player::White, last_move: None, info: GameInfo::new(), + hash: None, }, } } @@ -295,10 +303,17 @@ impl GameState { player: Player::White, last_move: None, info: GameInfo::new(), + hash: None, }, } } + pub fn clone_unhashed(&self) -> Self { + let mut new_game = self.clone(); + new_game.data.hash = None; + new_game + } + pub fn data(&self) -> &GameMobilityData { &self.data } @@ -373,9 +388,96 @@ impl GameState { player, last_move, info, + hash: None, }, }) } + + pub fn to_fen(&self) -> String { + let mut fen = self.board.to_fen(); + let player = match self.data.player { + Player::White => "w", + Player::Black => "b", + }; + let mut castling = format!( + "{}{}{}{}", + if self.data.info.can_castle_kingside(Player::White) { + "K" + } else { + "" + }, + if self.data.info.can_castle_queenside(Player::White) { + "Q" + } else { + "" + }, + if self.data.info.can_castle_kingside(Player::Black) { + "k" + } else { + "" + }, + if self.data.info.can_castle_queenside(Player::Black) { + "q" + } else { + "" + } + ); + + if castling.is_empty() { + castling = "-".to_string(); + } + + let en_passant = match self.data.last_move { + Some(MoveInfo { + mv: Move { source: _, target }, + info: MoveExtraInfo::EnPassant, + }) => { + let rank = match self.data.player { + Player::White => target.rank + 1, + Player::Black => target.rank - 1, + }; + format!("{}", pos!(rank, target.file)) + } + _ => "-".to_string(), + }; + + fen.push_str(&format!(" {} {} {} 0 1", player, castling, en_passant)); + + fen + } + + pub fn hash(&mut self) -> GameHash { + if let Some(hash) = self.data.hash { + return hash; + } + + let mut hash = GameHash::from(&self.board); + + if self.data.player == Player::Black { + hash.switch_turn(); + } + + for player in [Player::White, Player::Black] { + if !self.data.info.can_castle_kingside(player) { + hash.switch_kingside_castling(player); + } + if !self.data.info.can_castle_queenside(player) { + hash.switch_queenside_castling(player); + } + } + + if let Some(MoveInfo { + mv, + info: MoveExtraInfo::Passed, + }) = self.data.last_move + { + hash.switch_en_passant_file(mv.target.file); + } + + self.data.hash = Some(hash); + + hash + } } impl ModifiableBoard> for GameState { @@ -384,10 +486,21 @@ impl ModifiableBoard> for GameState { } fn update(&mut self, pos: &Position, value: Option) { + if let Some(hash) = self.data.hash.as_mut() { + hash.update_piece(pos, self.board.at(pos), value); + } self.board.update(pos, value); } fn move_piece(&mut self, source: &Position, target: &Position) { + if let Some(hash) = self.data.hash.as_mut() { + if let Some(captured_piece) = self.board.at(target) { + hash.update_piece(target, Some(captured_piece), None); + } + if let Some(moved_piece) = self.board.at(source) { + hash.move_piece(source, target, moved_piece); + } + } self.board.move_piece(source, target); } } @@ -402,11 +515,23 @@ impl CastlingRights for GameState { } fn disable_castle_kingside(&mut self, player: Player) { + if !self.data.info.can_castle_kingside(player) { + return; + } self.data.info.disable_castle_kingside(player); + if let Some(hash) = self.data.hash.as_mut() { + hash.switch_kingside_castling(player); + } } fn disable_castle_queenside(&mut self, player: Player) { + if !self.data.info.can_castle_queenside(player) { + return; + } self.data.info.disable_castle_queenside(player); + if let Some(hash) = self.data.hash.as_mut() { + hash.switch_queenside_castling(player); + } } } diff --git a/chusst-gen/src/game/play.rs b/chusst-gen/src/game/play.rs index df4f0e1..fc1ba67 100644 --- a/chusst-gen/src/game/play.rs +++ b/chusst-gen/src/game/play.rs @@ -39,6 +39,9 @@ impl ModifiableGame for GameState { fn update_player(&mut self, player: Player) { self.data.player = player; + if let Some(hash) = self.data.hash.as_mut() { + hash.switch_turn(); + } } fn info(&self) -> &GameInfo { @@ -94,6 +97,11 @@ impl ModifiableGame for GameState { self.move_piece(&mv.source, &mv.target); match move_info { + MoveExtraInfo::Passed => { + if let Some(hash) = self.data.hash.as_mut() { + hash.switch_en_passant_file(mv.source.file); + } + } MoveExtraInfo::EnPassant => { // Capture passed pawn let direction = B::pawn_progress_direction(&player); @@ -147,7 +155,15 @@ impl ModifiableGame for GameState { } } - self.data.player = !self.data.player; + if let Some(last_move) = self.data.last_move { + if last_move.info == MoveExtraInfo::Passed { + if let Some(hash) = self.data.hash.as_mut() { + hash.switch_en_passant_file(last_move.mv.source.file); + } + } + } + + self.update_player(!self.data.player); self.data.last_move = Some(MoveInfo { mv: *mv, info: move_info, diff --git a/chusst-gen/src/game/zobrist.rs b/chusst-gen/src/game/zobrist.rs new file mode 100644 index 0000000..41a4b53 --- /dev/null +++ b/chusst-gen/src/game/zobrist.rs @@ -0,0 +1,250 @@ +use crate::game::Position; +use lazy_static::lazy_static; +use rand::prelude::*; +use std::hash::{BuildHasher, Hasher}; +use std::ops::BitXorAssign; +use std::{fmt, sync::Mutex}; + +use crate::{ + board::{Board, Piece, PieceType, Player}, + pos, +}; + +#[derive(Default, Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct ZobristHash(u64); + +impl BitXorAssign for ZobristHash { + fn bitxor_assign(&mut self, rhs: Self) { + self.0 ^= rhs.0; + } +} + +impl fmt::Display for ZobristHash { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:016x}", self.0) + } +} + +impl From for u64 { + fn from(value: ZobristHash) -> u64 { + value.0 + } +} + +impl From for ZobristHash { + fn from(value: u64) -> Self { + Self(value) + } +} + +#[derive(Default)] +pub struct ZobristHasher(ZobristHash); + +/// Custom Hasher for ZobristHasher that just uses the hash value unchanged, +/// to avoid hashing on top of the Zobrist hash. +impl Hasher for ZobristHasher { + fn finish(&self) -> u64 { + self.0.into() + } + + fn write(&mut self, _bytes: &[u8]) { + unimplemented!() + } + + fn write_u64(&mut self, i: u64) { + self.0 = ZobristHash::from(i); + } +} + +#[derive(Default)] +pub struct ZobristHashBuilder; + +impl BuildHasher for ZobristHashBuilder { + type Hasher = ZobristHasher; + + fn build_hasher(&self) -> Self::Hasher { + ZobristHasher::default() + } +} + +#[derive(Copy, Clone)] +struct RandomHash(ZobristHash); + +impl Default for RandomHash { + fn default() -> Self { + Self(ZobristHash(RNG.lock().unwrap().gen())) + } +} + +impl From for ZobristHash { + fn from(value: RandomHash) -> Self { + value.0 + } +} + +#[derive(Default)] +struct PiecesHash { + pawn: RandomHash, + knight: RandomHash, + bishop: RandomHash, + rook: RandomHash, + queen: RandomHash, + king: RandomHash, +} + +impl PiecesHash { + fn by_piece(&self, piece: PieceType) -> ZobristHash { + match piece { + PieceType::Pawn => self.pawn, + PieceType::Knight => self.knight, + PieceType::Bishop => self.bishop, + PieceType::Rook => self.rook, + PieceType::Queen => self.queen, + PieceType::King => self.king, + } + .into() + } +} + +#[derive(Default)] +struct CastlingRightsHash { + does_not_have_kingside_hash: RandomHash, + does_not_have_queenside_hash: RandomHash, +} + +#[derive(Default)] +struct ByPlayer { + white: T, + black: T, +} + +impl ByPlayer { + fn by_player(&self, player: Player) -> &T { + match player { + Player::White => &self.white, + Player::Black => &self.black, + } + } +} + +#[derive(Default)] +struct ByFile([T; 8]); + +impl ByFile { + fn at(&self, file: usize) -> &T { + &self.0[file] + } +} + +#[derive(Default)] +struct ByPosition([ByFile; 8]); + +impl ByPosition { + fn at(&self, rank: usize, file: usize) -> &T { + self.0[rank].at(file) + } +} + +#[derive(Default)] +struct RandomTable { + pieces: ByPosition>, + black_turn: RandomHash, + castling: ByPlayer, + can_do_en_passant: ByFile, +} + +lazy_static! { + // Deterministic random number generator. + static ref RNG: Mutex = Mutex::new(StdRng::seed_from_u64(0)); + static ref RANDOM_TABLE: RandomTable = RandomTable::default(); +} + +impl ZobristHash { + pub fn switch_turn(&mut self) { + self.0 ^= RANDOM_TABLE.black_turn.0 .0; + } + + pub fn switch_kingside_castling(&mut self, player: Player) { + self.0 ^= RANDOM_TABLE + .castling + .by_player(player) + .does_not_have_kingside_hash + .0 + .0; + } + + pub fn switch_queenside_castling(&mut self, player: Player) { + self.0 ^= RANDOM_TABLE + .castling + .by_player(player) + .does_not_have_queenside_hash + .0 + .0; + } + + pub fn switch_en_passant_file(&mut self, file: usize) { + self.0 ^= RANDOM_TABLE.can_do_en_passant.at(file).0 .0; + } + + pub fn update_piece( + &mut self, + position: &Position, + old_piece: Option, + new_piece: Option, + ) { + // First remove the old piece + if let Some(Piece { piece, player }) = old_piece { + self.0 ^= RANDOM_TABLE + .pieces + .at(position.rank, position.file) + .by_player(player) + .by_piece(piece) + .0; + } + // Then add the new piece + if let Some(Piece { piece, player }) = new_piece { + self.0 ^= RANDOM_TABLE + .pieces + .at(position.rank, position.file) + .by_player(player) + .by_piece(piece) + .0; + } + } + + pub fn move_piece(&mut self, source: &Position, target: &Position, moved_piece: Piece) { + // First remove the old piece + self.0 ^= RANDOM_TABLE + .pieces + .at(source.rank, source.file) + .by_player(moved_piece.player) + .by_piece(moved_piece.piece) + .0; + // Then add the new piece + self.0 ^= RANDOM_TABLE + .pieces + .at(target.rank, target.file) + .by_player(moved_piece.player) + .by_piece(moved_piece.piece) + .0; + } +} + +impl From<&B> for ZobristHash { + fn from(value: &B) -> Self { + let mut hash = ZobristHash(0); + for rank in 0..8usize { + for file in 0..8usize { + if let Some(Piece { piece, player }) = value.at(&pos!(rank, file)) { + hash ^= RANDOM_TABLE + .pieces + .at(rank, file) + .by_player(player) + .by_piece(piece); + } + } + } + + hash + } +} diff --git a/chusst-uci/src/engine.rs b/chusst-uci/src/engine.rs index 3cd7d1d..70238e2 100644 --- a/chusst-uci/src/engine.rs +++ b/chusst-uci/src/engine.rs @@ -1,6 +1,7 @@ use anyhow::Result; use chusst_gen::eval::{ - EngineFeedback, EngineFeedbackMessage, EngineMessage, Game, GameMove, HasStopSignal, + EngineFeedback, EngineFeedbackMessage, EngineMessage, Game, GameHistory, GameMove, + HasStopSignal, }; use chusst_gen::game::{BitboardGame, MoveAction}; use tokio::sync::mpsc; @@ -21,7 +22,7 @@ pub struct NewGameCommand { #[derive(Clone)] pub enum EngineCommand { - NewGame(NewGameCommand), + NewGame(Box), // boxed due to big size Go(GoCommand), Stop, Exit, @@ -138,6 +139,7 @@ pub async fn engine_task( let mut to_engine = to_engine; let mut communicator = BufferedSenderWriter::new(from_engine); let mut game = BitboardGame::new(); + let mut history = GameHistory::new(); let mut command_receiver = EngineCommandReceiver { receiver: &mut to_engine, messages: Vec::new(), @@ -154,17 +156,20 @@ pub async fn engine_task( Some(EngineCommand::NewGame(new_game_cmd)) => { if let Some(new_game) = new_game_cmd.game { game = new_game; + history.clear(); } for mv in new_game_cmd.moves { if game.do_move(&mv).is_none() { let _ = communicator .send(EngineResponse::Error(format!("Invalid move {}", mv.mv))); } + history.push(mv); } } Some(EngineCommand::Go(go_command)) => { let best_move = game.get_best_move_with_logger( go_command.depth, + &history, &mut command_receiver, &mut communicator, ); diff --git a/chusst-uci/src/main.rs b/chusst-uci/src/main.rs index 2ec33a0..2b6564f 100644 --- a/chusst-uci/src/main.rs +++ b/chusst-uci/src/main.rs @@ -2,17 +2,20 @@ mod duplex_thread; mod engine; mod stdin; +use std::fmt; +use std::fs::File; +use std::io::Write; +use std::time::Instant; + use anyhow::Result; -use chusst_gen::eval::GameMove; -use chusst_gen::game::{BitboardGame, ModifiableGame, MoveAction}; use duplex_thread::DuplexChannel; use engine::{EngineCommand, EngineResponse, GoCommand, NewGameCommand}; use mio::{Poll, Token, Waker}; use rust_fsm::*; -use std::fmt; -use std::fs::File; -use std::io::Write; -use std::time::Instant; + +use chusst_gen::eval::GameMove; +use chusst_gen::game::{BitboardGame, MoveAction}; + use stdin::{stdin_task, StdinResponse}; use crate::duplex_thread::create_duplex_thread; @@ -325,10 +328,10 @@ async fn uci_loop( (Some(UciProtocolOutput::EngineCommandNewGame), ParsedInput::UciStdInInput(_)) => { if engine_channel .to_thread - .send(EngineCommand::NewGame(NewGameCommand { + .send(EngineCommand::NewGame(Box::new(NewGameCommand { game: Some(BitboardGame::new()), moves: Vec::new(), - })) + }))) .is_err() { log!("Error: could not send new game to engine"); @@ -368,9 +371,6 @@ async fn uci_loop( continue; } }; - if let Some(game) = &new_game { - log!("New position:\n{}", game.board()); - } let mut new_game_command = NewGameCommand { game: new_game, moves: Vec::new(), @@ -394,7 +394,7 @@ async fn uci_loop( } if engine_channel .to_thread - .send(EngineCommand::NewGame(new_game_command)) + .send(EngineCommand::NewGame(Box::new(new_game_command))) .is_err() { log!("Error: could not send new game command to engine"); diff --git a/package-lock.json b/package-lock.json index 4e8836c..35fe1ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chusst", - "version": "0.10.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chusst", - "version": "0.10.0", + "version": "0.11.0", "dependencies": { "@tauri-apps/api": ">=2.0.0-beta.0", "@testing-library/jest-dom": "^5.17.0", diff --git a/package.json b/package.json index 11346fa..577f7e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chusst", - "version": "0.10.0", + "version": "0.11.0", "private": true, "dependencies": { "@tauri-apps/api": ">=2.0.0-beta.0", diff --git a/pgn2yaml/src/converter/interpreter.rs b/pgn2yaml/src/converter/interpreter.rs index 1eecdf7..a13eb01 100644 --- a/pgn2yaml/src/converter/interpreter.rs +++ b/pgn2yaml/src/converter/interpreter.rs @@ -154,7 +154,7 @@ fn find_move_by_name(game: &SimpleGame, move_str: &str) -> Result>() .join(", ") ); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f0803ec..146b07f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,7 +2,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use chusst_gen::board::{Piece, Position}; -use chusst_gen::eval::{self, Game}; +use chusst_gen::eval::{self, Game, GameHistory}; use chusst_gen::game::{Move, MoveAction, MoveActionType, PromotionPieces}; #[cfg(feature = "bitboards")] @@ -30,13 +30,18 @@ struct TurnDescription { } struct GameData { + /// The game state game: GameModel, + /// The game history required by the engine + move_history: GameHistory, + /// The game history required by the UI history: Vec, } static GAME: Mutex = Mutex::new(GameData { game: GameModel::new(), - history: vec![], + move_history: Vec::new(), + history: Vec::new(), }); // Learn more about Tauri commands at https://tauri.app/v1/guides/features/command @@ -71,7 +76,16 @@ fn do_move( target_file: usize, promotion: Option, ) -> bool { - let promotion_piece = promotion.and_then(PromotionPieces::try_from_str); + let promotion_piece = if let Some(promotion_value) = promotion { + let promotion_piece = PromotionPieces::try_from_str(promotion_value.clone()); + if promotion_piece.is_none() { + println!("Invalid promotion piece: {}", promotion_value); + return false; + } + promotion_piece + } else { + None + }; let mv = MoveAction { mv: Move { source: Position { @@ -89,17 +103,16 @@ fn do_move( }, }; let game_data = &mut GAME.lock().unwrap(); - let game = &mut game_data.game; - let white_move = match game.move_name(&mv) { - Some(name) => name, - None => { - println!("Invalid move: {}", mv.mv); + let white_move = match game_data.game.move_name(&mv) { + Ok(name) => name, + Err(err) => { + println!("Invalid move {}: {}", mv.mv, err); return false; } }; - let white_captures = match game.do_move(&mv) { + let white_captures = match game_data.game.do_move(&mv) { Some(captures) => captures, None => { println!("Invalid move: {}", white_move); @@ -107,22 +120,27 @@ fn do_move( } }; - let (black_move_opt, black_captures, mate) = match game.get_best_move(4) { - eval::GameMove::Normal(mv) => { - let description = game.move_name(&mv); + game_data.move_history.push(mv); - let black_captures = game.do_move(&mv); - assert!(black_captures.is_some()); + let (black_move_opt, black_captures, mate) = + match game_data.game.get_best_move(&game_data.move_history, 4) { + eval::GameMove::Normal(mv) => { + let description = game_data.game.move_name(&mv).ok(); - let black_mate = game.is_mate(); + let black_captures = game_data.game.do_move(&mv); + assert!(black_captures.is_some()); - (description, black_captures.unwrap(), black_mate) - } - eval::GameMove::Mate(mate) => match mate { - eval::MateType::Stalemate => (None, vec![], Some(eval::MateType::Stalemate)), - eval::MateType::Checkmate => (None, vec![], Some(eval::MateType::Checkmate)), - }, - }; + game_data.move_history.push(mv); + + let black_mate = game_data.game.is_mate(); + + (description, black_captures.unwrap(), black_mate) + } + eval::GameMove::Mate(mate) => match mate { + eval::MateType::Stalemate => (None, vec![], Some(eval::MateType::Stalemate)), + eval::MateType::Checkmate => (None, vec![], Some(eval::MateType::Checkmate)), + }, + }; let history = &mut game_data.history; let turn = history.len() + 1; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 545c8f1..dc75dd7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "chusst", - "version": "0.10.0", + "version": "0.11.0", "build": { "beforeBuildCommand": "npm run build", "beforeDevCommand": "npm run start",