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}