Skip to main content

ai_othello/
protocol.rs

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
10/// One line-oriented engine session shared by the browser and native binary.
11pub 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}