diff --git a/src/analysis.rs b/src/analysis.rs new file mode 100644 index 0000000..a48e588 --- /dev/null +++ b/src/analysis.rs @@ -0,0 +1,237 @@ +//! Analysis functions. + +use std::fmt; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, atomic, mpsc}; + +use crate::board; +use crate::engine; +use crate::notation; +use crate::rules; +use crate::stats; + +/// Analysis node: a board along with the game state. +#[derive(Clone)] +pub struct Node { + /// Board for this node. + pub board: board::Board, + /// Game state. + pub game_state: rules::GameState, +} + +impl Node { + /// Create a new node for an empty board and a new game state. + pub fn new() -> Node { + Node { + board: board::new_empty(), + game_state: rules::GameState::new(), + } + } +} + +impl fmt::Debug for Node { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "Node {{ board: [...], game_state: {:?} }}", + self.game_state + ) + } +} + +impl fmt::Display for Node { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let mut s = vec!(); + board::draw(&self.board, &mut s); + let board_drawing = String::from_utf8_lossy(&s).to_string(); + write!( + f, + "* Board:\n{}\n\ + * Game state:\n{}", + board_drawing, self.game_state + ) + } +} + +impl PartialEq for Node { + fn eq(&self, other: &Self) -> bool { + ( + self.board.iter().zip(other.board.iter()).all(|(a, b)| a == b) && + self.game_state == other.game_state + ) + } +} + +impl Eq for Node {} + +impl Hash for Node { + fn hash(&self, state: &mut H) { + self.board.iter().for_each(|square| state.write_u8(*square)); + self.game_state.hash(state); + } +} + +/// Analysis parameters. +#[derive(Clone)] +pub struct AnalysisParams { + pub move_time: i32, + pub white_time: i32, + pub black_time: i32, + pub white_inc: i32, + pub black_inc: i32, +} + +const MIN_F32: f32 = std::f32::NEG_INFINITY; +const MAX_F32: f32 = std::f32::INFINITY; + +/// Analyse best moves for a given node. +pub fn analyze( + node: &mut Node, + _args: &AnalysisParams, + working: Arc, + tx: mpsc::Sender, + debug: bool, +) { + if !working.load(atomic::Ordering::Relaxed) { + return; + } + if debug { + tx.send(engine::Cmd::Log(format!("\tAnalyzing node:\n{}", node))).unwrap(); + let moves = rules::get_player_moves(&node.board, &node.game_state, true); + let moves_str = format!("\tLegal moves: {}", notation::move_list_to_string(&moves)); + tx.send(engine::Cmd::Log(moves_str)).unwrap(); + } + + let (max_score, best_move) = minimax(node, 0, 2, board::is_white(node.game_state.color)); + + if best_move.is_some() { + let log_str = format!( + "\tBest move {} evaluated {}", + notation::move_to_string(&best_move.unwrap()), max_score + ); + tx.send(engine::Cmd::Log(log_str)).unwrap(); + tx.send(engine::Cmd::TmpBestMove(best_move)).unwrap(); + } else { + // If no best move could be found, checkmate is unavoidable; send the first legal move. + tx.send(engine::Cmd::Log("Checkmate is unavoidable.".to_string())).unwrap(); + let moves = rules::get_player_moves(&node.board, &node.game_state, true); + let m = if moves.len() > 0 { Some(moves[0]) } else { None }; + tx.send(engine::Cmd::TmpBestMove(m)).unwrap(); + } + + // thread::sleep(time::Duration::from_secs(1)); + // for _ in 0..4 { + // let board = board.clone(); + // let wip = wip.clone(); + // thread::spawn(move || { + // analyze(&board, wip); + // }); + // } + +} + +/// Provide a "minimax" score for this node. +/// +/// This method recursively looks alternatively for minimum score for +/// one player, then maximum for its opponent; that way it assumes the +/// opponent always does their best. +/// +/// `depth` is increased at each recursive call; when `max_depth` is +/// reached, evaluate the current node and return its score. +/// +/// `maximizing` specifies whether the method should look for the +/// highest possible score (when true) or the lowest (when false). +fn minimax( + node: &mut Node, + depth: u32, + max_depth: u32, + maximizing: bool +) -> (f32, Option) { + if depth == max_depth { + let stats = stats::compute_stats(&node.board, &node.game_state); + return (evaluate(&stats), None); + } + let mut minmax = if maximizing { MIN_F32 } else { MAX_F32 }; + let mut minmax_move = None; + let moves = rules::get_player_moves(&node.board, &node.game_state, true); + for m in moves { + let mut sub_node = node.clone(); + rules::apply_move_to(&mut sub_node.board, &mut sub_node.game_state, &m); + if maximizing { + let (score, _) = minimax(&mut sub_node, depth + 1, max_depth, false); + if score >= minmax { + minmax = score; + minmax_move = Some(m); + } + } else { + let (score, _) = minimax(&mut sub_node, depth + 1, max_depth, true); + if score <= minmax { + minmax = score; + minmax_move = Some(m); + } + } + } + (minmax, minmax_move) +} + +/// Compute a score for white/black board stats. +/// +/// This uses the formula proposed by Shannon in his 1949 paper called +/// "Programming a Computer for Playing Chess", as it is quite simple +/// yet provide good enough results. +fn evaluate(stats: &(stats::BoardStats, stats::BoardStats)) -> f32 { + let (ws, bs) = stats; + + 200.0 * (ws.num_kings - bs.num_kings) as f32 + + 9.0 * (ws.num_queens - bs.num_queens) as f32 + + 5.0 * (ws.num_rooks - bs.num_rooks) as f32 + + 3.0 * (ws.num_bishops - bs.num_bishops) as f32 + + 3.0 * (ws.num_knights - bs.num_knights) as f32 + + (ws.num_pawns - bs.num_pawns) as f32 + - 0.5 * ( + ws.num_doubled_pawns - bs.num_doubled_pawns + + ws.num_isolated_pawns - bs.num_isolated_pawns + + ws.num_backward_pawns - bs.num_backward_pawns + ) as f32 + + 0.1 * (ws.mobility - bs.mobility) as f32 +} + +#[cfg(test)] +mod tests { + use super::*; + use board::pos; + + #[test] + fn test_minimax() { + let mut node = Node::new(); + node.game_state.castling = 0; + + // White mates in 1 move, queen to d7. + board::set_square(&mut node.board, &pos("a1"), board::SQ_WH_K); + board::set_square(&mut node.board, &pos("c6"), board::SQ_WH_P); + board::set_square(&mut node.board, &pos("h7"), board::SQ_WH_Q); + board::set_square(&mut node.board, &pos("d8"), board::SQ_BL_K); + let (_, m) = minimax(&mut node, 0, 2, true); + assert_eq!(m.unwrap(), notation::parse_move("h7d7")); + + // Check that it works for black as well. + board::set_square(&mut node.board, &pos("a1"), board::SQ_BL_K); + board::set_square(&mut node.board, &pos("c6"), board::SQ_BL_P); + board::set_square(&mut node.board, &pos("h7"), board::SQ_BL_Q); + board::set_square(&mut node.board, &pos("d8"), board::SQ_WH_K); + node.game_state.color = board::SQ_BL; + let (_, m) = minimax(&mut node, 0, 2, true); + assert_eq!(m.unwrap(), notation::parse_move("h7d7")); + } + + #[test] + fn test_evaluate() { + let mut node = Node::new(); + let stats = stats::compute_stats(&node.board, &node.game_state); + assert_eq!(evaluate(&stats), 0.0); + + rules::apply_move_to(&mut node.board, &mut node.game_state, ¬ation::parse_move("d2d4")); + let stats = stats::compute_stats(&node.board, &node.game_state); + assert_eq!(evaluate(&stats), 0.0); + } +} diff --git a/src/board.rs b/src/board.rs index 0e02153..598bd78 100644 --- a/src/board.rs +++ b/src/board.rs @@ -199,17 +199,6 @@ pub fn get_piece_iterator<'a>(board: &'a Board) -> Box u8 { - let mut count = 0; - for i in board.iter() { - if *i != SQ_E { - count += 1; - } - } - count -} - /// Find the king of `color`. pub fn find_king(board: &Board, color: u8) -> Option { for f in 0..8 { @@ -223,6 +212,17 @@ pub fn find_king(board: &Board, color: u8) -> Option { None } +/// Count number of pieces on board. Used for debugging. +pub fn num_pieces(board: &Board) -> u8 { + let mut count = 0; + for i in board.iter() { + if *i != SQ_E { + count += 1; + } + } + count +} + /// Write a text view of the board. Used for debugging. pub fn draw(board: &Board, f: &mut dyn std::io::Write) { for r in (0..8).rev() { @@ -315,12 +315,6 @@ mod tests { assert_eq!(is_empty(&b, &pos("a3")), true); } - #[test] - fn test_num_pieces() { - assert_eq!(num_pieces(&new_empty()), 0); - assert_eq!(num_pieces(&new()), 32); - } - #[test] fn test_find_king() { let b = new_empty(); @@ -329,4 +323,10 @@ mod tests { assert_eq!(find_king(&b, SQ_WH), Some(pos("e1"))); assert_eq!(find_king(&b, SQ_BL), Some(pos("e8"))); } + + #[test] + fn test_num_pieces() { + assert_eq!(num_pieces(&new_empty()), 0); + assert_eq!(num_pieces(&new()), 32); + } } diff --git a/src/engine.rs b/src/engine.rs index 305493f..c49cca5 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,23 +1,25 @@ //! Vatu engine. +//! +//! Hold the various data needed to perform a game analysis, +//! but actual analysis code is in the `analysis` module. use std::sync::{Arc, atomic, mpsc}; use std::thread; +use crate::analysis; use crate::board; use crate::notation; use crate::rules; -use crate::stats; use crate::uci; -const MIN_F32: f32 = std::f32::NEG_INFINITY; -const MAX_F32: f32 = std::f32::INFINITY; - /// Analysis engine. pub struct Engine { /// Debug mode, log some data. debug: bool, /// Current game state, starting point of further analysis. - node: Node, + node: analysis::Node, + /// Store already evaluated nodes with their score. + // score_map: Arc>>, /// Communication mode. mode: Mode, /// If true, the engine is currently listening to incoming cmds. @@ -26,48 +28,6 @@ pub struct Engine { working: Arc, } -/// Analysis node: a board along with the game state. -#[derive(Clone)] -struct Node { - /// Board for this node. - board: board::Board, - /// Game state. - game_state: rules::GameState, -} - -impl Node { - fn new() -> Node { - Node { - board: board::new_empty(), - game_state: rules::GameState::new(), - } - } -} - -impl std::fmt::Debug for Node { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!( - f, - "Node {{ board: [...], game_state: {:?} }}", - self.game_state - ) - } -} - -impl std::fmt::Display for Node { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - let mut s = vec!(); - board::draw(&self.board, &mut s); - let board_drawing = String::from_utf8_lossy(&s).to_string(); - write!( - f, - "* Board:\n{}\n\ - * Game state:\n{}", - board_drawing, self.game_state - ) - } -} - /// Engine communication mode. enum Mode { /// No mode, sit here and do nothing. @@ -105,16 +65,6 @@ pub enum Cmd { Info(Vec), } -/// Parameters for starting work. -#[derive(Clone)] -struct WorkArgs { - move_time: i32, - white_time: i32, - black_time: i32, - white_inc: i32, - black_inc: i32, -} - /// Information to be transmitted back to whatever is listening. #[derive(Debug, Clone)] pub enum Info { @@ -126,7 +76,8 @@ impl Engine { pub fn new() -> Engine { Engine { debug: false, - node: Node::new(), + node: analysis::Node::new(), + // score_map: HashMap::with_capacity(2usize.pow(10)), mode: Mode::No, listening: false, working: Arc::new(atomic::AtomicBool::new(false)), @@ -168,6 +119,7 @@ impl Engine { } } + /// Send a command back to the controlling interface. fn reply(&mut self, cmd: Cmd) { match &self.mode { Mode::Uci(tx, _, _) => { @@ -210,10 +162,12 @@ impl Engine { self.node.game_state.fullmove = fen.fullmove.parse::().ok().unwrap(); } + /// Apply a series of moves to the current node. fn apply_moves(&mut self, moves: &Vec) { moves.iter().for_each(|m| self.apply_move(m)); } + /// Apply a move to the current node. fn apply_move(&mut self, m: &rules::Move) { rules::apply_move_to(&mut self.node.board, &mut self.node.game_state, m); } @@ -221,7 +175,7 @@ impl Engine { /// Start working on board, returning the best move found. /// /// Stop working after `movetime` ms, or go on forever if it's -1. - fn work(&mut self, args: &WorkArgs) { + fn work(&mut self, args: &analysis::AnalysisParams) { self.working.store(true, atomic::Ordering::Relaxed); let mut node = self.node.clone(); let args = args.clone(); @@ -229,10 +183,11 @@ impl Engine { let tx = match &self.mode { Mode::Uci(_, _, tx) => tx.clone(), _ => return }; let debug = self.debug; thread::spawn(move || { - analyze(&mut node, &args, working, tx, debug); + analysis::analyze(&mut node, &args, working, tx, debug); }); } + /// Unset the work flag, stopping workers. fn stop(&mut self) { self.working.store(false, atomic::Ordering::SeqCst); } @@ -269,7 +224,7 @@ impl Engine { /// Start working using parameters passed with a "go" command. fn uci_go(&mut self, g_args: &Vec) { - let mut args = WorkArgs { + let mut args = analysis::AnalysisParams { move_time: -1, white_time: -1, black_time: -1, @@ -290,138 +245,3 @@ impl Engine { self.work(&args); } } - -fn analyze( - node: &mut Node, - _args: &WorkArgs, - working: Arc, - tx: mpsc::Sender, - debug: bool, -) { - if !working.load(atomic::Ordering::Relaxed) { - return; - } - if debug { - tx.send(Cmd::Log(format!("\tAnalyzing node:\n{}", node))).unwrap(); - let moves = rules::get_player_moves(&node.board, &node.game_state, true); - let moves_str = format!("\tLegal moves: {}", notation::move_list_to_string(&moves)); - tx.send(Cmd::Log(moves_str)).unwrap(); - } - - let (max_score, best_move) = minimax(node, 0, 2, board::is_white(node.game_state.color)); - - if best_move.is_some() { - let log_str = format!( - "\tBest move {} evaluated {}", - notation::move_to_string(&best_move.unwrap()), max_score - ); - tx.send(Cmd::Log(log_str)).unwrap(); - tx.send(Cmd::TmpBestMove(best_move)).unwrap(); - } else { - // If no best move could be found, checkmate is unavoidable; send the first legal move. - tx.send(Cmd::Log("Checkmate is unavoidable.".to_string())).unwrap(); - let moves = rules::get_player_moves(&node.board, &node.game_state, true); - let m = if moves.len() > 0 { Some(moves[0]) } else { None }; - tx.send(Cmd::TmpBestMove(m)).unwrap(); - } - - // thread::sleep(time::Duration::from_secs(1)); - // for _ in 0..4 { - // let board = board.clone(); - // let wip = wip.clone(); - // thread::spawn(move || { - // analyze(&board, wip); - // }); - // } - -} - -fn minimax( - node: &mut Node, - depth: u32, - max_depth: u32, - maximizing: bool -) -> (f32, Option) { - if depth == max_depth { - let stats = stats::compute_stats(&node.board, &node.game_state); - return (evaluate(&stats), None); - } - let mut minmax = if maximizing { MIN_F32 } else { MAX_F32 }; - let mut minmax_move = None; - let moves = rules::get_player_moves(&node.board, &node.game_state, true); - for m in moves { - let mut sub_node = node.clone(); - rules::apply_move_to(&mut sub_node.board, &mut sub_node.game_state, &m); - if maximizing { - let (score, _) = minimax(&mut sub_node, depth + 1, max_depth, false); - if score > minmax { - minmax = score; - minmax_move = Some(m); - } - } else { - let (score, _) = minimax(&mut sub_node, depth + 1, max_depth, true); - if score < minmax { - minmax = score; - minmax_move = Some(m); - } - } - } - (minmax, minmax_move) -} - -fn evaluate(stats: &(stats::BoardStats, stats::BoardStats)) -> f32 { - let (ws, bs) = stats; - - 200.0 * (ws.num_kings - bs.num_kings) as f32 - + 9.0 * (ws.num_queens - bs.num_queens) as f32 - + 5.0 * (ws.num_rooks - bs.num_rooks) as f32 - + 3.0 * (ws.num_bishops - bs.num_bishops) as f32 - + 3.0 * (ws.num_knights - bs.num_knights) as f32 - + (ws.num_pawns - bs.num_pawns) as f32 - - 0.5 * ( - ws.num_doubled_pawns - bs.num_doubled_pawns + - ws.num_isolated_pawns - bs.num_isolated_pawns + - ws.num_backward_pawns - bs.num_backward_pawns - ) as f32 - + 0.1 * (ws.mobility - bs.mobility) as f32 -} - -#[cfg(test)] -mod tests { - use super::*; - use board::pos; - - #[test] - fn test_minimax() { - let mut node = Node::new(); - node.game_state.castling = 0; - - // White mates in 1 move, queen to d7. - board::set_square(&mut node.board, &pos("a1"), board::SQ_WH_K); - board::set_square(&mut node.board, &pos("c6"), board::SQ_WH_P); - board::set_square(&mut node.board, &pos("h7"), board::SQ_WH_Q); - board::set_square(&mut node.board, &pos("d8"), board::SQ_BL_K); - let (_, m) = minimax(&mut node, 0, 2, true); - assert_eq!(m.unwrap(), notation::parse_move("h7d7")); - - // Check that it works for black as well. - board::set_square(&mut node.board, &pos("a1"), board::SQ_BL_K); - board::set_square(&mut node.board, &pos("c6"), board::SQ_BL_P); - board::set_square(&mut node.board, &pos("h7"), board::SQ_BL_Q); - board::set_square(&mut node.board, &pos("d8"), board::SQ_WH_K); - node.game_state.color = board::SQ_BL; - let (_, m) = minimax(&mut node, 0, 2, true); - assert_eq!(m.unwrap(), notation::parse_move("h7d7")); - } - - #[test] - fn test_evaluate() { - let mut node = Node::new(); - let stats = stats::compute_stats(&node.board, &node.game_state); - assert_eq!(evaluate(&stats), 0.0); - - rules::apply_move_to(&mut node.board, &mut node.game_state, ¬ation::parse_move("d2d4")); - let stats = stats::compute_stats(&node.board, &node.game_state); - assert_eq!(evaluate(&stats), 0.0); - } -} diff --git a/src/main.rs b/src/main.rs index eff6631..3345a19 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use std::process; use clap::{App, AppSettings, Arg, ArgMatches, SubCommand}; +pub mod analysis; pub mod board; pub mod engine; pub mod notation; diff --git a/src/rules.rs b/src/rules.rs index 7adb482..d334235 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -10,7 +10,10 @@ use crate::notation; /// /// - `color`: current player's turn /// - `castling`: which castling options are available; updated throughout the game. -#[derive(Debug, Clone)] +/// - `en_passant`: position of a pawn that can be taken using en passant attack. +/// - `halfmove`: eh not sure +/// - `fullmove`: same +#[derive(Debug, PartialEq, Clone, Hash)] pub struct GameState { pub color: u8, pub castling: u8, @@ -283,9 +286,8 @@ fn get_pawn_moves( // Check diagonals for pieces to attack. if i == 1 { // First diagonal. - let df = f - 1; - if df >= POS_MIN { - let diag: Pos = (df, forward_r); + if f - 1 >= POS_MIN { + let diag: Pos = (f - 1, forward_r); if let Some(m) = move_on_enemy(piece, at, get_square(board, &diag), &diag) { if can_register(commit, board, game_state, &m) { moves.push(m); @@ -293,9 +295,8 @@ fn get_pawn_moves( } } // Second diagonal. - let df = f + 1; - if df <= POS_MAX { - let diag: Pos = (df, forward_r); + if f + 1 <= POS_MAX { + let diag: Pos = (f + 1, forward_r); if let Some(m) = move_on_enemy(piece, at, get_square(board, &diag), &diag) { if can_register(commit, board, game_state, &m) { moves.push(m); @@ -317,14 +318,16 @@ fn get_bishop_moves( ) -> Vec { let (f, r) = at; let mut views = [true; 4]; // Store diagonals where a piece blocks commit. - let mut moves = vec!(); + let mut moves = Vec::with_capacity(8); for dist in 1..=7 { for (dir, offset) in [(1, -1), (1, 1), (-1, 1), (-1, -1)].iter().enumerate() { if !views[dir] { continue } let p = (f + offset.0 * dist, r + offset.1 * dist); + // If this position is out of the board, stop looking in that direction. if !is_valid_pos(p) { + views[dir] = false; continue } if is_empty(board, &p) { @@ -353,7 +356,7 @@ fn get_knight_moves( commit: bool, ) -> Vec { let (f, r) = at; - let mut moves = vec!(); + let mut moves = Vec::with_capacity(8); for offset in [(1, 2), (2, 1), (2, -1), (1, -2), (-1, -2), (-2, -1), (-2, 1), (-1, 2)].iter() { let p = (f + offset.0, r + offset.1); if !is_valid_pos(p) { @@ -381,7 +384,7 @@ fn get_rook_moves( commit: bool, ) -> Vec { let (f, r) = at; - let mut moves = vec!(); + let mut moves = Vec::with_capacity(8); let mut views = [true; 4]; // Store lines where a piece blocks commit. for dist in 1..=7 { for (dir, offset) in [(0, 1), (1, 0), (0, -1), (-1, 0)].iter().enumerate() { @@ -389,7 +392,9 @@ fn get_rook_moves( continue } let p = (f + offset.0 * dist, r + offset.1 * dist); + // If this position is out of the board, stop looking in that direction. if !is_valid_pos(p) { + views[dir] = false; continue } if is_empty(board, &p) { diff --git a/src/stats.rs b/src/stats.rs index 5f2e511..f222e11 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -82,12 +82,14 @@ pub fn compute_color_stats_into( color: u8 ) { stats.reset(); + // Compute mobility for all pieces. + stats.mobility = rules::get_player_moves(board, game_state, true).len() as i32; + // Compute amount of each piece. for (piece, p) in get_piece_iterator(board) { let (pos_f, pos_r) = p; if piece == SQ_E || !is_color(piece, color) { continue } - // For all piece types, increment its number. Pawns have a few additional stats. match get_type(piece) { SQ_R => stats.num_rooks += 1, SQ_N => stats.num_knights += 1, @@ -162,8 +164,6 @@ pub fn compute_color_stats_into( }, _ => {} } - // Compute mobility for all pieces. - stats.mobility += rules::get_piece_moves(board, &p, game_state, true).len() as i32; } }