Skip to main content

ai_hex/
protocol.rs

1use crate::{
2    BoardSize, Color, GameResult, KnowledgePolicy, MctsOptions, MctsReport, MctsSearcher,
3    MctsStrategy, Move, Position, RolloutPolicy, Seat, SwapRule,
4};
5use std::fmt::Write;
6use std::str::FromStr;
7
8pub struct Engine {
9    position: Position,
10    history: Vec<Move>,
11    searcher: MctsSearcher,
12    last_decision: Option<MctsReport>,
13}
14
15impl Default for Engine {
16    fn default() -> Self {
17        Self::new()
18    }
19}
20
21impl Engine {
22    pub fn new() -> Self {
23        Self {
24            position: Position::new(BoardSize::default(), SwapRule::Enabled),
25            history: Vec::new(),
26            searcher: MctsSearcher::new(),
27            last_decision: None,
28        }
29    }
30
31    pub fn command(&mut self, line: &str) -> Vec<String> {
32        let words = line.split_whitespace().collect::<Vec<_>>();
33        let Some(command) = words.first().copied() else {
34            return vec![error("empty command")];
35        };
36        match command {
37            "gai" => vec![
38                "id name ai-hex".to_owned(),
39                "id game hex".to_owned(),
40                "gaiok".to_owned(),
41            ],
42            "isready" => vec!["readyok".to_owned()],
43            "newgame" => self.new_game(&words[1..]),
44            "position" => self.set_position(&words[1..]),
45            "mcts" => self.search(&words[1..]),
46            "legal" => vec![format!(
47                "legalmoves {}",
48                self.position
49                    .legal_moves()
50                    .iter()
51                    .map(Move::to_string)
52                    .collect::<Vec<_>>()
53                    .join(" ")
54            )],
55            "state" => vec![format!("state {}", self.snapshot_json())],
56            "quit" => Vec::new(),
57            _ => vec![error("unknown command")],
58        }
59    }
60
61    pub fn snapshot_json(&self) -> String {
62        let mut json = String::with_capacity(8_192);
63        write!(
64            json,
65            "{{\"size\":{},\"board\":[",
66            self.position.size().get()
67        )
68        .expect("writing to String cannot fail");
69        for dense in 0..self.position.size().cell_count() {
70            if dense > 0 {
71                json.push(',');
72            }
73            let cell = crate::Cell::from_dense(dense, self.position.size());
74            match self.position.color_at(cell) {
75                Some(Color::Red) => json.push_str("\"R\""),
76                Some(Color::Blue) => json.push_str("\"B\""),
77                None => json.push_str("null"),
78            }
79        }
80        write!(
81            json,
82            "],\"seatToMove\":\"{}\",\"colorToMove\":\"{}\",\"seatColors\":[\"{}\",\"{}\"],\"colorsSwapped\":{},\"swapAvailable\":{},\"result\":\"{}\",\"winnerSeat\":{},\"winnerColor\":{},",
83            self.position.seat_to_move().as_str(),
84            color_code(self.position.color_to_move()),
85            color_code(self.position.color_for_seat(Seat::One)),
86            color_code(self.position.color_for_seat(Seat::Two)),
87            self.position.colors_swapped(),
88            self.position.swap_available(),
89            result_name(self.position.result()),
90            winner_seat_json(self.position.result()),
91            winner_color_json(self.position),
92        )
93        .expect("writing to String cannot fail");
94
95        json.push_str("\"winningPath\":[");
96        for (index, cell) in self.position.winning_path().iter().enumerate() {
97            if index > 0 {
98                json.push(',');
99            }
100            write!(json, "\"{cell}\"").expect("writing to String cannot fail");
101        }
102        json.push_str("],\"legalMoves\":[");
103        for (index, mv) in self.position.legal_moves().iter().enumerate() {
104            if index > 0 {
105                json.push(',');
106            }
107            write!(json, "\"{mv}\"").expect("writing to String cannot fail");
108        }
109        json.push_str("],\"history\":[");
110        for (index, mv) in self.history.iter().enumerate() {
111            if index > 0 {
112                json.push(',');
113            }
114            write!(json, "\"{mv}\"").expect("writing to String cannot fail");
115        }
116        json.push_str("],\"lastMove\":");
117        if let Some(last) = self.history.last() {
118            write!(json, "\"{last}\"").expect("writing to String cannot fail");
119        } else {
120            json.push_str("null");
121        }
122        json.push_str(",\"decision\":");
123        match &self.last_decision {
124            Some(report) => write_report(&mut json, report),
125            None => json.push_str("null"),
126        }
127        json.push('}');
128        json
129    }
130
131    fn new_game(&mut self, words: &[&str]) -> Vec<String> {
132        match parse_setup(words) {
133            Ok((size, rule, consumed)) if consumed == words.len() => {
134                self.position = Position::new(size, rule);
135                self.history.clear();
136                self.last_decision = None;
137                Vec::new()
138            }
139            Ok(_) => vec![error("newgame supports size and swap")],
140            Err(message) => vec![error(message)],
141        }
142    }
143
144    fn set_position(&mut self, words: &[&str]) -> Vec<String> {
145        let (size, rule, consumed) = match parse_setup(words) {
146            Ok(setup) => setup,
147            Err(message) => return vec![error(message)],
148        };
149        let remaining = &words[consumed..];
150        let move_words = match remaining.first() {
151            None => &[][..],
152            Some(&"moves") => &remaining[1..],
153            Some(_) => return vec![error("expected 'moves' after position setup")],
154        };
155        let moves = match move_words
156            .iter()
157            .map(|word| Move::from_str(word))
158            .collect::<Result<Vec<_>, _>>()
159        {
160            Ok(moves) => moves,
161            Err(message) => return vec![error(&message.to_string())],
162        };
163        match Position::from_moves(size, rule, &moves) {
164            Ok(position) => {
165                self.position = position;
166                self.history = moves;
167                self.last_decision = None;
168                Vec::new()
169            }
170            Err(message) => vec![error(&message.to_string())],
171        }
172    }
173
174    fn search(&mut self, words: &[&str]) -> Vec<String> {
175        let mut options = MctsOptions::default();
176        let mut index = 0;
177        while index < words.len() {
178            let Some(value) = words.get(index + 1) else {
179                return vec![error("mcts options need a value")];
180            };
181            match words[index] {
182                "simulations" => match value.parse::<u32>() {
183                    Ok(simulations @ 1..=1_000_000) => options.max_simulations = simulations,
184                    _ => return vec![error("simulations must be from 1 through 1000000")],
185                },
186                "softtime" => match value.parse::<u32>() {
187                    Ok(time @ 0..=2_000) => options.soft_time_ms = time,
188                    _ => return vec![error("softtime must be from 0 through 2000 milliseconds")],
189                },
190                "exploration" => match value.parse::<f64>() {
191                    Ok(exploration)
192                        if exploration.is_finite() && (0.0..=4.0).contains(&exploration) =>
193                    {
194                        options.exploration = exploration;
195                    }
196                    _ => return vec![error("exploration must be from 0 through 4")],
197                },
198                "strategy" => match *value {
199                    "plain-uct" => options.strategy = MctsStrategy::PlainUct,
200                    "uct-rave" => options.strategy = MctsStrategy::UctRave,
201                    _ => return vec![error("strategy must be plain-uct or uct-rave")],
202                },
203                "rave" => match value.parse::<f64>() {
204                    Ok(equivalence)
205                        if equivalence.is_finite() && (1.0..=100_000.0).contains(&equivalence) =>
206                    {
207                        options.rave_equivalence = equivalence;
208                    }
209                    _ => return vec![error("rave must be from 1 through 100000")],
210                },
211                "rollout" => match *value {
212                    "random" => options.rollout_policy = RolloutPolicy::Random,
213                    "save-bridge" => options.rollout_policy = RolloutPolicy::SaveBridge,
214                    _ => return vec![error("rollout must be random or save-bridge")],
215                },
216                "knowledge" => match *value {
217                    "off" => options.knowledge_policy = KnowledgePolicy::Disabled,
218                    value => match value.parse::<u32>() {
219                        Ok(min_visits @ 0..=1_000_000) => {
220                            options.knowledge_policy =
221                                KnowledgePolicy::InferiorCells { min_visits };
222                        }
223                        _ => return vec![error("knowledge must be off or from 0 through 1000000")],
224                    },
225                },
226                "connections" => match *value {
227                    "on" => options.use_virtual_connections = true,
228                    "off" => options.use_virtual_connections = false,
229                    _ => return vec![error("connections must be on or off")],
230                },
231                "seed" => match value.parse::<u64>() {
232                    Ok(seed) => options.seed = seed,
233                    _ => return vec![error("seed must be an unsigned 64-bit number")],
234                },
235                _ => {
236                    return vec![error(
237                        "mcts supports simulations, softtime, exploration, strategy, rave, rollout, knowledge, connections, and seed",
238                    )];
239                }
240            }
241            index += 2;
242        }
243        let report = self.searcher.search(self.position, options);
244        let best = report
245            .best_move
246            .map_or_else(|| "none".to_owned(), |mv| mv.to_string());
247        let output = format!(
248            "bestmove {best} simulations {} nodes {} score {:.4}",
249            report.simulations, report.tree_nodes, report.expected_score,
250        );
251        self.last_decision = Some(report);
252        vec![output]
253    }
254}
255
256fn parse_setup(words: &[&str]) -> Result<(BoardSize, SwapRule, usize), &'static str> {
257    let mut size = BoardSize::default();
258    let mut swap = SwapRule::Enabled;
259    let mut index = 0;
260    while index < words.len() {
261        match words[index] {
262            "moves" => break,
263            "size" => {
264                let Some(value) = words.get(index + 1) else {
265                    return Err("size needs a value");
266                };
267                size = value
268                    .parse::<u8>()
269                    .ok()
270                    .and_then(|value| BoardSize::new(value).ok())
271                    .ok_or("size must be from 9 through 24")?;
272            }
273            "swap" => {
274                let Some(value) = words.get(index + 1) else {
275                    return Err("swap needs on or off");
276                };
277                swap = match *value {
278                    "on" => SwapRule::Enabled,
279                    "off" => SwapRule::Disabled,
280                    _ => return Err("swap must be on or off"),
281                };
282            }
283            _ => return Err("setup supports size and swap"),
284        }
285        index += 2;
286    }
287    Ok((size, swap, index))
288}
289
290fn write_report(json: &mut String, report: &MctsReport) {
291    write!(
292        json,
293        "{{\"bestMove\":{},\"algorithm\":\"uct\",\"strategy\":\"{}\",\"raveEquivalence\":{:.1},\"rolloutPolicy\":\"{}\",\"knowledgeThreshold\":{},\"virtualConnectionsEnabled\":{},\"simulations\":{},\"treeNodes\":{},\"rootVisits\":{},\"rolloutMoves\":{},\"bridgeReplies\":{},\"knowledgeNodes\":{},\"prunedMoves\":{},\"mustPlayNodes\":{},\"rootPrunedMoves\":{},\"rootMustPlayMoves\":{},\"virtualConnections\":{},\"semiConnections\":{},\"connectionSearchTruncatedNodes\":{},\"provenNodes\":{},\"solverPropagations\":{},\"provenWinner\":{},\"proofDistance\":{},\"expectedScore\":{:.4},\"elapsedMs\":{},\"rootMoves\":[",
294        report
295            .best_move
296            .map_or_else(|| "null".to_owned(), |mv| format!("\"{mv}\"")),
297        report.strategy.as_str(),
298        report.rave_equivalence,
299        report.rollout_policy.as_str(),
300        report
301            .knowledge_policy
302            .min_visits()
303            .map_or_else(|| "null".to_owned(), |visits| visits.to_string()),
304        report.virtual_connections_enabled,
305        report.simulations,
306        report.tree_nodes,
307        report.root_visits,
308        report.rollout_moves,
309        report.bridge_replies,
310        report.knowledge_nodes,
311        report.pruned_moves,
312        report.must_play_nodes,
313        report.root_pruned_moves,
314        report.root_must_play_moves,
315        report.virtual_connections,
316        report.semi_connections,
317        report.connection_search_truncated_nodes,
318        report.proven_nodes,
319        report.solver_propagations,
320        optional_seat_json(report.proven_winner),
321        report
322            .proof_distance
323            .map_or_else(|| "null".to_owned(), |distance| distance.to_string()),
324        report.expected_score,
325        report.elapsed_ms,
326    )
327    .expect("writing to String cannot fail");
328    for (index, stats) in report.root_moves.iter().enumerate() {
329        if index > 0 {
330            json.push(',');
331        }
332        write!(
333            json,
334            "{{\"move\":\"{}\",\"visits\":{},\"expectedScore\":{:.4},\"raveVisits\":{},\"raveExpectedScore\":{:.4},\"provenWinner\":{},\"proofDistance\":{}}}",
335            stats.mv,
336            stats.visits,
337            stats.expected_score,
338            stats.rave_visits,
339            stats.rave_expected_score,
340            optional_seat_json(stats.proven_winner),
341            stats
342                .proof_distance
343                .map_or_else(|| "null".to_owned(), |distance| distance.to_string()),
344        )
345        .expect("writing to String cannot fail");
346    }
347    json.push_str("]}");
348}
349
350fn result_name(result: GameResult) -> &'static str {
351    match result {
352        GameResult::Ongoing => "ongoing",
353        GameResult::Win(_) => "win",
354    }
355}
356
357fn winner_seat_json(result: GameResult) -> &'static str {
358    match result {
359        GameResult::Win(Seat::One) => "\"one\"",
360        GameResult::Win(Seat::Two) => "\"two\"",
361        GameResult::Ongoing => "null",
362    }
363}
364
365fn optional_seat_json(seat: Option<Seat>) -> &'static str {
366    match seat {
367        Some(Seat::One) => "\"one\"",
368        Some(Seat::Two) => "\"two\"",
369        None => "null",
370    }
371}
372
373fn winner_color_json(position: Position) -> &'static str {
374    match position.result() {
375        GameResult::Win(seat) => match position.color_for_seat(seat) {
376            Color::Red => "\"R\"",
377            Color::Blue => "\"B\"",
378        },
379        GameResult::Ongoing => "null",
380    }
381}
382
383const fn color_code(color: Color) -> &'static str {
384    match color {
385        Color::Red => "R",
386        Color::Blue => "B",
387    }
388}
389
390fn error(message: &str) -> String {
391    format!("info string error {message}")
392}