1use crate::bitboard::Bitboard;
2use crate::game::{check_winner, current_player, WinStatus};
3use crate::moves::{apply_move, generate_legal_moves, Move};
4use crate::state::State;
5use rand::prelude::*;
6use std::collections::HashMap;
7use std::time::Instant;
8
9pub struct MCTSConfig {
10 pub exploration_weight: f64,
11 pub max_iterations: u32,
12 pub max_depth: u32,
13 pub seed: Option<u64>,
14 pub time_limit_s: Option<f64>,
18 pub use_transposition_table: bool,
22}
23
24impl Default for MCTSConfig {
25 fn default() -> Self {
26 Self {
27 exploration_weight: std::f64::consts::SQRT_2,
28 max_iterations: 10_000,
29 max_depth: 16,
30 seed: None,
31 time_limit_s: None,
32 use_transposition_table: true,
33 }
34 }
35}
36
37struct MCTSNode {
38 bb: Bitboard,
39 children: Vec<usize>,
40 mv: Option<Move>, visit_count: u32,
42 win_count_p0: u32,
43 win_count_p1: u32,
44 untried_moves: Vec<Move>,
45 is_terminal: bool,
46 terminal_value: f64, }
48
49pub struct MCTSEngine {
50 config: MCTSConfig,
51 nodes: Vec<MCTSNode>,
52 transpositions: HashMap<[u8; 18], usize>,
53 rng: StdRng,
54 iterations_performed: u32,
55}
56
57impl MCTSEngine {
58 pub fn new(config: MCTSConfig) -> Self {
59 if let Some(limit) = config.time_limit_s {
60 assert!(
61 limit > 0.0 && limit.is_finite(),
62 "time_limit_s must be positive and finite, got {limit}"
63 );
64 }
65 let rng = match config.seed {
66 Some(s) => StdRng::seed_from_u64(s),
67 None => StdRng::from_entropy(),
68 };
69 Self {
70 config,
71 nodes: Vec::new(),
72 transpositions: HashMap::new(),
73 rng,
74 iterations_performed: 0,
75 }
76 }
77
78 pub fn search(&mut self, bb: &Bitboard) -> Option<(Move, f64)> {
81 self.nodes.clear();
82 self.transpositions.clear();
83 self.iterations_performed = 0;
84
85 let legal = generate_legal_moves(bb);
86 if legal.is_empty() {
87 return None;
88 }
89
90 let terminal = check_winner(bb);
91 let is_terminal = terminal != WinStatus::NoWin;
92 let terminal_value = match terminal {
93 WinStatus::Player0Wins => 1.0,
94 WinStatus::Player1Wins => -1.0,
95 WinStatus::NoWin => 0.0,
96 };
97
98 self.nodes.push(MCTSNode {
99 bb: *bb,
100 children: Vec::new(),
101 mv: None,
102 visit_count: 0,
103 win_count_p0: 0,
104 win_count_p1: 0,
105 untried_moves: legal,
106 is_terminal,
107 terminal_value,
108 });
109
110 let deadline = self
111 .config
112 .time_limit_s
113 .map(|s| Instant::now() + std::time::Duration::from_secs_f64(s));
114
115 for _ in 0..self.config.max_iterations {
116 let mut path = self.select(0);
117 let leaf = *path.last().expect("path always contains the root");
118 let expanded = self.expand(leaf);
119 if expanded != leaf {
120 path.push(expanded);
121 }
122 let value = self.simulate(expanded);
123 self.backpropagate(&path, value);
124 self.iterations_performed += 1;
125
126 if let Some(deadline) = deadline {
127 if Instant::now() >= deadline {
128 break;
129 }
130 }
131 }
132
133 self.best_move(bb)
134 }
135
136 fn select(&self, node_id: usize) -> Vec<usize> {
141 let mut path = vec![node_id];
142 let mut current = node_id;
143 loop {
144 let node = &self.nodes[current];
145 if node.is_terminal || !node.untried_moves.is_empty() || node.children.is_empty() {
146 return path;
147 }
148 let parent_visits = node.visit_count as f64;
149 let c = self.config.exploration_weight;
150 let mover = current_player(&node.bb).unwrap_or(0);
156
157 let mut best_ucb = f64::NEG_INFINITY;
158 let mut best_child = node.children[0];
159 for &child_id in &node.children {
160 let child = &self.nodes[child_id];
161 if child.visit_count == 0 {
162 best_child = child_id;
163 break;
164 }
165 let child_visits = child.visit_count as f64;
166 let wins = if mover == 0 {
167 child.win_count_p0 as f64
168 } else {
169 child.win_count_p1 as f64
170 };
171 let win_rate = wins / child_visits;
172 let ucb = win_rate + c * (parent_visits.ln() / child_visits).sqrt();
173 if ucb > best_ucb {
174 best_ucb = ucb;
175 best_child = child_id;
176 }
177 }
178 path.push(best_child);
179 current = best_child;
180 }
181 }
182
183 fn expand(&mut self, node_id: usize) -> usize {
184 if self.nodes[node_id].is_terminal || self.nodes[node_id].untried_moves.is_empty() {
185 return node_id;
186 }
187
188 let idx = self
189 .rng
190 .gen_range(0..self.nodes[node_id].untried_moves.len());
191 let mv = self.nodes[node_id].untried_moves.swap_remove(idx);
192 let parent_bb = self.nodes[node_id].bb;
193 let new_bb = apply_move(&parent_bb, &mv);
194
195 if self.config.use_transposition_table {
196 let key = State::new(new_bb).canonical_key();
197 if let Some(&existing) = self.transpositions.get(&key) {
198 if !self.nodes[node_id].children.contains(&existing) {
199 self.nodes[node_id].children.push(existing);
200 }
201 return existing;
202 }
203 }
204
205 let legal = generate_legal_moves(&new_bb);
206 let terminal = check_winner(&new_bb);
207 let is_terminal = terminal != WinStatus::NoWin || legal.is_empty();
208 let terminal_value = match terminal {
209 WinStatus::Player0Wins => 1.0,
210 WinStatus::Player1Wins => -1.0,
211 WinStatus::NoWin if legal.is_empty() => {
212 if current_player(&new_bb) == Some(0) {
214 -1.0
215 } else {
216 1.0
217 }
218 }
219 WinStatus::NoWin => 0.0,
220 };
221
222 let child_id = self.nodes.len();
223 self.nodes.push(MCTSNode {
224 bb: new_bb,
225 children: Vec::new(),
226 mv: Some(mv),
227 visit_count: 0,
228 win_count_p0: 0,
229 win_count_p1: 0,
230 untried_moves: legal,
231 is_terminal,
232 terminal_value,
233 });
234 if self.config.use_transposition_table {
235 self.transpositions
236 .insert(State::new(new_bb).canonical_key(), child_id);
237 }
238
239 self.nodes[node_id].children.push(child_id);
240 child_id
241 }
242
243 fn simulate(&mut self, node_id: usize) -> f64 {
244 let node = &self.nodes[node_id];
245 if node.is_terminal {
246 return node.terminal_value;
247 }
248
249 let mut current_bb = node.bb;
250 let mut depth = 0u32;
251
252 loop {
253 if depth >= self.config.max_depth {
254 return 0.0;
255 }
256 let w = check_winner(¤t_bb);
257 if w != WinStatus::NoWin {
258 return match w {
259 WinStatus::Player0Wins => 1.0,
260 WinStatus::Player1Wins => -1.0,
261 WinStatus::NoWin => unreachable!(),
262 };
263 }
264 let moves = generate_legal_moves(¤t_bb);
265 if moves.is_empty() {
266 return if current_player(¤t_bb) == Some(0) {
268 -1.0
269 } else {
270 1.0
271 };
272 }
273 let mv = moves[self.rng.gen_range(0..moves.len())];
274 current_bb = apply_move(¤t_bb, &mv);
275 depth += 1;
276 }
277 }
278
279 fn backpropagate(&mut self, path: &[usize], value: f64) {
280 for &node_id in path.iter().rev() {
281 let node = &mut self.nodes[node_id];
282 node.visit_count += 1;
283 if value > 0.0 {
284 node.win_count_p0 += 1;
285 } else if value < 0.0 {
286 node.win_count_p1 += 1;
287 }
288 }
289 }
290
291 fn best_move(&self, root_bb: &Bitboard) -> Option<(Move, f64)> {
292 let root = &self.nodes[0];
293 if root.children.is_empty() {
294 return None;
295 }
296
297 let mut best_visits = 0u32;
298 let mut best_child = root.children[0];
299 for &child_id in &root.children {
300 let child = &self.nodes[child_id];
301 if child.visit_count > best_visits {
302 best_visits = child.visit_count;
303 best_child = child_id;
304 }
305 }
306
307 let child = &self.nodes[best_child];
308 let mover = current_player(root_bb).unwrap_or(0);
311 let win_rate = if child.visit_count > 0 {
312 let wins = if mover == 0 {
313 child.win_count_p0 as f64
314 } else {
315 child.win_count_p1 as f64
316 };
317 wins / child.visit_count as f64
318 } else {
319 0.5
320 };
321
322 child.mv.map(|mv| (mv, win_rate))
323 }
324
325 pub fn iterations_performed(&self) -> u32 {
326 self.iterations_performed
327 }
328
329 pub fn nodes_created(&self) -> usize {
330 self.nodes.len()
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use crate::game::has_winning_line;
338
339 #[test]
340 fn mcts_returns_a_move() {
341 let mut engine = MCTSEngine::new(MCTSConfig {
342 max_iterations: 100,
343 seed: Some(42),
344 ..Default::default()
345 });
346 let result = engine.search(&Bitboard::EMPTY);
347 assert!(result.is_some());
348 let (mv, prob) = result.unwrap();
349 assert_eq!(mv.player, 0);
350 assert!(mv.shape < 4);
351 assert!(mv.position < 16);
352 assert!((0.0..=1.0).contains(&prob));
353 }
354
355 #[test]
356 fn mcts_finds_winning_move() {
357 let bb = Bitboard::EMPTY
358 .with_move(0, 0, 0)
359 .with_move(1, 1, 5)
360 .with_move(0, 2, 2);
361 let mut engine = MCTSEngine::new(MCTSConfig {
362 max_iterations: 500,
363 seed: Some(123),
364 ..Default::default()
365 });
366 let result = engine.search(&bb);
367 assert!(result.is_some());
368 }
369
370 #[test]
371 fn mcts_no_moves_returns_none() {
372 let bb = Bitboard::EMPTY
374 .with_move(0, 0, 0)
375 .with_move(1, 1, 1)
376 .with_move(0, 2, 2)
377 .with_move(1, 3, 3);
378 let mut engine = MCTSEngine::new(MCTSConfig {
379 max_iterations: 10,
380 seed: Some(1),
381 ..Default::default()
382 });
383 assert!(engine.search(&bb).is_none());
386 }
387
388 #[test]
392 fn mcts_picks_immediate_win_for_player_1() {
393 let bb = Bitboard::EMPTY
395 .with_move(0, 0, 0)
396 .with_move(1, 1, 1)
397 .with_move(0, 2, 2);
398 let mut engine = MCTSEngine::new(MCTSConfig {
399 max_iterations: 3_000,
400 seed: Some(7),
401 ..Default::default()
402 });
403 let (mv, prob) = engine.search(&bb).unwrap();
404 let after = apply_move(&bb, &mv);
405 assert!(
406 has_winning_line(&after),
407 "p1 must play the immediate win, got {mv:?} (prob {prob})"
408 );
409 assert!(prob > 0.5, "win probability is for the root mover");
410 }
411
412 #[test]
413 fn time_limit_stops_early() {
414 let mut engine = MCTSEngine::new(MCTSConfig {
415 max_iterations: u32::MAX,
416 seed: Some(3),
417 time_limit_s: Some(0.05),
418 ..Default::default()
419 });
420 let start = Instant::now();
421 let result = engine.search(&Bitboard::EMPTY);
422 assert!(result.is_some());
423 assert!(start.elapsed().as_secs_f64() < 1.0);
424 assert!(engine.iterations_performed() < u32::MAX);
425 assert!(engine.iterations_performed() > 0);
426 }
427
428 #[test]
429 fn same_seed_same_move() {
430 let bb = Bitboard::EMPTY.with_move(0, 0, 0);
431 let run = |seed| {
432 let mut engine = MCTSEngine::new(MCTSConfig {
433 max_iterations: 300,
434 seed: Some(seed),
435 ..Default::default()
436 });
437 engine.search(&bb).unwrap().0
438 };
439 assert_eq!(run(11), run(11));
440 }
441
442 #[test]
443 fn transposition_table_reduces_nodes() {
444 let run = |use_tt| {
445 let mut engine = MCTSEngine::new(MCTSConfig {
446 max_iterations: 2_000,
447 seed: Some(5),
448 use_transposition_table: use_tt,
449 ..Default::default()
450 });
451 engine.search(&Bitboard::EMPTY).unwrap();
452 engine.nodes_created()
453 };
454 assert!(run(true) < run(false));
455 }
456
457 #[test]
458 #[should_panic(expected = "time_limit_s must be positive")]
459 fn invalid_time_limit_panics() {
460 MCTSEngine::new(MCTSConfig {
461 time_limit_s: Some(0.0),
462 ..Default::default()
463 });
464 }
465}