use std::f64::consts::SQRT_2;
use turnbase::{Game, PlayerId, Prng, sample_chance};
use crate::{Bot, RankedBot};
pub struct Mcts {
iterations: u32,
exploration: f64,
rng: Prng,
}
impl Mcts {
#[must_use]
pub const fn new(iterations: u32, seed: u64) -> Self {
Self {
iterations,
exploration: SQRT_2,
rng: Prng::new(seed),
}
}
#[must_use]
pub const fn with_exploration(mut self, exploration: f64) -> Self {
self.exploration = exploration;
self
}
pub fn evaluate<G>(&mut self, game: &G, state: &G::State, player: PlayerId) -> f64
where
G: Game,
G::State: Clone,
G::Action: Clone,
{
let tree = self.run(game, state, player);
tree[0].mean()
}
fn run<G>(&mut self, game: &G, root_state: &G::State, root: PlayerId) -> Vec<Node<G::Action>>
where
G: Game,
G::State: Clone,
G::Action: Clone,
{
let mut nodes = vec![make_node(game, root_state)];
for _ in 0..self.iterations {
let mut state = root_state.clone();
let mut path = vec![0usize];
let mut current = 0usize;
loop {
if nodes[current].terminal {
break;
}
if nodes[current].chance {
let Some(action) = sample_chance(game, &state, &mut self.rng) else {
break;
};
game.apply(&mut state, PlayerId::CHANCE, action.clone());
current = child_for(&mut nodes, current, &action, game, &state);
path.push(current);
continue;
}
if let Some(action) = nodes[current].untried.pop() {
let mover = nodes[current].to_move;
game.apply(&mut state, mover, action.clone());
nodes.push(make_node(game, &state));
let child = nodes.len() - 1;
nodes[current].children.push((action, child));
path.push(child);
break;
}
let mover = nodes[current].to_move;
let (action, child) = self.select(&nodes, current, mover == root);
game.apply(&mut state, mover, action);
current = child;
path.push(current);
}
let value = self.rollout(game, state, root);
for &id in &path {
nodes[id].visits += 1;
nodes[id].value += value;
}
}
nodes
}
fn select<A: Clone>(&self, nodes: &[Node<A>], node: usize, maximizing: bool) -> (A, usize) {
let parent_visits = f64::from(nodes[node].visits);
let sign = if maximizing { 1.0 } else { -1.0 };
let mut best = None;
let mut best_score = f64::NEG_INFINITY;
for (action, id) in &nodes[node].children {
let child = &nodes[*id];
let exploit = sign * child.mean();
let explore = self.exploration * (parent_visits.ln() / f64::from(child.visits)).sqrt();
let score = exploit + explore;
if score > best_score {
best_score = score;
best = Some((action.clone(), *id));
}
}
best.expect("a fully expanded node has children")
}
fn rollout<G>(&mut self, game: &G, mut state: G::State, root: PlayerId) -> f64
where
G: Game,
{
while !game.is_terminal(&state) {
let Some(actor) = game.active_players(&state).iter().next() else {
break;
};
if actor.is_chance() {
let Some(action) = sample_chance(game, &state, &mut self.rng) else {
break;
};
game.apply(&mut state, PlayerId::CHANCE, action);
} else {
let mut actions = game.legal_actions(&state, actor);
if actions.is_empty() {
break;
}
#[allow(clippy::cast_possible_truncation)]
let index = self.rng.below(actions.len() as u64) as usize;
game.apply(&mut state, actor, actions.swap_remove(index));
}
}
game.reward(&state, root)
}
}
struct Node<A> {
to_move: PlayerId,
chance: bool,
terminal: bool,
visits: u32,
value: f64,
untried: Vec<A>,
children: Vec<(A, usize)>,
}
impl<A> Node<A> {
fn mean(&self) -> f64 {
if self.visits == 0 {
0.0
} else {
self.value / f64::from(self.visits)
}
}
}
fn make_node<G>(game: &G, state: &G::State) -> Node<G::Action>
where
G: Game,
{
let mut node = Node {
to_move: PlayerId::CHANCE,
chance: false,
terminal: false,
visits: 0,
value: 0.0,
untried: Vec::new(),
children: Vec::new(),
};
let actor = game.active_players(state).iter().next();
match actor {
None => node.terminal = true,
Some(player) if player.is_chance() => {
node.to_move = PlayerId::CHANCE;
node.chance = true;
}
Some(player) => {
node.to_move = player;
node.untried = game.legal_actions(state, player);
}
}
node
}
fn child_for<G>(
nodes: &mut Vec<Node<G::Action>>,
parent: usize,
action: &G::Action,
game: &G,
state: &G::State,
) -> usize
where
G: Game,
G::Action: Clone,
{
if let Some((_, id)) = nodes[parent].children.iter().find(|(a, _)| a == action) {
return *id;
}
nodes.push(make_node(game, state));
let id = nodes.len() - 1;
nodes[parent].children.push((action.clone(), id));
id
}
impl<G> Bot<G> for Mcts
where
G: Game,
G::State: Clone,
G::Action: Clone,
{
fn choose(&mut self, game: &G, state: &G::State, player: PlayerId) -> Option<G::Action> {
if game.legal_actions(state, player).is_empty() {
return None;
}
let tree = self.run(game, state, player);
tree[0]
.children
.iter()
.max_by_key(|(_, id)| tree[*id].visits)
.map(|(action, _)| action.clone())
}
}
impl<G> RankedBot<G> for Mcts
where
G: Game,
G::State: Clone,
G::Action: Clone,
{
fn rank(&mut self, game: &G, state: &G::State, player: PlayerId) -> Vec<(G::Action, f64)> {
let tree = self.run(game, state, player);
let total = f64::from(tree[0].visits.max(1));
let mut ranked: Vec<(G::Action, f64)> = tree[0]
.children
.iter()
.map(|(action, id)| (action.clone(), f64::from(tree[*id].visits) / total))
.collect();
ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
ranked
}
}
#[cfg(test)]
mod tests {
use super::Mcts;
use crate::{Bot, Random, RankedBot};
use high_card::HighCard;
use tic_tac_toe::{Move, TicTacToe};
use turnbase::{Game, PlayerId};
const P0: PlayerId = PlayerId::new(0);
fn run_match<X: Bot<TicTacToe>, O: Bot<TicTacToe>>(
x: &mut X,
o: &mut O,
) -> <TicTacToe as Game>::State {
let game = TicTacToe;
let mut state = game.new_initial_state(0);
while !game.is_terminal(&state) {
let player = game.active_players(&state).iter().next().unwrap();
let action = if player.index() == 0 {
x.choose(&game, &state, player)
} else {
o.choose(&game, &state, player)
}
.unwrap();
game.apply(&mut state, player, action);
}
state
}
#[test]
fn mcts_does_not_lose_to_random() {
let game = TicTacToe;
for seed in 0..6 {
let mut x = Mcts::new(2000, seed);
let mut o = Random::new(seed + 100);
let end = run_match(&mut x, &mut o);
assert!(
game.reward(&end, P0) >= 0.0,
"MCTS X lost to random O (seed {seed})"
);
}
}
#[test]
fn mcts_takes_an_immediate_win() {
let game = TicTacToe;
let mut state = game.new_initial_state(0);
for (seat, cell) in [(0u32, 0u8), (1, 3), (0, 1), (1, 4)] {
game.apply(&mut state, PlayerId::new(seat), Move(cell));
}
let mut mcts = Mcts::new(3000, 1);
assert_eq!(mcts.choose(&game, &state, P0), Some(Move(2)));
}
#[test]
fn rank_is_a_probability_distribution() {
let game = TicTacToe;
let state = game.new_initial_state(0);
let mut mcts = Mcts::new(1500, 7);
let ranked = mcts.rank(&game, &state, P0);
assert_eq!(ranked.len(), game.legal_actions(&state, P0).len());
let sum: f64 = ranked.iter().map(|(_, p)| p).sum();
assert!((sum - 1.0).abs() < 1e-9, "visit shares sum to 1");
assert!(
ranked.windows(2).all(|w| w[0].1 >= w[1].1),
"sorted best-first"
);
}
#[test]
fn high_card_is_evaluated_as_fair() {
let game = HighCard::default();
let state = game.new_initial_state(0);
let mut mcts = Mcts::new(20_000, 3);
let value = mcts.evaluate(&game, &state, P0);
assert!(value.abs() < 0.1, "high card should be ~fair, got {value}");
}
}