Skip to main content

ai_othello/
cassio.rs

1//! Native adapter for the useful core of Cassio's `ENGINE-PROTOCOL`.
2//!
3//! Cassio owns iterative deepening and time management. This adapter accepts
4//! one explicit depth per request and deliberately keeps that wire contract
5//! outside the game, evaluation, and search modules.
6
7use 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)]
15/// Line-oriented adapter for Cassio's `ENGINE-PROTOCOL`.
16pub 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    /// Executes one command while polling `should_stop` during search.
28    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, &parameters, vec!["ready.".to_owned()])
43            }
44            "get-version" => without_parameters(
45                command,
46                &parameters,
47                vec!["version: AI Othello 0.1.0".to_owned(), "ready.".to_owned()],
48            ),
49            "midgame-search" => self.midgame_search(&parameters, should_stop),
50            "endgame-search" => self.endgame_search(&parameters, should_stop),
51            "feed-hash" => unsupported("feed-hash"),
52            "get-search-infos" => unsupported("live search polling"),
53            "quit" | "eof" => without_parameters(command, &parameters, 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}