1use crate::{
2 Evaluation, EvaluationProfile, GameResult, Move, Position, Score, ScoreKind, SearchConfig,
3 SearchReport, Side, Square, evaluate, perft, search,
4};
5use std::fmt::Write;
6use std::str::FromStr;
7#[cfg(not(target_arch = "wasm32"))]
8use std::time::Instant;
9
10pub struct Engine {
12 position: Position,
13 history: Vec<Move>,
14 evaluator: EvaluationProfile,
15 last_report: Option<SearchReport>,
16 last_move: Option<Move>,
17 last_flips: u64,
18}
19
20impl Default for Engine {
21 fn default() -> Self {
22 Self::new()
23 }
24}
25
26impl Engine {
27 pub fn new() -> Self {
28 Self {
29 position: Position::start(),
30 history: Vec::new(),
31 evaluator: EvaluationProfile::default(),
32 last_report: None,
33 last_move: None,
34 last_flips: 0,
35 }
36 }
37
38 pub fn command(&mut self, line: &str) -> Vec<String> {
39 let words: Vec<_> = line.split_whitespace().collect();
40 let Some(command) = words.first().copied() else {
41 return vec![error("empty_command", "command line is empty")];
42 };
43 match command {
44 "gai" => self.handshake(),
45 "isready" => vec!["readyok".to_owned()],
46 "newgame" => {
47 self.reset();
48 Vec::new()
49 }
50 "setoption" => self.set_option(&words[1..]),
51 "position" => self.set_position(&words[1..]),
52 "go" => self.go(&words[1..]),
53 "legal" => vec![format!(
54 "legalmoves {}",
55 self.position
56 .legal_moves()
57 .into_iter()
58 .map(|mv| mv.to_string())
59 .collect::<Vec<_>>()
60 .join(" ")
61 )],
62 "eval" => vec![evaluation_info(evaluate(self.position, self.evaluator))],
63 "perft" => self.run_perft(&words[1..]),
64 "state" => vec![format!("state {}", self.diagnostic_snapshot_json())],
65 "quit" => Vec::new(),
66 _ => vec![error("unknown_command", command)],
67 }
68 }
69
70 pub fn snapshot_json(&self) -> String {
71 self.serialize_snapshot(false)
72 }
73
74 fn diagnostic_snapshot_json(&self) -> String {
75 self.serialize_snapshot(true)
76 }
77
78 fn serialize_snapshot(&self, diagnostics: bool) -> String {
79 let mut json = String::with_capacity(if diagnostics { 16_384 } else { 1_024 });
80 if diagnostics {
81 json.push_str("{\"game\":\"othello\",\"board\":[");
82 } else {
83 json.push_str("{\"board\":[");
84 }
85 for index in 0..64u8 {
86 if index > 0 {
87 json.push(',');
88 }
89 match self.position.side_at(Square::new(index)) {
90 Some(Side::Black) => json.push_str("\"B\""),
91 Some(Side::White) => json.push_str("\"W\""),
92 None => json.push_str("null"),
93 }
94 }
95 let (result, winner, black, white) = result_fields(self.position);
96 write!(
97 json,
98 "],\"sideToMove\":\"{}\",\"result\":\"{}\",\"winner\":{},\"counts\":{{\"black\":{},\"white\":{}}},",
99 self.position.side_to_move(),
100 result,
101 winner,
102 black,
103 white
104 )
105 .expect("writing to String cannot fail");
106
107 json.push_str("\"legalMoves\":[");
108 write_moves(&mut json, self.position.legal_moves().as_slice());
109 json.push_str("],\"history\":[");
110 write_moves(&mut json, &self.history);
111 json.push_str("],\"lastMove\":");
112 match self.last_move {
113 Some(mv) => write!(json, "\"{mv}\"").expect("writing to String cannot fail"),
114 None => json.push_str("null"),
115 }
116 json.push_str(",\"lastFlips\":[");
117 write_squares(&mut json, self.last_flips);
118 json.push_str("],\"overlays\":{\"legal\":[");
119 write_squares(&mut json, self.position.legal_placement_bits());
120
121 if diagnostics {
122 json.push_str("],\"blackFrontier\":[");
123 write_squares(&mut json, self.position.frontier_bits(Side::Black));
124 json.push_str("],\"whiteFrontier\":[");
125 write_squares(&mut json, self.position.frontier_bits(Side::White));
126 write!(
127 json,
128 "]}},\"evaluator\":\"{}\",\"evaluation\":",
129 self.evaluator
130 )
131 .expect("writing to String cannot fail");
132 write_evaluation_json(&mut json, evaluate(self.position, self.evaluator));
133 json.push_str(",\"analysis\":");
134 match &self.last_report {
135 Some(report) => write_report_json(&mut json, report),
136 None => json.push_str("null"),
137 }
138 } else {
139 write!(
140 json,
141 "]}},\"evaluator\":\"{}\",\"analysis\":",
142 self.evaluator
143 )
144 .expect("writing to String cannot fail");
145 match &self.last_report {
146 Some(report) => {
147 json.push_str("{\"bestMove\":");
148 match report.best_move {
149 Some(mv) => {
150 write!(json, "\"{mv}\"").expect("writing to String cannot fail");
151 }
152 None => json.push_str("null"),
153 }
154 json.push('}');
155 }
156 None => json.push_str("null"),
157 }
158 }
159 json.push('}');
160 json
161 }
162
163 fn reset(&mut self) {
164 self.position = Position::start();
165 self.history.clear();
166 self.last_report = None;
167 self.last_move = None;
168 self.last_flips = 0;
169 }
170
171 fn handshake(&self) -> Vec<String> {
172 vec![
173 "id name ai-othello".to_owned(),
174 "id author Nick Gustafson".to_owned(),
175 "id game othello".to_owned(),
176 "option name Evaluator type combo default phase var material var mobility var corners var frontier var phase"
177 .to_owned(),
178 "gaiok".to_owned(),
179 ]
180 }
181
182 fn set_option(&mut self, words: &[&str]) -> Vec<String> {
183 if words.len() != 4
184 || !words[0].eq_ignore_ascii_case("name")
185 || !words[1].eq_ignore_ascii_case("evaluator")
186 || !words[2].eq_ignore_ascii_case("value")
187 {
188 return vec![error(
189 "bad_setoption",
190 "expected: setoption name Evaluator value <evaluator>",
191 )];
192 }
193 match EvaluationProfile::from_str(words[3]) {
194 Ok(evaluator) => {
195 self.evaluator = evaluator;
196 self.last_report = None;
197 Vec::new()
198 }
199 Err(message) => vec![error("bad_option_value", message)],
200 }
201 }
202
203 fn set_position(&mut self, words: &[&str]) -> Vec<String> {
204 if words.first().copied() != Some("startpos") {
205 return vec![error(
206 "bad_position",
207 "Othello supports: position startpos [moves ...]",
208 )];
209 }
210 let move_words = match words.get(1) {
211 None => &[][..],
212 Some(&"moves") => &words[2..],
213 Some(_) => {
214 return vec![error("bad_position", "expected 'moves' after 'startpos'")];
215 }
216 };
217 let mut position = Position::start();
218 let mut history = Vec::with_capacity(move_words.len());
219 let mut last_move = None;
220 let mut last_flips = 0;
221 for word in move_words {
222 let mv = match Move::from_str(word) {
223 Ok(mv) => mv,
224 Err(message) => return vec![error("bad_move", message)],
225 };
226 match position.make_move(mv) {
227 Ok(undo) => {
228 history.push(mv);
229 last_move = Some(mv);
230 last_flips = undo.flipped();
231 }
232 Err(move_error) => {
233 return vec![error("illegal_move", &move_error.to_string())];
234 }
235 }
236 }
237 self.position = position;
238 self.history = history;
239 self.last_move = last_move;
240 self.last_flips = last_flips;
241 self.last_report = None;
242 Vec::new()
243 }
244
245 fn go(&mut self, words: &[&str]) -> Vec<String> {
246 let Ok(config) = parse_config(words, self.evaluator) else {
247 return vec![error(
248 "bad_go",
249 "expected: go depth <0..16> [endgame <0..16>]",
250 )];
251 };
252 if self.position.result() != GameResult::Ongoing {
253 return vec![
254 "info string game is already over".to_owned(),
255 "bestmove (none)".to_owned(),
256 ];
257 }
258
259 #[cfg(target_arch = "wasm32")]
260 let started = js_sys::Date::now();
261 #[cfg(not(target_arch = "wasm32"))]
262 let started = Instant::now();
263
264 let report = search(self.position, config);
265
266 #[cfg(target_arch = "wasm32")]
267 let elapsed_millis = (js_sys::Date::now() - started).max(0.0) as u128;
268 #[cfg(not(target_arch = "wasm32"))]
269 let elapsed_millis = started.elapsed().as_millis();
270
271 let nps = u128::from(report.stats.nodes) * 1_000 / elapsed_millis.max(1);
272 let pv = report
273 .principal_variation
274 .iter()
275 .map(ToString::to_string)
276 .collect::<Vec<_>>()
277 .join(" ");
278 let best_move = report
279 .best_move
280 .map_or_else(|| "(none)".to_owned(), |mv| mv.to_string());
281 let info = format!(
282 "info depth {} {} nodes {} nps {} time {} cutoffs {} passes {} exactnodes {} exact {} pv {}",
283 config.depth,
284 score_protocol(report.score, report.exact),
285 report.stats.nodes,
286 nps,
287 elapsed_millis,
288 report.stats.cutoffs,
289 report.stats.passes,
290 report.stats.exact_nodes,
291 report.exact,
292 pv
293 );
294 self.last_report = Some(report);
295 vec![info, format!("bestmove {best_move}")]
296 }
297
298 fn run_perft(&self, words: &[&str]) -> Vec<String> {
299 if words.len() != 1 {
300 return vec![error("bad_perft", "expected: perft <depth 0..12>")];
301 }
302 let Ok(depth) = words[0].parse::<u8>() else {
303 return vec![error("bad_perft", "depth must be an integer")];
304 };
305 if depth > 12 {
306 return vec![error("bad_perft", "depth must be between 0 and 12")];
307 }
308 let mut position = self.position;
309 #[cfg(target_arch = "wasm32")]
310 let started = js_sys::Date::now();
311 #[cfg(not(target_arch = "wasm32"))]
312 let started = Instant::now();
313 let nodes = perft(&mut position, depth);
314 #[cfg(target_arch = "wasm32")]
315 let elapsed_millis = (js_sys::Date::now() - started).max(0.0) as u128;
316 #[cfg(not(target_arch = "wasm32"))]
317 let elapsed_millis = started.elapsed().as_millis();
318 vec![format!(
319 "perft depth {depth} nodes {nodes} time {elapsed_millis}"
320 )]
321 }
322}
323
324fn parse_config(words: &[&str], evaluator: EvaluationProfile) -> Result<SearchConfig, ()> {
325 if words.len() != 2 && words.len() != 4 {
326 return Err(());
327 }
328 let mut depth = None;
329 let mut endgame = None;
330 for pair in words.chunks_exact(2) {
331 match pair {
332 ["depth", value] => {
333 let value = value.parse::<u8>().map_err(|_| ())?;
334 if value > 16 {
335 return Err(());
336 }
337 if depth.replace(value).is_some() {
338 return Err(());
339 }
340 }
341 ["endgame", value] => {
342 let value = value.parse::<u8>().map_err(|_| ())?;
343 if value > 16 {
344 return Err(());
345 }
346 if endgame.replace(value).is_some() {
347 return Err(());
348 }
349 }
350 _ => return Err(()),
351 }
352 }
353 Ok(SearchConfig {
354 depth: depth.ok_or(())?,
355 evaluator,
356 exact_endgame_empties: endgame.unwrap_or(0),
357 })
358}
359
360fn evaluation_info(evaluation: Evaluation) -> String {
361 format!(
362 "evaluation profile {} total {} phase {} material {} mobility {} potential {} corners {} danger {} frontier {}",
363 evaluation.profile,
364 evaluation.total,
365 evaluation.phase,
366 evaluation.material,
367 evaluation.mobility,
368 evaluation.potential_mobility,
369 evaluation.corners,
370 evaluation.corner_danger,
371 evaluation.frontier
372 )
373}
374
375fn score_protocol(score: Score, exact: bool) -> String {
376 match score.kind() {
377 ScoreKind::Win { margin } => format!("score win {margin}"),
378 ScoreKind::Loss { margin } => format!("score loss {margin}"),
379 ScoreKind::Draw => "score draw 0".to_owned(),
380 ScoreKind::Estimate(0) if exact => "score draw 0".to_owned(),
381 ScoreKind::Estimate(value) => format!("score eval {value}"),
382 }
383}
384
385fn write_moves(json: &mut String, moves: &[Move]) {
386 for (index, mv) in moves.iter().enumerate() {
387 if index > 0 {
388 json.push(',');
389 }
390 write!(json, "\"{mv}\"").expect("writing to String cannot fail");
391 }
392}
393
394fn write_squares(json: &mut String, mut bits: u64) {
395 let mut index = 0;
396 while bits != 0 {
397 if index > 0 {
398 json.push(',');
399 }
400 let square = Square::new(bits.trailing_zeros() as u8);
401 write!(json, "\"{square}\"").expect("writing to String cannot fail");
402 bits &= bits - 1;
403 index += 1;
404 }
405}
406
407fn write_evaluation_json(json: &mut String, evaluation: Evaluation) {
408 write!(
409 json,
410 "{{\"profile\":\"{}\",\"phase\":{},\"total\":{},\"terms\":{{\"material\":{},\"mobility\":{},\"potentialMobility\":{},\"corners\":{},\"cornerDanger\":{},\"frontier\":{}}},\"weights\":{{\"material\":{},\"mobility\":{},\"potentialMobility\":{},\"corners\":{},\"cornerDanger\":{},\"frontier\":{}}}}}",
411 evaluation.profile,
412 evaluation.phase,
413 evaluation.total,
414 evaluation.material,
415 evaluation.mobility,
416 evaluation.potential_mobility,
417 evaluation.corners,
418 evaluation.corner_danger,
419 evaluation.frontier,
420 evaluation.weights.material,
421 evaluation.weights.mobility,
422 evaluation.weights.potential_mobility,
423 evaluation.weights.corners,
424 evaluation.weights.corner_danger,
425 evaluation.weights.frontier
426 )
427 .expect("writing to String cannot fail");
428}
429
430fn write_report_json(json: &mut String, report: &SearchReport) {
431 write!(
432 json,
433 "{{\"depth\":{},\"evaluator\":\"{}\",\"exact\":{},\"bestMove\":",
434 report.config.depth, report.config.evaluator, report.exact
435 )
436 .expect("writing to String cannot fail");
437 match report.best_move {
438 Some(mv) => write!(json, "\"{mv}\"").expect("writing to String cannot fail"),
439 None => json.push_str("null"),
440 }
441 json.push_str(",\"score\":");
442 write_score_json(json, report.score, report.exact);
443 json.push_str(",\"pv\":[");
444 write_moves(json, &report.principal_variation);
445 write!(
446 json,
447 "],\"stats\":{{\"nodes\":{},\"leaves\":{},\"cutoffs\":{},\"passes\":{},\"exactNodes\":{},\"maxPly\":{}}},\"candidates\":[",
448 report.stats.nodes,
449 report.stats.leaves,
450 report.stats.cutoffs,
451 report.stats.passes,
452 report.stats.exact_nodes,
453 report.stats.max_ply
454 )
455 .expect("writing to String cannot fail");
456 for (index, candidate) in report.candidates.iter().enumerate() {
457 if index > 0 {
458 json.push(',');
459 }
460 write!(
461 json,
462 "{{\"move\":\"{}\",\"nodes\":{},\"cutoffs\":{},\"flipped\":[",
463 candidate.mv, candidate.nodes, candidate.cutoffs
464 )
465 .expect("writing to String cannot fail");
466 write_squares(json, candidate.flipped);
467 json.push_str("],\"score\":");
468 write_score_json(json, candidate.score, report.exact);
469 json.push('}');
470 }
471 json.push_str("]}");
472}
473
474fn write_score_json(json: &mut String, score: Score, exact: bool) {
475 match score.kind() {
476 ScoreKind::Win { margin } => {
477 write!(json, "{{\"kind\":\"win\",\"margin\":{margin}}}")
478 .expect("writing to String cannot fail");
479 }
480 ScoreKind::Loss { margin } => {
481 write!(json, "{{\"kind\":\"loss\",\"margin\":{margin}}}")
482 .expect("writing to String cannot fail");
483 }
484 ScoreKind::Draw | ScoreKind::Estimate(0) if exact => {
485 json.push_str("{\"kind\":\"draw\"}");
486 }
487 ScoreKind::Estimate(value) => {
488 write!(json, "{{\"kind\":\"estimate\",\"value\":{value}}}")
489 .expect("writing to String cannot fail");
490 }
491 ScoreKind::Draw => json.push_str("{\"kind\":\"draw\"}"),
492 }
493}
494
495fn result_fields(position: Position) -> (&'static str, &'static str, u8, u8) {
496 let black = position.disc_count(Side::Black);
497 let white = position.disc_count(Side::White);
498 match position.result() {
499 GameResult::Ongoing => ("ongoing", "null", black, white),
500 GameResult::Draw { .. } => ("draw", "null", black, white),
501 GameResult::Win {
502 winner: Side::Black,
503 ..
504 } => ("win", "\"B\"", black, white),
505 GameResult::Win {
506 winner: Side::White,
507 ..
508 } => ("win", "\"W\"", black, white),
509 }
510}
511
512fn error(code: &str, message: &str) -> String {
513 format!("error code {code} message {}", message.replace('\n', " "))
514}