use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use cozy_chess::util::{display_uci_move, parse_uci_move};
use cozy_chess::{Board, Color, Move};
use log::{error, warn};
use crate::strategy::Strategy;
use crate::uci::{self, Go};
const MOVES_AHEAD: u32 = 30;
const MAX_BUDGET: Duration = Duration::from_secs(10);
const MIN_BUDGET: Duration = Duration::from_millis(10);
const RESERVE: Duration = Duration::from_millis(50);
const POLL: Duration = Duration::from_millis(1);
pub struct Limits {
stop: Arc<AtomicBool>,
deadline: Option<Instant>,
pub depth: Option<u8>,
pub nodes: Option<u64>,
pub search_moves: Vec<Move>,
pub infinite: bool,
}
impl Limits {
pub fn new(go: &Go, board: &Board) -> Limits {
let (remaining, increment) = match board.side_to_move() {
Color::White => (go.white_time, go.white_increment),
Color::Black => (go.black_time, go.black_increment),
};
let deadline = match (go.movetime, remaining) {
(Some(movetime), _) => Some(Instant::now() + movetime),
(None, Some(remaining)) => Some(
Instant::now() + budget(remaining, increment.unwrap_or_default(), go.moves_to_go),
),
(None, None) => None,
};
let depth = go.depth.or(go.mate.map(|moves| moves.saturating_mul(2)));
Limits {
stop: Arc::new(AtomicBool::new(false)),
deadline,
depth,
nodes: go.nodes,
search_moves: playable(board, &go.search_moves),
infinite: go.infinite || (deadline.is_none() && depth.is_none() && go.nodes.is_none()),
}
}
pub fn expired(&self) -> bool {
self.stop.load(Ordering::Relaxed)
|| self
.deadline
.is_some_and(|deadline| Instant::now() >= deadline)
}
pub fn spent(&self, nodes: u64) -> bool {
self.nodes.is_some_and(|limit| nodes >= limit) || self.expired()
}
}
fn playable(board: &Board, wanted: &[String]) -> Vec<Move> {
wanted
.iter()
.filter_map(|text| match parse_uci_move(board, text) {
Ok(played) if board.is_legal(played) => Some(played),
_ => {
warn!("Ignoring searchmoves {text}: unplayable here");
None
}
})
.collect()
}
fn budget(remaining: Duration, increment: Duration, moves_to_go: Option<u32>) -> Duration {
let share = moves_to_go.unwrap_or(MOVES_AHEAD).clamp(1, MOVES_AHEAD);
let planned = (remaining / share + increment / 2).min(MAX_BUDGET);
planned
.min(remaining.saturating_sub(RESERVE))
.max(MIN_BUDGET)
}
pub struct Handle {
stop: Arc<AtomicBool>,
thread: JoinHandle<()>,
}
impl Handle {
pub fn stop(&self) {
self.stop.store(true, Ordering::Relaxed);
}
pub fn finish(self) {
self.stop();
if self.thread.join().is_err() {
error!("Search thread panicked");
}
}
}
pub fn spawn(strategy: Strategy, board: Board, limits: Limits) -> Handle {
let stop = Arc::clone(&limits.stop);
let thread = thread::spawn(move || {
let best = strategy.pick(&board, &limits);
while limits.infinite && !limits.expired() {
thread::sleep(POLL);
}
uci::send(&best_move(&board, best));
});
Handle { stop, thread }
}
fn best_move(board: &Board, played: Option<Move>) -> String {
match played {
Some(played) => format!("bestmove {}", display_uci_move(board, played)),
None => "bestmove 0000".to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn limits_of(go: &Go) -> Limits {
Limits::new(go, &Board::startpos())
}
fn to_depth(depth: u8) -> Limits {
limits_of(&Go {
depth: Some(depth),
..Go::default()
})
}
#[test]
fn spends_a_slice_of_the_clock() {
assert_eq!(
budget(Duration::from_secs(120), Duration::from_secs(2), None),
Duration::from_secs(5)
);
}
#[test]
fn spends_more_when_the_moves_ahead_are_counted() {
assert_eq!(
budget(Duration::from_secs(60), Duration::ZERO, Some(10)),
Duration::from_secs(6)
);
}
#[test]
fn caps_the_time_spent_on_one_move() {
assert_eq!(
budget(Duration::from_secs(3600), Duration::ZERO, None),
MAX_BUDGET
);
}
#[test]
fn stays_within_the_clock() {
assert_eq!(
budget(Duration::from_millis(60), Duration::from_secs(10), None),
MIN_BUDGET
);
}
#[test]
fn runs_until_stopped_only_without_any_bound() {
assert!(limits_of(&Go::default()).infinite);
assert!(
limits_of(&Go {
infinite: true,
..Go::default()
})
.infinite
);
assert!(!to_depth(3).infinite);
}
#[test]
fn searches_a_mate_to_a_bounded_depth() {
let limits = limits_of(&Go {
mate: Some(2),
..Go::default()
});
assert_eq!(limits.depth, Some(4));
assert!(!limits.infinite);
}
#[test]
fn keeps_only_the_searchmoves_that_can_be_played() {
let limits = limits_of(&Go {
search_moves: ["e2e4", "e2e5", "nonsense"].map(str::to_owned).into(),
..Go::default()
});
let board = Board::startpos();
assert_eq!(limits.search_moves.len(), 1);
assert_eq!(
display_uci_move(&board, limits.search_moves[0]).to_string(),
"e2e4"
);
}
#[test]
fn expires_at_the_deadline() {
let limits = limits_of(&Go {
movetime: Some(Duration::ZERO),
..Go::default()
});
assert!(limits.expired());
}
#[test]
fn expires_when_stopped() {
let limits = to_depth(3);
assert!(!limits.expired());
limits.stop.store(true, Ordering::Relaxed);
assert!(limits.expired());
}
#[test]
fn reports_castling_the_way_guis_write_it() {
let board = Board::from_fen("4k3/8/8/8/8/8/8/4K2R w K - 0 1", false).unwrap();
let castle = parse_uci_move(&board, "e1g1").unwrap();
assert_eq!(best_move(&board, Some(castle)), "bestmove e1g1");
assert_eq!(best_move(&board, None), "bestmove 0000");
}
#[test]
fn expires_when_the_nodes_run_out() {
let limits = limits_of(&Go {
nodes: Some(100),
..Go::default()
});
assert!(!limits.spent(99));
assert!(limits.spent(100));
}
}