analysis: move analysis code to its own module

This commit is contained in:
dece 2020-06-14 01:33:11 +02:00
parent ea2e7ead91
commit 39c3bc8786
6 changed files with 289 additions and 226 deletions

237
src/analysis.rs Normal file
View file

@ -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<H: Hasher>(&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<atomic::AtomicBool>,
tx: mpsc::Sender<engine::Cmd>,
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<rules::Move>) {
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, &notation::parse_move("d2d4"));
let stats = stats::compute_stats(&node.board, &node.game_state);
assert_eq!(evaluate(&stats), 0.0);
}
}

View file

@ -199,17 +199,6 @@ pub fn get_piece_iterator<'a>(board: &'a Board) -> Box<dyn Iterator<Item = (u8,
)
}
/// 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
}
/// Find the king of `color`.
pub fn find_king(board: &Board, color: u8) -> Option<Pos> {
for f in 0..8 {
@ -223,6 +212,17 @@ pub fn find_king(board: &Board, color: u8) -> Option<Pos> {
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);
}
}

View file

@ -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<RwLock<HashMap<Node, f32>>>,
/// Communication mode.
mode: Mode,
/// If true, the engine is currently listening to incoming cmds.
@ -26,48 +28,6 @@ pub struct Engine {
working: Arc<atomic::AtomicBool>,
}
/// 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<Info>),
}
/// 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::<i32>().ok().unwrap();
}
/// Apply a series of moves to the current node.
fn apply_moves(&mut self, moves: &Vec<rules::Move>) {
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<uci::GoArgs>) {
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<atomic::AtomicBool>,
tx: mpsc::Sender<Cmd>,
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<rules::Move>) {
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, &notation::parse_move("d2d4"));
let stats = stats::compute_stats(&node.board, &node.game_state);
assert_eq!(evaluate(&stats), 0.0);
}
}

View file

@ -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;

View file

@ -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<Move> {
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<Move> {
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<Move> {
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) {

View file

@ -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;
}
}