use std::fmt;
use std::io::{self, BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use std::time::Duration;
use crate::game::Game;
use crate::types::ChessError;
use crate::Board;
#[derive(Debug)]
pub enum UciError {
SpawnFailed(io::Error),
WriteFailed(io::Error),
Timeout,
ProcessDied,
ParseError(String),
EngineError(String),
}
impl fmt::Display for UciError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
UciError::SpawnFailed(e) => write!(f, "Failed to spawn engine: {e}"),
UciError::WriteFailed(e) => write!(f, "Failed to write to engine: {e}"),
UciError::Timeout => write!(f, "Engine did not respond in time"),
UciError::ProcessDied => write!(f, "Engine process terminated unexpectedly"),
UciError::ParseError(s) => write!(f, "Parse error: {s}"),
UciError::EngineError(s) => write!(f, "Engine error: {s}"),
}
}
}
impl std::error::Error for UciError {}
impl From<UciError> for ChessError {
fn from(e: UciError) -> Self {
ChessError::new(e.to_string())
}
}
pub type UciResult<T> = Result<T, UciError>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Score {
Centipawns(i32),
Mate(i32),
}
impl fmt::Display for Score {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Score::Centipawns(cp) => write!(f, "{cp:+} cp"),
Score::Mate(n) if *n > 0 => write!(f, "Mate in {n}"),
Score::Mate(n) => write!(f, "Being mated in {}", n.abs()),
}
}
}
#[derive(Debug, Clone)]
pub struct AnalysisInfo {
pub depth: Option<u32>,
pub seldepth: Option<u32>,
pub score: Option<Score>,
pub pv: Vec<String>,
pub nodes: Option<u64>,
pub nps: Option<u64>,
pub time_ms: Option<u64>,
pub hashfull: Option<u32>,
pub multipv: Option<u32>,
pub message: Option<String>,
}
impl AnalysisInfo {
fn empty() -> Self {
Self {
depth: None,
seldepth: None,
score: None,
pv: Vec::new(),
nodes: None,
nps: None,
time_ms: None,
hashfull: None,
multipv: None,
message: None,
}
}
}
impl fmt::Display for AnalysisInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(ref msg) = self.message {
return write!(f, "[engine] {msg}");
}
if let Some(mpv) = self.multipv {
write!(f, "[pv {mpv}] ")?;
}
if let Some(d) = self.depth {
write!(f, "depth {d} ")?;
}
if let Some(ref s) = self.score {
write!(f, "score {s} ")?;
}
if !self.pv.is_empty() {
write!(f, "pv {}", self.pv.join(" "))?;
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct PlayResult {
pub best_move: String,
pub ponder_move: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct SearchConfig {
pub depth: Option<u32>,
pub movetime: Option<u64>,
pub wtime: Option<u64>,
pub btime: Option<u64>,
pub winc: Option<u64>,
pub binc: Option<u64>,
pub searchmoves: Vec<String>,
pub multipv: Option<u32>,
pub infinite: bool,
pub read_timeout_ms: Option<u64>,
pub ponder_move: Option<String>,
}
impl SearchConfig {
pub fn depth(d: u32) -> Self {
Self { depth: Some(d), ..Default::default() }
}
pub fn movetime(ms: u64) -> Self {
Self { movetime: Some(ms), ..Default::default() }
}
pub fn infinite() -> Self {
Self { infinite: true, ..Default::default() }
}
pub fn builder() -> SearchConfigBuilder {
SearchConfigBuilder::default()
}
fn to_go_command(&self) -> String {
let mut cmd = String::from("go");
if self.ponder_move.is_some() {
cmd.push_str(" ponder");
}
if self.infinite {
cmd.push_str(" infinite");
return cmd;
}
if let Some(d) = self.depth {
cmd.push_str(&format!(" depth {d}"));
}
if let Some(ms) = self.movetime {
cmd.push_str(&format!(" movetime {ms}"));
}
if let Some(t) = self.wtime {
cmd.push_str(&format!(" wtime {t}"));
}
if let Some(t) = self.btime {
cmd.push_str(&format!(" btime {t}"));
}
if let Some(i) = self.winc {
cmd.push_str(&format!(" winc {i}"));
}
if let Some(i) = self.binc {
cmd.push_str(&format!(" binc {i}"));
}
if !self.searchmoves.is_empty() {
cmd.push_str(" searchmoves ");
cmd.push_str(&self.searchmoves.join(" "));
}
cmd
}
}
#[derive(Debug, Clone, Default)]
pub struct SearchConfigBuilder(SearchConfig);
impl SearchConfigBuilder {
pub fn depth(mut self, d: u32) -> Self { self.0.depth = Some(d); self }
pub fn movetime(mut self, ms: u64) -> Self { self.0.movetime = Some(ms); self }
pub fn wtime(mut self, ms: u64) -> Self { self.0.wtime = Some(ms); self }
pub fn btime(mut self, ms: u64) -> Self { self.0.btime = Some(ms); self }
pub fn winc(mut self, ms: u64) -> Self { self.0.winc = Some(ms); self }
pub fn binc(mut self, ms: u64) -> Self { self.0.binc = Some(ms); self }
pub fn multipv(mut self, n: u32) -> Self { self.0.multipv = Some(n); self }
pub fn infinite(mut self) -> Self { self.0.infinite = true; self }
pub fn searchmoves(mut self, moves: Vec<String>) -> Self {
self.0.searchmoves = moves; self
}
pub fn read_timeout_ms(mut self, ms: u64) -> Self {
self.0.read_timeout_ms = Some(ms); self
}
pub fn ponder_move(mut self, mv: impl Into<String>) -> Self {
self.0.ponder_move = Some(mv.into()); self
}
pub fn build(self) -> SearchConfig { self.0 }
}
#[derive(Debug, Clone)]
pub struct UciOption {
pub name: String,
pub kind: String,
pub default: Option<String>,
pub min: Option<String>,
pub max: Option<String>,
pub vars: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct EngineInfo {
pub name: Option<String>,
pub author: Option<String>,
pub options: Vec<UciOption>,
}
#[derive(Debug)]
enum EngineMessage {
Line(String),
Eof,
}
fn spawn_reader(stdout: ChildStdout, tx: Sender<EngineMessage>) {
thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(l) => {
if tx.send(EngineMessage::Line(l)).is_err() {
break;
}
}
Err(_) => {
let _ = tx.send(EngineMessage::Eof);
break;
}
}
}
let _ = tx.send(EngineMessage::Eof);
});
}
pub struct UciEngine {
child: Child,
stdin: ChildStdin,
rx: Receiver<EngineMessage>,
pub info: EngineInfo,
current_fen: Option<String>,
timeout_ms: u64,
is_pondering: bool,
ponder_move: Option<String>,
}
impl UciEngine {
pub fn new(path: &str) -> UciResult<Self> {
Self::with_options(path, &[])
}
pub fn with_options(path: &str, options: &[(&str, &str)]) -> UciResult<Self> {
let mut child = Command::new(path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(UciError::SpawnFailed)?;
let stdin = child.stdin.take().expect("stdin should be piped");
let stdout = child.stdout.take().expect("stdout should be piped");
let (tx, rx) = mpsc::channel();
spawn_reader(stdout, tx);
let mut engine = Self {
child,
stdin,
rx,
info: EngineInfo::default(),
current_fen: None,
timeout_ms: 10_000,
is_pondering: false,
ponder_move: None,
};
engine.handshake()?;
for (name, value) in options {
engine.set_option(name, value)?;
}
Ok(engine)
}
pub fn set_timeout(&mut self, ms: u64) {
self.timeout_ms = ms;
}
pub fn set_option(&mut self, name: &str, value: &str) -> UciResult<()> {
self.send(&format!("setoption name {name} value {value}"))
}
pub fn set_startpos(&mut self) -> UciResult<()> {
self.send("position startpos")?;
self.current_fen = None;
Ok(())
}
pub fn set_position_fen(&mut self, fen: &str) -> UciResult<()> {
self.send(&format!("position fen {fen}"))?;
self.current_fen = Some(fen.to_owned());
Ok(())
}
pub fn sync_game(&mut self, game: &Game) -> UciResult<()> {
self.set_position_fen(&game.get_fen())
}
pub fn current_fen(&self) -> Option<&str> {
self.current_fen.as_deref()
}
pub fn best_move(&mut self, config: &SearchConfig) -> UciResult<String> {
if let Some(n) = config.multipv {
self.set_option("MultiPV", &n.to_string())?;
} else {
let _ = self.set_option("MultiPV", "1");
}
self.send(&config.to_go_command())?;
let timeout = config.read_timeout_ms.unwrap_or(self.timeout_ms);
let (best, _, _) = self.wait_for_bestmove_with_timeout(timeout)?;
Ok(best)
}
pub fn ponder(&mut self, ponder_move: &str, config: &SearchConfig) -> UciResult<()> {
if self.is_pondering {
return Err(UciError::EngineError(
"already pondering; call ponderhit() or ponder_miss() first".into(),
));
}
let ponder_config = SearchConfig {
ponder_move: Some(ponder_move.to_owned()),
..config.clone()
};
let pos_cmd = match &self.current_fen {
Some(fen) => format!("position fen {fen} moves {ponder_move}"),
None => format!("position startpos moves {ponder_move}"),
};
self.send(&pos_cmd)?;
self.send(&ponder_config.to_go_command())?;
self.is_pondering = true;
self.ponder_move = Some(ponder_move.to_owned());
Ok(())
}
pub fn ponderhit(&mut self) -> UciResult<PlayResult> {
if !self.is_pondering {
return Err(UciError::EngineError(
"not currently pondering".into(),
));
}
self.send("stop")?;
self.send("ponderhit")?;
self.is_pondering = false;
self.ponder_move = None;
let (best_move, ponder_move, _) = self.wait_for_bestmove()?;
Ok(PlayResult { best_move, ponder_move })
}
pub fn ponder_miss(&mut self) -> UciResult<()> {
if !self.is_pondering {
return Ok(()); }
self.send("stop")?;
self.is_pondering = false;
self.ponder_move = None;
let _ = self.wait_for_bestmove();
Ok(())
}
pub fn is_pondering(&self) -> bool {
self.is_pondering
}
pub fn best_move_with_analysis(
&mut self,
config: &SearchConfig,
) -> UciResult<(String, Vec<AnalysisInfo>)> {
if let Some(n) = config.multipv {
self.set_option("MultiPV", &n.to_string())?;
}
self.send(&config.to_go_command())?;
let timeout = config.read_timeout_ms.unwrap_or(self.timeout_ms);
let (best, _, infos) = self.wait_for_bestmove_with_timeout(timeout)?;
Ok((best, infos))
}
pub fn top_moves_from_board(
&mut self,
board: &Board,
n: u32,
config: &SearchConfig,
) -> UciResult<Vec<(String, Option<Score>)>> {
let n = n.clamp(1, 500);
let cfg = SearchConfig {
multipv: Some(n),
..config.clone()
};
let fen = crate::fen::board_to_fen(board);
self.set_position_fen(&fen)?;
let (_, infos) = self.best_move_with_analysis(&cfg)?;
let mut by_line: std::collections::HashMap<u32, AnalysisInfo> =
std::collections::HashMap::new();
for info in infos {
let key = info.multipv.unwrap_or(1);
let deeper = by_line.get(&key).and_then(|p| p.depth).unwrap_or(0);
if info.depth.unwrap_or(0) >= deeper {
by_line.insert(key, info);
}
}
let mut keys: Vec<u32> = by_line.keys().copied().collect();
keys.sort_unstable();
Ok(keys
.into_iter()
.filter_map(|k| {
let info = by_line.remove(&k)?;
let mv = info.pv.first()?.clone();
Some((mv, info.score))
})
.collect())
}
pub fn evaluate_board(
&mut self,
board: &Board,
config: &SearchConfig,
) -> UciResult<Option<Score>> {
let fen = crate::fen::board_to_fen(board);
self.set_position_fen(&fen)?;
let infos = self.analyze(config)?;
Ok(infos
.iter()
.filter(|i| i.multipv.unwrap_or(1) == 1)
.max_by_key(|i| i.depth.unwrap_or(0))
.and_then(|i| i.score.clone()))
}
pub fn analyze(&mut self, config: &SearchConfig) -> UciResult<Vec<AnalysisInfo>> {
let (_, infos) = self.best_move_with_analysis(config)?;
Ok(infos)
}
pub fn stop(&mut self) -> UciResult<()> {
self.send("stop")
}
pub fn new_game(&mut self) -> UciResult<()> {
self.send("ucinewgame")?;
self.current_fen = None;
self.wait_for_readyok()?;
Ok(())
}
pub fn best_move_for_game(
&mut self,
game: &Game,
config: &SearchConfig,
) -> UciResult<String> {
self.sync_game(game)?;
self.best_move(config)
}
pub fn play(&mut self, game: &Game, config: &SearchConfig) -> UciResult<PlayResult> {
self.sync_game(game)?;
if let Some(n) = config.multipv {
self.set_option("MultiPV", &n.to_string())?;
} else {
let _ = self.set_option("MultiPV", "1");
}
self.send(&config.to_go_command())?;
let timeout = config.read_timeout_ms.unwrap_or(self.timeout_ms);
let (best_move, ponder_move, _) = self.wait_for_bestmove_with_timeout(timeout)?;
Ok(PlayResult { best_move, ponder_move })
}
pub fn analyze_game(
&mut self,
game: &Game,
config: &SearchConfig,
) -> UciResult<Vec<AnalysisInfo>> {
self.sync_game(game)?;
self.analyze(config)
}
pub fn top_moves(
&mut self,
game: &Game,
n: u32,
config: &SearchConfig,
) -> UciResult<Vec<(String, Option<Score>)>> {
let n = n.clamp(1, 500);
let cfg = SearchConfig {
multipv: Some(n),
..config.clone()
};
self.sync_game(game)?;
let (_, infos) = self.best_move_with_analysis(&cfg)?;
let mut by_line: std::collections::HashMap<u32, AnalysisInfo> =
std::collections::HashMap::new();
for info in infos {
let key = info.multipv.unwrap_or(1);
let deeper = by_line
.get(&key)
.and_then(|prev| prev.depth)
.unwrap_or(0);
if info.depth.unwrap_or(0) >= deeper {
by_line.insert(key, info);
}
}
let mut keys: Vec<u32> = by_line.keys().copied().collect();
keys.sort_unstable();
let result = keys
.into_iter()
.filter_map(|k| {
let info = by_line.remove(&k)?;
let mv = info.pv.first()?.clone();
Some((mv, info.score))
})
.collect();
Ok(result)
}
pub fn evaluate(
&mut self,
game: &Game,
config: &SearchConfig,
) -> UciResult<Option<Score>> {
let infos = self.analyze_game(game, config)?;
Ok(infos
.iter()
.filter(|i| i.multipv.unwrap_or(1) == 1)
.max_by_key(|i| i.depth.unwrap_or(0))
.and_then(|i| i.score.clone()))
}
pub fn quit(mut self) -> UciResult<()> {
let _ = self.send("quit");
let _ = self.child.wait();
Ok(())
}
fn send(&mut self, line: &str) -> UciResult<()> {
writeln!(self.stdin, "{line}").map_err(UciError::WriteFailed)?;
self.stdin.flush().map_err(UciError::WriteFailed)
}
fn read_line(&self) -> UciResult<Option<String>> {
self.read_line_timeout(self.timeout_ms)
}
fn read_line_timeout(&self, timeout_ms: u64) -> UciResult<Option<String>> {
match self.rx.recv_timeout(Duration::from_millis(timeout_ms)) {
Ok(EngineMessage::Line(l)) => Ok(Some(l)),
Ok(EngineMessage::Eof) => Ok(None),
Err(mpsc::RecvTimeoutError::Timeout) => Err(UciError::Timeout),
Err(mpsc::RecvTimeoutError::Disconnected) => Err(UciError::ProcessDied),
}
}
fn wait_for_readyok(&mut self) -> UciResult<()> {
self.send("isready")?;
loop {
match self.read_line()? {
Some(line) if line.trim() == "readyok" => return Ok(()),
Some(_) => {} None => return Err(UciError::ProcessDied),
}
}
}
fn handshake(&mut self) -> UciResult<()> {
self.send("uci")?;
loop {
match self.read_line()? {
Some(line) => {
let trimmed = line.trim();
if trimmed == "uciok" {
break;
}
if let Some(rest) = trimmed.strip_prefix("id name ") {
self.info.name = Some(rest.to_owned());
} else if let Some(rest) = trimmed.strip_prefix("id author ") {
self.info.author = Some(rest.to_owned());
} else if trimmed.starts_with("option ")
&& let Some(opt) = parse_option_line(trimmed) {
self.info.options.push(opt);
}
}
None => return Err(UciError::ProcessDied),
}
}
self.wait_for_readyok()
}
fn wait_for_bestmove_with_timeout(
&mut self,
timeout_ms: u64,
) -> UciResult<(String, Option<String>, Vec<AnalysisInfo>)> {
let mut infos: Vec<AnalysisInfo> = Vec::new();
loop {
match self.read_line_timeout(timeout_ms)? {
Some(line) => {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("bestmove ") {
let mut tokens = rest.split_whitespace();
let best = tokens.next().unwrap_or("").to_owned();
if best.is_empty() || best == "(none)" {
return Err(UciError::EngineError(
"engine returned no bestmove".into(),
));
}
let ponder_suggestion = if tokens.next() == Some("ponder") {
tokens.next().map(str::to_owned)
} else {
None
};
return Ok((best, ponder_suggestion, infos));
} else if trimmed.starts_with("info ")
&& let Some(info) = parse_info_line(trimmed) {
infos.push(info);
}
}
None => return Err(UciError::ProcessDied),
}
}
}
fn wait_for_bestmove(&mut self) -> UciResult<(String, Option<String>, Vec<AnalysisInfo>)> {
self.wait_for_bestmove_with_timeout(self.timeout_ms)
}
}
impl Drop for UciEngine {
fn drop(&mut self) {
let _ = writeln!(self.stdin, "quit");
let _ = self.stdin.flush();
let _ = self.child.wait();
}
}
fn parse_option_line(line: &str) -> Option<UciOption> {
let rest = line.strip_prefix("option ")?;
let mut name = String::new();
let mut kind = String::new();
let mut default = None;
let mut min = None;
let mut max = None;
let mut vars = Vec::new();
let keywords = ["name", "type", "default", "min", "max", "var"];
let tokens: Vec<&str> = rest.splitn(2, "name ").collect();
let mut current_key: Option<&str> = None;
let mut current_val: Vec<&str> = Vec::new();
let flush = |key: &str, val: &[&str],
name: &mut String,
kind: &mut String,
default: &mut Option<String>,
min: &mut Option<String>,
max: &mut Option<String>,
vars: &mut Vec<String>| {
let v = val.join(" ");
match key {
"name" => *name = v,
"type" => *kind = v,
"default" => *default = Some(v),
"min" => *min = Some(v),
"max" => *max = Some(v),
"var" => vars.push(v),
_ => {}
}
};
for word in rest.split_whitespace() {
if keywords.contains(&word) {
if let Some(key) = current_key {
flush(key, ¤t_val, &mut name, &mut kind,
&mut default, &mut min, &mut max, &mut vars);
current_val.clear();
}
current_key = Some(word);
} else {
current_val.push(word);
}
}
if let Some(key) = current_key {
flush(key, ¤t_val, &mut name, &mut kind,
&mut default, &mut min, &mut max, &mut vars);
}
drop(tokens);
if name.is_empty() {
return None;
}
Some(UciOption { name, kind, default, min, max, vars })
}
fn parse_info_line(line: &str) -> Option<AnalysisInfo> {
let rest = line.strip_prefix("info")?;
let mut info = AnalysisInfo::empty();
let mut words: std::iter::Peekable<std::str::SplitWhitespace> =
rest.split_whitespace().peekable();
while let Some(token) = words.next() {
match token {
"depth" => {
info.depth = words.next().and_then(|w| w.parse().ok());
}
"seldepth" => {
info.seldepth = words.next().and_then(|w| w.parse().ok());
}
"multipv" => {
info.multipv = words.next().and_then(|w| w.parse().ok());
}
"nodes" => {
info.nodes = words.next().and_then(|w| w.parse().ok());
}
"nps" => {
info.nps = words.next().and_then(|w| w.parse().ok());
}
"time" => {
info.time_ms = words.next().and_then(|w| w.parse().ok());
}
"hashfull" => {
info.hashfull = words.next().and_then(|w| w.parse().ok());
}
"score" => {
if let Some(kind) = words.next()
&& let Some(val) = words.next().and_then(|w| w.parse::<i32>().ok()) {
info.score = Some(match kind {
"cp" => Score::Centipawns(val),
"mate" => Score::Mate(val),
_ => continue,
});
if let Some(&next) = words.peek()
&& (next == "lowerbound" || next == "upperbound") {
words.next();
}
}
}
"pv" => {
info.pv = words.by_ref().map(str::to_owned).collect();
}
"string" => {
let msg: Vec<&str> = words.by_ref().collect();
if !msg.is_empty() {
info.message = Some(msg.join(" "));
}
}
_ => {}
}
}
Some(info)
}