1use std::f64::consts::SQRT_2;
4
5use turnbase::{Game, PlayerId, Prng, sample_chance};
6
7use crate::{Bot, RankedBot};
8
9pub struct Mcts {
23 iterations: u32,
24 exploration: f64,
25 rng: Prng,
26}
27
28impl Mcts {
29 #[must_use]
32 pub const fn new(iterations: u32, seed: u64) -> Self {
33 Self {
34 iterations,
35 exploration: SQRT_2,
36 rng: Prng::new(seed),
37 }
38 }
39
40 #[must_use]
42 pub const fn with_exploration(mut self, exploration: f64) -> Self {
43 self.exploration = exploration;
44 self
45 }
46
47 pub fn evaluate<G>(&mut self, game: &G, state: &G::State, player: PlayerId) -> f64
51 where
52 G: Game,
53 G::State: Clone,
54 G::Action: Clone,
55 {
56 let tree = self.run(game, state, player);
57 tree[0].mean()
58 }
59
60 fn run<G>(&mut self, game: &G, root_state: &G::State, root: PlayerId) -> Vec<Node<G::Action>>
61 where
62 G: Game,
63 G::State: Clone,
64 G::Action: Clone,
65 {
66 let mut nodes = vec![make_node(game, root_state)];
67 for _ in 0..self.iterations {
68 let mut state = root_state.clone();
69 let mut path = vec![0usize];
70 let mut current = 0usize;
71
72 loop {
73 if nodes[current].terminal {
74 break;
75 }
76 if nodes[current].chance {
77 let Some(action) = sample_chance(game, &state, &mut self.rng) else {
78 break;
79 };
80 game.apply(&mut state, PlayerId::CHANCE, action.clone());
81 current = child_for(&mut nodes, current, &action, game, &state);
82 path.push(current);
83 continue;
84 }
85 if let Some(action) = nodes[current].untried.pop() {
86 let mover = nodes[current].to_move;
87 game.apply(&mut state, mover, action.clone());
88 nodes.push(make_node(game, &state));
89 let child = nodes.len() - 1;
90 nodes[current].children.push((action, child));
91 path.push(child);
92 break;
93 }
94 let mover = nodes[current].to_move;
95 let (action, child) = self.select(&nodes, current, mover == root);
96 game.apply(&mut state, mover, action);
97 current = child;
98 path.push(current);
99 }
100
101 let value = self.rollout(game, state, root);
102 for &id in &path {
103 nodes[id].visits += 1;
104 nodes[id].value += value;
105 }
106 }
107 nodes
108 }
109
110 fn select<A: Clone>(&self, nodes: &[Node<A>], node: usize, maximizing: bool) -> (A, usize) {
114 let parent_visits = f64::from(nodes[node].visits);
115 let sign = if maximizing { 1.0 } else { -1.0 };
116 let mut best = None;
117 let mut best_score = f64::NEG_INFINITY;
118 for (action, id) in &nodes[node].children {
119 let child = &nodes[*id];
120 let exploit = sign * child.mean();
121 let explore = self.exploration * (parent_visits.ln() / f64::from(child.visits)).sqrt();
122 let score = exploit + explore;
123 if score > best_score {
124 best_score = score;
125 best = Some((action.clone(), *id));
126 }
127 }
128 best.expect("a fully expanded node has children")
129 }
130
131 fn rollout<G>(&mut self, game: &G, mut state: G::State, root: PlayerId) -> f64
132 where
133 G: Game,
134 {
135 while !game.is_terminal(&state) {
136 let Some(actor) = game.active_players(&state).iter().next() else {
137 break;
138 };
139 if actor.is_chance() {
140 let Some(action) = sample_chance(game, &state, &mut self.rng) else {
141 break;
142 };
143 game.apply(&mut state, PlayerId::CHANCE, action);
144 } else {
145 let mut actions = game.legal_actions(&state, actor);
146 if actions.is_empty() {
147 break;
148 }
149 #[allow(clippy::cast_possible_truncation)]
151 let index = self.rng.below(actions.len() as u64) as usize;
152 game.apply(&mut state, actor, actions.swap_remove(index));
153 }
154 }
155 game.reward(&state, root)
156 }
157}
158
159struct Node<A> {
162 to_move: PlayerId,
163 chance: bool,
164 terminal: bool,
165 visits: u32,
166 value: f64,
167 untried: Vec<A>,
168 children: Vec<(A, usize)>,
169}
170
171impl<A> Node<A> {
172 fn mean(&self) -> f64 {
173 if self.visits == 0 {
174 0.0
175 } else {
176 self.value / f64::from(self.visits)
177 }
178 }
179}
180
181fn make_node<G>(game: &G, state: &G::State) -> Node<G::Action>
182where
183 G: Game,
184{
185 let mut node = Node {
186 to_move: PlayerId::CHANCE,
187 chance: false,
188 terminal: false,
189 visits: 0,
190 value: 0.0,
191 untried: Vec::new(),
192 children: Vec::new(),
193 };
194 let actor = game.active_players(state).iter().next();
195 match actor {
196 None => node.terminal = true,
197 Some(player) if player.is_chance() => {
198 node.to_move = PlayerId::CHANCE;
199 node.chance = true;
200 }
201 Some(player) => {
202 node.to_move = player;
203 node.untried = game.legal_actions(state, player);
204 }
205 }
206 node
207}
208
209fn child_for<G>(
211 nodes: &mut Vec<Node<G::Action>>,
212 parent: usize,
213 action: &G::Action,
214 game: &G,
215 state: &G::State,
216) -> usize
217where
218 G: Game,
219 G::Action: Clone,
220{
221 if let Some((_, id)) = nodes[parent].children.iter().find(|(a, _)| a == action) {
222 return *id;
223 }
224 nodes.push(make_node(game, state));
225 let id = nodes.len() - 1;
226 nodes[parent].children.push((action.clone(), id));
227 id
228}
229
230impl<G> Bot<G> for Mcts
231where
232 G: Game,
233 G::State: Clone,
234 G::Action: Clone,
235{
236 fn choose(&mut self, game: &G, state: &G::State, player: PlayerId) -> Option<G::Action> {
237 if game.legal_actions(state, player).is_empty() {
238 return None;
239 }
240 let tree = self.run(game, state, player);
241 tree[0]
242 .children
243 .iter()
244 .max_by_key(|(_, id)| tree[*id].visits)
245 .map(|(action, _)| action.clone())
246 }
247}
248
249impl<G> RankedBot<G> for Mcts
250where
251 G: Game,
252 G::State: Clone,
253 G::Action: Clone,
254{
255 fn rank(&mut self, game: &G, state: &G::State, player: PlayerId) -> Vec<(G::Action, f64)> {
258 let tree = self.run(game, state, player);
259 let total = f64::from(tree[0].visits.max(1));
260 let mut ranked: Vec<(G::Action, f64)> = tree[0]
261 .children
262 .iter()
263 .map(|(action, id)| (action.clone(), f64::from(tree[*id].visits) / total))
264 .collect();
265 ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
266 ranked
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::Mcts;
273 use crate::{Bot, Random, RankedBot};
274 use high_card::HighCard;
275 use tic_tac_toe::{Move, TicTacToe};
276 use turnbase::{Game, PlayerId};
277
278 const P0: PlayerId = PlayerId::new(0);
279
280 fn run_match<X: Bot<TicTacToe>, O: Bot<TicTacToe>>(
281 x: &mut X,
282 o: &mut O,
283 ) -> <TicTacToe as Game>::State {
284 let game = TicTacToe;
285 let mut state = game.new_initial_state(0);
286 while !game.is_terminal(&state) {
287 let player = game.active_players(&state).iter().next().unwrap();
288 let action = if player.index() == 0 {
289 x.choose(&game, &state, player)
290 } else {
291 o.choose(&game, &state, player)
292 }
293 .unwrap();
294 game.apply(&mut state, player, action);
295 }
296 state
297 }
298
299 #[test]
300 fn mcts_does_not_lose_to_random() {
301 let game = TicTacToe;
302 for seed in 0..6 {
303 let mut x = Mcts::new(2000, seed);
304 let mut o = Random::new(seed + 100);
305 let end = run_match(&mut x, &mut o);
306 assert!(
307 game.reward(&end, P0) >= 0.0,
308 "MCTS X lost to random O (seed {seed})"
309 );
310 }
311 }
312
313 #[test]
314 fn mcts_takes_an_immediate_win() {
315 let game = TicTacToe;
317 let mut state = game.new_initial_state(0);
318 for (seat, cell) in [(0u32, 0u8), (1, 3), (0, 1), (1, 4)] {
319 game.apply(&mut state, PlayerId::new(seat), Move(cell));
320 }
321 let mut mcts = Mcts::new(3000, 1);
322 assert_eq!(mcts.choose(&game, &state, P0), Some(Move(2)));
323 }
324
325 #[test]
326 fn rank_is_a_probability_distribution() {
327 let game = TicTacToe;
328 let state = game.new_initial_state(0);
329 let mut mcts = Mcts::new(1500, 7);
330 let ranked = mcts.rank(&game, &state, P0);
331 assert_eq!(ranked.len(), game.legal_actions(&state, P0).len());
332 let sum: f64 = ranked.iter().map(|(_, p)| p).sum();
333 assert!((sum - 1.0).abs() < 1e-9, "visit shares sum to 1");
334 assert!(
335 ranked.windows(2).all(|w| w[0].1 >= w[1].1),
336 "sorted best-first"
337 );
338 }
339
340 #[test]
341 fn high_card_is_evaluated_as_fair() {
342 let game = HighCard::default();
345 let state = game.new_initial_state(0);
346 let mut mcts = Mcts::new(20_000, 3);
347 let value = mcts.evaluate(&game, &state, P0);
348 assert!(value.abs() < 0.1, "high card should be ~fair, got {value}");
349 }
350}