Skip to main content

turnbase_bots/
mcts.rs

1//! Monte Carlo tree search (UCT) with chance-node support.
2
3use std::f64::consts::SQRT_2;
4
5use turnbase::{Game, PlayerId, Prng, sample_chance};
6
7use crate::{Bot, RankedBot};
8
9/// UCT Monte Carlo tree search for sequential games, with chance nodes.
10///
11/// Assumes exactly one active player per decision node (like [`Minimax`]) and
12/// two-player zero-sum outcomes. All node values are kept from a fixed root
13/// player's perspective: the root maximizes, the opponent minimizes (a sign
14/// flip in selection), and chance nodes average their sampled children, which
15/// is exactly the expectiminimax behavior. Chance nodes are descended by
16/// sampling [`Game::chance_outcomes`], never by UCT.
17///
18/// Rollouts are uniform-random. Randomness (rollouts and chance sampling) comes
19/// from an internal seeded generator, so a run is reproducible.
20///
21/// [`Minimax`]: crate::Minimax
22pub struct Mcts {
23    iterations: u32,
24    exploration: f64,
25    rng: Prng,
26}
27
28impl Mcts {
29    /// Creates a search running `iterations` simulations per move, seeded from
30    /// `seed`. Uses the standard UCT exploration constant (sqrt 2).
31    #[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    /// Sets the UCT exploration constant (higher explores more).
41    #[must_use]
42    pub const fn with_exploration(mut self, exploration: f64) -> Self {
43        self.exploration = exploration;
44        self
45    }
46
47    /// Estimates the value of `state` for `player` as the mean rollout reward,
48    /// searching from `player`'s perspective. Works even when the root is a
49    /// chance node (no decision to make), unlike [`Bot::choose`].
50    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    /// UCT: pick the child maximizing exploitation + exploration. Exploitation
111    /// is the child's mean from the mover's perspective, so it is negated when
112    /// the mover is the opponent (who minimizes the root's value).
113    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                // Index is strictly below len, so the cast cannot truncate.
150                #[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
159/// One search-tree node. State is not stored; it is replayed from the root each
160/// iteration by cloning and applying actions along the path.
161struct 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
209/// Finds or creates the chance child reached by `action`.
210fn 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    /// Ranks root actions by visit share (the MCTS-recommended policy). Scores
256    /// are the fraction of simulations spent on each move and sum to 1.
257    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        // X at 0,1 with 2 open -> winning move; O at 3,4.
316        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        // Only chance acts; each seat is equally likely to draw higher, so the
343        // expected reward is ~0. This exercises descending through chance nodes.
344        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}