/*
* Velvet Chess Engine
* Copyright (C) 2020 mhonert (https://github.com/mhonert)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
use crate::board::EN_PASSANT;
use crate::colors::{Color, BLACK, WHITE};
use crate::engine::Engine;
use crate::move_gen::{
decode_end_index, decode_piece_id, decode_start_index, is_valid_move, Move, NO_MOVE,
};
use crate::move_sort::SortedMoveGenerator;
use crate::pieces::EMPTY;
use crate::score_util::{
decode_move, decode_score, encode_scored_move, ScoredMove, BLACK_MATE_SCORE, MAX_SCORE,
MIN_SCORE, WHITE_MATE_SCORE,
};
use crate::transposition_table::{
get_depth, get_score_type, get_scored_move, EXACT, LOWER_BOUND, MAX_DEPTH, UPPER_BOUND,
};
use crate::uci_move::UCIMove;
use std::cmp::{max, min};
use std::time::{Duration, Instant};
use crate::eval::Eval;
pub trait Search {
fn find_best_move(&mut self, min_depth: i32, is_strict_timelimit: bool) -> Move;
fn rec_find_best_move(
&mut self,
alpha: i32,
beta: i32,
player_color: Color,
depth: i32,
ply: i32,
nullmove_performed: bool,
nullmove_verification: bool,
is_in_check: bool,
) -> i32;
fn quiescence_search(&mut self, player_color: Color, alpha: i32, beta: i32, ply: i32) -> i32;
fn get_base_stats(&self, duration: Duration) -> String;
fn extract_pv(&mut self, m: Move, depth: i32) -> String;
fn terminal_score(&mut self, active_player: Color, ply: i32) -> i32;
}
const CANCEL_SEARCH: i32 = i32::max_value() - 1;
const LMR_THRESHOLD: i32 = 4;
const LMR_REDUCTIONS: i32 = 2;
const FUTILITY_MARGIN_MULTIPLIER: i32 = 51;
const FUTILE_MOVE_REDUCTIONS: i32 = 2;
const LOSING_MOVE_REDUCTIONS: i32 = 2;
const QS_SEE_THRESHOLD: i32 = 104;
const QS_PRUNE_MARGIN: i32 = 989;
const TIMEEXT_MULTIPLIER: i32 = 5;
const TIMEEXT_SCORE_CHANGE_THRESHOLD: i32 = 80;
const TIMEEXT_SCORE_FLUCTUATION_THRESHOLD: i32 = 130;
const TIMEEXT_SCORE_FLUCTUATION_REDUCTIONS: i32 = 90; // reduction percentage per search iteration
const RAZOR_MARGIN: i32 = 130;
impl Search for Engine {
fn find_best_move(&mut self, min_depth: i32, is_strict_timelimit: bool) -> Move {
let mut alpha = MIN_SCORE;
let mut beta = MAX_SCORE;
self.hh.clear();
self.starttime = Instant::now();
let mut moves = SortedMoveGenerator::gen_legal_moves();
self.cancel_possible = false;
self.node_count = 0;
self.log_every_second = false;
self.is_stopped = false;
let mut current_best_move: ScoredMove = NO_MOVE;
let mut already_extended_timelimit = false;
let mut previous_best_move: Move = NO_MOVE;
let mut previous_best_score: i32 = 0;
let mut fluctuation_count: i32 = 0;
let mut score_fluctuations: i32 = 0;
let player_color = self.board.active_player();
let mut scored_move_count = 0;
// Use iterative deepening, i.e. increase the search depth after each iteration
for depth in min(min_depth, 2)..(MAX_DEPTH as i32) {
self.current_depth = depth;
let mut best_score: i32 = MIN_SCORE;
let previous_alpha = alpha;
let previous_beta = beta;
let iteration_start_time = Instant::now();
let mut best_move: Move = NO_MOVE;
let mut a = -beta; // Search principal variation node with full window
let mut iteration_cancelled = false;
let mut move_num = 0;
while let Some(scored_move) =
moves.next_legal_move(&self.gen, &self.hh, &mut self.board)
{
move_num += 1;
let m = decode_move(scored_move);
if depth > 12 {
let now = Instant::now();
let total_duration = now.duration_since(self.starttime);
if total_duration.as_millis() >= 1000 {
self.log_every_second = true;
self.last_log_time = now;
let uci_move = UCIMove::from_encoded_move(&self.board, m).to_uci();
let base_stats = self.get_base_stats(total_duration);
println!(
"info depth {}{} currmove {} currmovenumber {}",
depth, base_stats, uci_move, move_num
);
}
}
let target_piece_id = decode_piece_id(m);
let start = decode_start_index(m);
let end = decode_end_index(m);
let previous_piece = self.board.get_item(start);
let removed_piece_id = self.board.perform_move(target_piece_id as i8, start, end);
let gives_check = self.board.is_in_check(-player_color);
// Use principal variation search
let mut result = self.rec_find_best_move(
a,
-alpha,
-player_color,
depth - 1,
1,
false,
true,
gives_check,
);
if result == CANCEL_SEARCH {
iteration_cancelled = true;
} else {
// Repeat search if it falls outside the window
if -result > alpha && -result < beta {
result = self.rec_find_best_move(
-beta,
-alpha,
-player_color,
depth - 1,
1,
false,
true,
gives_check,
);
if result == CANCEL_SEARCH {
iteration_cancelled = true;
}
}
}
self.board
.undo_move(previous_piece, start, end, removed_piece_id);
if iteration_cancelled {
if best_move != NO_MOVE && previous_best_move != NO_MOVE {
score_fluctuations =
score_fluctuations * 100 / TIMEEXT_SCORE_FLUCTUATION_REDUCTIONS;
score_fluctuations += (best_score - previous_best_score).abs();
if best_score.abs() >= (BLACK_MATE_SCORE - MAX_DEPTH as i32) {
// Reset score fluctuation statistic, if a check mate is found
score_fluctuations = 0;
}
}
if !is_strict_timelimit
&& !self.is_stopped
&& !already_extended_timelimit
&& should_extend_timelimit(
best_move,
best_score,
previous_best_move,
previous_best_score,
score_fluctuations,
fluctuation_count,
)
{
already_extended_timelimit = true;
self.timelimit_ms *= TIMEEXT_MULTIPLIER;
iteration_cancelled = false;
continue;
}
break;
}
let score = -result;
if score > best_score {
best_score = score;
best_move = m;
alpha = max(alpha, best_score);
if self.log_every_second {
let pv = self.extract_pv(best_move, depth - 1);
println!(
"info depth {} score {} pv {}",
depth,
get_score_info(best_score),
pv
);
if self.is_search_stopped() {
self.is_stopped = true;
iteration_cancelled = true;
}
}
}
a = -(alpha + 1); // Search all other moves (after principal variation) with a zero window
moves.update_move(encode_scored_move(m, score));
scored_move_count += 1;
}
let current_time = Instant::now();
let iteration_duration = current_time.duration_since(iteration_start_time);
let total_duration = current_time.duration_since(self.starttime);
let remaining_time = self.timelimit_ms - total_duration.as_millis() as i32;
if !iteration_cancelled {
if previous_best_move != NO_MOVE {
score_fluctuations =
score_fluctuations * 100 / TIMEEXT_SCORE_FLUCTUATION_REDUCTIONS;
score_fluctuations += (best_score - previous_best_score).abs();
fluctuation_count += 1;
}
self.cancel_possible = depth >= min_depth;
if self.cancel_possible
&& (remaining_time <= (iteration_duration.as_millis() as i32 * 2))
{
// Not enough time left for another iteration
if is_strict_timelimit
|| already_extended_timelimit
|| !should_extend_timelimit(
best_move,
best_score,
previous_best_move,
previous_best_score,
score_fluctuations,
fluctuation_count,
)
{
iteration_cancelled = true;
}
}
}
if best_move == NO_MOVE {
best_move = previous_best_move;
best_score = previous_best_score;
}
println!(
"info depth {} score {}{} pv {}",
depth,
get_score_info(best_score),
self.get_base_stats(total_duration),
self.extract_pv(best_move, depth - 1)
);
current_best_move = encode_scored_move(best_move, best_score);
if iteration_cancelled || scored_move_count <= 1 {
// stop searching, if iteration has been cancelled or there is no valid move or only a single valid move
break;
}
previous_best_move = best_move;
previous_best_score = best_score;
alpha = previous_alpha;
beta = previous_beta;
moves.reset();
moves.resort();
}
if scored_move_count == 0 {
return encode_scored_move(
NO_MOVE,
self.terminal_score(player_color, 0) * player_color as i32,
);
}
current_best_move
}
// Recursively calls itself with alternating player colors to
// find the best possible move in response to the current board position.
fn rec_find_best_move(
&mut self,
mut alpha: i32,
beta: i32,
player_color: Color,
mut depth: i32,
ply: i32,
nullmove_performed: bool,
mut nullmove_verification: bool,
is_in_check: bool,
) -> i32 {
if self.node_count & 1023 == 0 && self.cancel_possible {
let current_time = Instant::now();
let total_duration = current_time.duration_since(self.starttime);
if total_duration.as_millis() as i32 >= self.timelimit_ms{
// Cancel search if the time limit has been reached
return CANCEL_SEARCH;
} else if self.is_search_stopped() {
self.is_stopped = true;
return CANCEL_SEARCH;
}
if depth > 3
&& self.log_every_second
&& current_time.duration_since(self.last_log_time).as_millis() >= 1000
{
self.last_log_time = current_time;
let base_stats = self.get_base_stats(total_duration);
println!("info depth {}{}", self.current_depth, base_stats);
}
}
let is_pv = (alpha + 1) < beta; // in a principal variation search, non-PV nodes are searched with a zero-window
if self.board.is_engine_draw() {
self.node_count += 1;
return 0;
}
if is_in_check {
// Extend search when in check
if depth < 0 {
depth = 1;
} else {
depth += 1;
}
} else if depth == 1
&& (self.board.get_score() * player_color as i32) < alpha - RAZOR_MARGIN
{
// Directly jump to quiescence search, if current position score is below a certain threshold
depth = 0;
}
// Quiescence search
if depth <= 0 {
return self.quiescence_search(player_color, alpha, beta, ply);
}
self.node_count += 1;
// Check transposition table
let hash = self.board.get_hash();
let tt_entry = self.tt.get_entry(hash);
let mut scored_move = get_scored_move(tt_entry);
if scored_move != NO_MOVE {
// Validate hash move for additional protection against hash collisions
if get_depth(tt_entry) >= depth {
if is_valid_move(&mut self.board, player_color, decode_move(scored_move)) {
let score = decode_score(scored_move);
match get_score_type(tt_entry) {
EXACT => {
return score;
}
UPPER_BOUND => {
if score <= alpha {
return alpha;
}
}
LOWER_BOUND => {
if score >= beta {
return beta;
}
}
_ => (),
};
} else {
scored_move = NO_MOVE;
}
} else if !is_valid_move(&mut self.board, player_color, decode_move(scored_move)) {
scored_move = NO_MOVE;
}
}
let mut fail_high = false;
// Null move pruning
if !is_pv && !nullmove_performed && depth > 2 && !is_in_check {
self.board.perform_null_move();
let result = self.rec_find_best_move(
-beta,
-beta + 1,
-player_color,
depth - 4,
ply + 1,
true,
false,
false,
);
self.board.undo_null_move();
if result == CANCEL_SEARCH {
return CANCEL_SEARCH;
}
if -result >= beta {
if nullmove_verification {
depth -= 1;
nullmove_verification = false;
fail_high = true;
} else {
return -result;
}
}
}
let primary_killer = self.hh.get_primary_killer(ply);
let secondary_killer = self.hh.get_secondary_killer(ply);
let mut moves =
SortedMoveGenerator::gen_moves(scored_move, primary_killer, secondary_killer);
let mut best_move = NO_MOVE;
let mut best_score = MIN_SCORE;
let mut score_type = UPPER_BOUND;
let mut evaluated_move_count = 0;
let mut has_valid_moves = false;
let allow_reductions = depth > 2 && !is_in_check;
// Futile move pruning
let mut allow_futile_move_pruning = false;
let mut prune_low_score = 0;
if !is_pv && depth <= 4 {
prune_low_score =
self.board.get_score() * player_color as i32 + depth * FUTILITY_MARGIN_MULTIPLIER;
allow_futile_move_pruning = prune_low_score <= alpha;
}
loop {
scored_move = match moves.next_move(&self.gen, &self.hh, &mut self.board) {
Some(scored_move) => scored_move,
None => {
if fail_high && has_valid_moves {
// research required, because a Zugzwang position was detected (fail-high report by null search, but no found cutoff)
depth += 1;
nullmove_verification = true;
fail_high = false;
evaluated_move_count = 0;
moves.reset();
continue;
} else {
// Last move has been evaluated
break;
}
}
};
let m = decode_move(scored_move);
let target_piece_id = decode_piece_id(m);
let start = decode_start_index(m);
let end = decode_end_index(m);
let previous_piece = self.board.get_item(start);
let move_state = self.board.perform_move(target_piece_id as i8, start, end);
let removed_piece_id = move_state & !EN_PASSANT;
let mut skip = self.board.is_in_check(player_color); // skip if move would put own king in check
let mut reductions = 0;
let mut gives_check = false;
if !skip {
has_valid_moves = true;
gives_check = self.board.is_in_check(-player_color);
if removed_piece_id == EMPTY {
let has_negative_history =
self.hh
.has_negative_history(player_color, depth, start, end);
let own_moves_left = (depth + 1) / 2;
if !gives_check
&& allow_reductions
&& evaluated_move_count > LMR_THRESHOLD
&& !self.board.is_pawn_move_close_to_promotion(
previous_piece,
end,
own_moves_left - 1,
)
{
// Reduce search depth for late moves (i.e. after trying the most promising moves)
reductions = LMR_REDUCTIONS;
if has_negative_history
|| self.board.see_score(
-player_color,
start,
end,
target_piece_id,
EMPTY as u32,
) < 0
{
// Reduce more, if move has negative history or SEE score
reductions += 1;
}
} else if !gives_check
&& allow_futile_move_pruning
&& target_piece_id as i8 == previous_piece.abs()
{
if own_moves_left <= 1
|| (has_negative_history
&& self.board.see_score(
-player_color,
start,
end,
target_piece_id,
EMPTY as u32,
) < 0)
{
// Prune futile move
skip = true;
if prune_low_score > best_score {
best_move = NO_MOVE;
best_score = prune_low_score; // remember score with added margin for cases when all moves are pruned
}
} else {
// Reduce futile move
reductions = FUTILE_MOVE_REDUCTIONS;
}
} else if has_negative_history
|| self.board.see_score(
-player_color,
start,
end,
target_piece_id,
EMPTY as u32,
) < 0
{
// Reduce search depth for moves with negative history or negative SEE score
reductions = LOSING_MOVE_REDUCTIONS;
}
} else if removed_piece_id <= previous_piece.abs()
&& self.board.see_score(
-player_color,
start,
end,
target_piece_id,
removed_piece_id as u32,
) < 0
{
// Reduce search depth for moves with negative capture moves with negative SEE score
reductions = LOSING_MOVE_REDUCTIONS;
}
}
if skip {
self.board.undo_move(previous_piece, start, end, move_state);
} else {
if removed_piece_id == EMPTY {
self.hh.update_played_moves(player_color, start, end);
}
evaluated_move_count += 1;
let a = if evaluated_move_count > 1 {
-(alpha + 1)
} else {
-beta
};
let mut result = self.rec_find_best_move(
a,
-alpha,
-player_color,
depth - reductions - 1,
ply + 1,
false,
nullmove_verification,
gives_check,
);
if result == CANCEL_SEARCH {
self.board.undo_move(previous_piece, start, end, move_state);
return CANCEL_SEARCH;
}
if -result > alpha && (-result < beta || reductions > 0) {
// Repeat search without reduction and with full window
result = self.rec_find_best_move(
-beta,
-alpha,
-player_color,
depth - 1,
ply + 1,
false,
nullmove_verification,
gives_check,
);
if result == CANCEL_SEARCH {
self.board.undo_move(previous_piece, start, end, move_state);
return CANCEL_SEARCH;
}
}
let score = -result;
self.board.undo_move(previous_piece, start, end, move_state);
if score > best_score {
best_score = score;
best_move = m;
// Alpha-beta pruning
if best_score > alpha {
alpha = best_score;
score_type = EXACT;
}
if alpha >= beta {
self.tt.write_entry(
hash,
depth,
encode_scored_move(best_move, best_score),
LOWER_BOUND,
);
if removed_piece_id == EMPTY {
self.hh.update(ply, player_color, start, end, best_move);
}
return alpha;
}
}
}
}
if !has_valid_moves {
if is_in_check {
// Check mate
return WHITE_MATE_SCORE + ply;
}
// Stale mate
return 0;
}
self.tt.write_entry(
hash,
depth,
encode_scored_move(best_move, best_score),
score_type,
);
best_score
}
fn quiescence_search(
&mut self,
active_player: Color,
mut alpha: i32,
beta: i32,
ply: i32,
) -> i32 {
self.node_count += 1;
if self.board.is_engine_draw() {
return 0;
}
let position_score = self.board.get_score() * active_player as i32;
if ply >= MAX_DEPTH as i32 {
return position_score;
}
if position_score >= beta {
return beta;
}
// Prune nodes where the position score is already so far below alpha that it is very unlikely to be raised by any available move
if position_score < alpha - QS_PRUNE_MARGIN {
return alpha;
}
if alpha < position_score {
alpha = position_score;
}
let mut moves = SortedMoveGenerator::gen_capture_moves();
let mut threshold = alpha - position_score - QS_SEE_THRESHOLD;
while let Some(scored_move) = moves.next_capture_move(&self.gen, &mut self.board)
{
let m = decode_move(scored_move);
let target_piece_id = decode_piece_id(m);
let start = decode_start_index(m);
let end = decode_end_index(m);
let previous_piece = self.board.get_item(start);
let previous_piece_id = previous_piece.abs();
let captured_piece_id = self.board.get_item(end).abs();
// skip capture moves with a SEE score below the given threshold
if self.board.see_score(
-active_player,
start,
end,
previous_piece_id as u32,
captured_piece_id as u32,
) <= threshold
{
continue;
}
let move_state = self.board.perform_move(target_piece_id as i8, start, end);
if self.board.is_in_check(active_player) {
// Invalid move
self.board.undo_move(previous_piece, start, end, move_state);
continue;
}
let score = -self.quiescence_search(-active_player, -beta, -alpha, ply + 1);
self.board.undo_move(previous_piece, start, end, move_state);
if score >= beta {
return beta;
}
if score > alpha {
alpha = score;
threshold = alpha - position_score - QS_SEE_THRESHOLD;
}
}
alpha
}
fn get_base_stats(&self, duration: Duration) -> String {
let duration_micros = duration.as_micros();
let nodes_per_second = if duration_micros > 0 {
self.node_count as u128 * 1_000_000 / duration_micros
} else {
0
};
if nodes_per_second > 0 {
format!(
" nodes {} nps {} time {}",
self.node_count,
nodes_per_second,
duration_micros / 1000
)
} else {
format!(" nodes {} time {}", self.node_count, duration_micros / 1000)
}
}
fn extract_pv(&mut self, m: Move, depth: i32) -> String {
let uci_move = UCIMove::from_encoded_move(&self.board, m).to_uci();
if depth == 0 {
return uci_move;
}
let target_piece_id = decode_piece_id(m);
let start = decode_start_index(m);
let end = decode_end_index(m);
let previous_piece = self.board.get_item(start);
let removed_piece_id = self.board.perform_move(target_piece_id as i8, start, end);
let entry = self.tt.get_entry(self.board.get_hash());
let next_move = if entry != 0 {
decode_move(get_scored_move(entry))
} else {
NO_MOVE
};
let active_player = self.board.active_player();
let is_valid_followup_move =
next_move != NO_MOVE && is_valid_move(&mut self.board, active_player, next_move);
let followup_uci_moves = if is_valid_followup_move {
format!(" {}", self.extract_pv(next_move, depth - 1))
} else {
String::from("")
};
self.board
.undo_move(previous_piece, start, end, removed_piece_id);
format!("{}{}", uci_move, followup_uci_moves)
}
// If a check mate position can be achieved, then earlier check mates should have a better score than later check mates
// to prevent unnecessary delays.
fn terminal_score(&mut self, active_player: Color, ply: i32) -> i32 {
if active_player == WHITE {
if self.is_check_mate(WHITE) {
return WHITE_MATE_SCORE + ply;
} else {
return 0; // Stale mate
}
}
if self.is_check_mate(BLACK) {
return BLACK_MATE_SCORE - ply;
}
0 // Stale mate
}
}
fn should_extend_timelimit(
new_move: Move,
new_score: i32,
previous_move: Move,
previous_score: i32,
score_fluctuations: i32,
fluctuation_count: i32,
) -> bool {
if previous_move == 0 || new_move == 0 {
return false;
}
let avg_fluctuations = if fluctuation_count > 0 {
score_fluctuations / fluctuation_count
} else {
0
};
new_move != previous_move
|| (new_score - previous_score).abs() >= TIMEEXT_SCORE_CHANGE_THRESHOLD
|| avg_fluctuations >= TIMEEXT_SCORE_FLUCTUATION_THRESHOLD
}
fn get_score_info(score: i32) -> String {
if score <= WHITE_MATE_SCORE + MAX_DEPTH as i32 {
return format!("mate {}", (WHITE_MATE_SCORE - score - 1) / 2);
} else if score >= BLACK_MATE_SCORE - MAX_DEPTH as i32 {
return format!("mate {}", (BLACK_MATE_SCORE - score + 1) / 2);
}
format!("cp {}", score)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::board::Board;
use crate::engine::Message;
use crate::fen::write_fen;
use crate::pieces::{K, R};
use std::sync::mpsc;
#[test]
fn finds_mate_in_one() {
#[rustfmt::skip]
let items: [i8; 64] = [
0, 0, 0, 0, K, 0, 0, 0,
-R, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, -K, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, -R,
];
let fen = to_fen(BLACK, &items);
let (_, rx) = mpsc::channel::();
let mut engine = Engine::new_from_fen(rx, &fen, 1);
engine.timelimit_ms = 0;
let m = engine.find_best_move(2, true);
assert_ne!(NO_MOVE, m);
engine.perform_move(m);
assert!(engine.is_check_mate(WHITE));
}
#[test]
fn finds_mate_in_two() {
#[rustfmt::skip]
let items: [i8; 64] = [
0, 0, 0, 0, -K, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
R, 0, 0, 0, K, 0, 0, R,
];
let fen = to_fen(WHITE, &items);
let (_, rx) = mpsc::channel::();
let mut engine = Engine::new_from_fen(rx, &fen, 1);
engine.timelimit_ms = 0;
let m1 = engine.find_best_move(3, true);
engine.perform_move(m1);
let m2 = engine.find_best_move(2, true);
engine.perform_move(m2);
let m3 = engine.find_best_move(1, true);
engine.perform_move(m3);
assert!(engine.is_check_mate(BLACK));
}
fn to_fen(active_player: Color, items: &[i8; 64]) -> String {
write_fen(&Board::new(items, active_player, 0, None, 0, 1))
}
}