1use crate::{
8 EvaluationProfile, Position, ScoreKind, SearchConfig, SearchReport, Side, search_until,
9};
10use std::time::Instant;
11
12const MAX_DEPTH: u8 = 16;
13
14#[derive(Debug, Default)]
15pub struct CassioEngine;
17
18impl CassioEngine {
19 pub const fn new() -> Self {
20 Self
21 }
22
23 pub fn command(&mut self, line: &str) -> Vec<String> {
24 self.command_until(line, || false)
25 }
26
27 pub fn command_until<F: Fn() -> bool>(&mut self, line: &str, should_stop: F) -> Vec<String> {
29 let mut words = line.split_whitespace();
30 if words.next() != Some("ENGINE-PROTOCOL") {
31 return vec![cassio_error("expected ENGINE-PROTOCOL prefix")];
32 }
33 let Some(command) = words.next() else {
34 return vec![
35 cassio_error("missing command after ENGINE-PROTOCOL"),
36 "ready.".to_owned(),
37 ];
38 };
39 let parameters: Vec<_> = words.collect();
40 match command.to_ascii_lowercase().as_str() {
41 "init" | "new-position" | "empty-hash" | "stop" => {
42 without_parameters(command, ¶meters, vec!["ready.".to_owned()])
43 }
44 "get-version" => without_parameters(
45 command,
46 ¶meters,
47 vec!["version: AI Othello 0.1.0".to_owned(), "ready.".to_owned()],
48 ),
49 "midgame-search" => self.midgame_search(¶meters, should_stop),
50 "endgame-search" => self.endgame_search(¶meters, should_stop),
51 "feed-hash" => unsupported("feed-hash"),
52 "get-search-infos" => unsupported("live search polling"),
53 "quit" | "eof" => without_parameters(command, ¶meters, vec!["bye bye.".to_owned()]),
54 _ => malformed(command, "unknown command"),
55 }
56 }
57
58 fn midgame_search<F: Fn() -> bool>(&mut self, words: &[&str], should_stop: F) -> Vec<String> {
59 let Ok((position, consumed)) = parse_position(words) else {
60 return malformed("midgame-search", "expected a 64-cell board and X/O side");
61 };
62 let limits = &words[consumed..];
63 if limits.len() != 4 {
64 return malformed(
65 "midgame-search",
66 "expected: <position> <alpha> <beta> <depth> <precision>",
67 );
68 }
69 let (Ok(alpha), Ok(beta), Ok(depth), Ok(precision)) = (
70 limits[0].parse::<f64>(),
71 limits[1].parse::<f64>(),
72 limits[2].parse::<u8>(),
73 limits[3].parse::<u8>(),
74 ) else {
75 return malformed("midgame-search", "search limits must be numeric");
76 };
77 if !alpha.is_finite() || !beta.is_finite() || alpha >= beta {
78 return malformed("midgame-search", "alpha must be less than beta");
79 }
80 if depth > MAX_DEPTH {
81 return malformed("midgame-search", "depth must be between 0 and 16");
82 }
83 if !(1..=100).contains(&precision) {
84 return malformed("midgame-search", "precision must be between 1 and 100");
85 }
86 self.run_search(position, depth, 0, precision, should_stop)
87 }
88
89 fn endgame_search<F: Fn() -> bool>(&mut self, words: &[&str], should_stop: F) -> Vec<String> {
90 let Ok((position, consumed)) = parse_position(words) else {
91 return malformed("endgame-search", "expected a 64-cell board and X/O side");
92 };
93 let limits = &words[consumed..];
94 if limits.len() != 3 {
95 return malformed(
96 "endgame-search",
97 "expected: <position> <alpha> <beta> <precision>",
98 );
99 }
100 let (Ok(alpha), Ok(beta), Ok(precision)) = (
101 limits[0].parse::<i32>(),
102 limits[1].parse::<i32>(),
103 limits[2].parse::<u8>(),
104 ) else {
105 return malformed("endgame-search", "search limits must be integers");
106 };
107 if alpha >= beta {
108 return malformed("endgame-search", "alpha must be less than beta");
109 }
110 if !(1..=100).contains(&precision) {
111 return malformed("endgame-search", "precision must be between 1 and 100");
112 }
113 let depth = position.empty_count();
114 if depth > MAX_DEPTH {
115 return malformed(
116 "endgame-search",
117 "this teaching engine solves at most 16 empty squares",
118 );
119 }
120 self.run_search(position, depth, depth, precision, should_stop)
121 }
122
123 fn run_search<F: Fn() -> bool>(
124 &mut self,
125 position: Position,
126 depth: u8,
127 exact_endgame_empties: u8,
128 precision: u8,
129 should_stop: F,
130 ) -> Vec<String> {
131 let started = Instant::now();
132 let config = SearchConfig {
133 depth,
134 evaluator: EvaluationProfile::Phase,
135 exact_endgame_empties,
136 };
137 let Some(report) = search_until(position, config, should_stop) else {
138 return vec!["ready.".to_owned()];
139 };
140 let elapsed = started.elapsed().as_secs_f64();
141 vec![
142 result_line(position, &report, precision, elapsed),
143 "ready.".to_owned(),
144 ]
145 }
146}
147
148fn parse_position(words: &[&str]) -> Result<(Position, usize), &'static str> {
149 let Some(first) = words.first().copied() else {
150 return Err("missing position");
151 };
152 if !first.is_ascii() {
153 return Err("position must use Cassio ASCII cells");
154 }
155 if first.len() == 65 {
156 let (board, side) = first.split_at(64);
157 Position::from_cassio(board, side).map(|position| (position, 1))
158 } else if first.len() == 64 {
159 let Some(side) = words.get(1).copied() else {
160 return Err("missing side");
161 };
162 Position::from_cassio(first, side).map(|position| (position, 2))
163 } else {
164 Err("bad board length")
165 }
166}
167
168fn result_line(position: Position, report: &SearchReport, precision: u8, elapsed: f64) -> String {
169 let value = match report.score.kind() {
170 ScoreKind::Win { margin } => f64::from(margin),
171 ScoreKind::Draw => 0.0,
172 ScoreKind::Loss { margin } => -f64::from(margin),
173 ScoreKind::Estimate(raw) => f64::from(raw) / 100.0,
174 };
175 let side = match position.side_to_move() {
176 Side::Black => 'X',
177 Side::White => 'O',
178 };
179 let best_move = report
180 .best_move
181 .map_or_else(|| "pa".to_owned(), |mv| mv.to_string());
182 let pv = report
183 .principal_variation
184 .iter()
185 .map(ToString::to_string)
186 .collect::<Vec<_>>()
187 .join("");
188 format!(
189 "{}, move {}, depth {}, @{}%, {}{:+.2} <= v <= {}{:+.2}, {}, node {}, time {:.3}",
190 position.to_cassio(),
191 best_move,
192 report.config.depth,
193 precision,
194 side,
195 value,
196 side,
197 value,
198 pv,
199 report.stats.nodes,
200 elapsed
201 )
202}
203
204fn malformed(command: &str, message: &str) -> Vec<String> {
205 vec![
206 cassio_error(&format!("{command}: {message}")),
207 "ready.".to_owned(),
208 ]
209}
210
211fn without_parameters(command: &str, parameters: &[&str], response: Vec<String>) -> Vec<String> {
212 if parameters.is_empty() {
213 response
214 } else {
215 malformed(command, "this command takes no parameters")
216 }
217}
218
219fn unsupported(feature: &str) -> Vec<String> {
220 vec![
221 cassio_error(&format!(
222 "{feature} is not supported by this teaching engine"
223 )),
224 "ready.".to_owned(),
225 ]
226}
227
228fn cassio_error(message: &str) -> String {
229 format!("ERROR: {message}")
230}