1use std::f64::consts::SQRT_2;
4
5use turnbase::{Determinize, Game, PlayerId, Prng, sample_chance};
6
7use crate::{Bot, RankedBot};
8
9pub struct Ismcts {
28 iterations: u32,
29 exploration: f64,
30 rng: Prng,
31}
32
33impl Ismcts {
34 #[must_use]
37 pub const fn new(iterations: u32, seed: u64) -> Self {
38 Self {
39 iterations,
40 exploration: SQRT_2,
41 rng: Prng::new(seed),
42 }
43 }
44
45 #[must_use]
47 pub const fn with_exploration(mut self, exploration: f64) -> Self {
48 self.exploration = exploration;
49 self
50 }
51
52 pub fn evaluate<G>(&mut self, game: &G, state: &G::State, player: PlayerId) -> f64
55 where
56 G: Determinize,
57 G::State: Clone,
58 G::Action: Clone,
59 {
60 let tree = self.run(game, state, player);
61 tree[0].mean(player.index() as usize)
62 }
63
64 fn run<G>(
65 &mut self,
66 game: &G,
67 root_state: &G::State,
68 observer: PlayerId,
69 ) -> Vec<Node<G::Action>>
70 where
71 G: Determinize,
72 G::State: Clone,
73 G::Action: Clone,
74 {
75 let players = game.num_players();
76 let mut nodes = vec![Node::new(players)];
77 for _ in 0..self.iterations {
78 let mut world = game.determinize(root_state, observer, &mut self.rng);
79 let mut path = vec![0usize];
80 let mut available: Vec<Vec<usize>> = Vec::new();
82 let mut current = 0usize;
83
84 loop {
85 if game.is_terminal(&world) {
86 break;
87 }
88 let Some(actor) = game.active_players(&world).iter().next() else {
89 break;
90 };
91 if actor.is_chance() {
92 let Some(action) = sample_chance(game, &world, &mut self.rng) else {
93 break;
94 };
95 game.apply(&mut world, PlayerId::CHANCE, action.clone());
96 current = child_for(&mut nodes, current, &action, players);
97 path.push(current);
98 continue;
99 }
100
101 let legal = game.legal_actions(&world, actor);
102 if legal.is_empty() {
103 break;
104 }
105 let untried: Vec<G::Action> = legal
106 .iter()
107 .filter(|a| !nodes[current].children.iter().any(|(c, _)| c == *a))
108 .cloned()
109 .collect();
110
111 if !untried.is_empty() {
112 #[allow(clippy::cast_possible_truncation)]
114 let pick = self.rng.below(untried.len() as u64) as usize;
115 let action = untried[pick].clone();
116 game.apply(&mut world, actor, action.clone());
117 nodes.push(Node::new(players));
118 let child = nodes.len() - 1;
119 nodes[current].children.push((action, child));
120 available.push(legal_children(&nodes[current], &legal));
121 path.push(child);
122 break;
123 }
124
125 let seat = actor.index() as usize;
126 let legal_ids = legal_children(&nodes[current], &legal);
127 let (action, child) = self.select(&nodes, current, &legal_ids, seat);
128 available.push(legal_ids);
129 game.apply(&mut world, actor, action);
130 current = child;
131 path.push(current);
132 }
133
134 let rewards = self.rollout(game, world, players);
135 for &id in &path {
136 nodes[id].visits += 1;
137 for (seat, reward) in rewards.iter().enumerate() {
138 nodes[id].value[seat] += reward;
139 }
140 }
141 for ids in &available {
142 for &id in ids {
143 nodes[id].avails += 1;
144 }
145 }
146 }
147 nodes
148 }
149
150 fn select<A: Clone>(
154 &self,
155 nodes: &[Node<A>],
156 parent: usize,
157 legal_ids: &[usize],
158 seat: usize,
159 ) -> (A, usize) {
160 let mut best = None;
161 let mut best_score = f64::NEG_INFINITY;
162 for &id in legal_ids {
163 let child = &nodes[id];
164 let exploit = child.mean(seat);
165 let explore = self.exploration
166 * (f64::from(child.avails.max(1)).ln() / f64::from(child.visits.max(1))).sqrt();
167 let score = exploit + explore;
168 if score > best_score {
169 best_score = score;
170 best = Some(id);
171 }
172 }
173 let id = best.expect("a fully expanded node has legal children");
174 let action = nodes[parent]
175 .children
176 .iter()
177 .find(|(_, cid)| *cid == id)
178 .map(|(a, _)| a.clone())
179 .expect("selected child belongs to the parent");
180 (action, id)
181 }
182
183 fn rollout<G>(&mut self, game: &G, mut state: G::State, players: usize) -> Vec<f64>
184 where
185 G: Game,
186 {
187 while !game.is_terminal(&state) {
188 let Some(actor) = game.active_players(&state).iter().next() else {
189 break;
190 };
191 if actor.is_chance() {
192 let Some(action) = sample_chance(game, &state, &mut self.rng) else {
193 break;
194 };
195 game.apply(&mut state, PlayerId::CHANCE, action);
196 } else {
197 let mut actions = game.legal_actions(&state, actor);
198 if actions.is_empty() {
199 break;
200 }
201 #[allow(clippy::cast_possible_truncation)]
203 let index = self.rng.below(actions.len() as u64) as usize;
204 game.apply(&mut state, actor, actions.swap_remove(index));
205 }
206 }
207 (0..players)
208 .map(|seat| game.reward(&state, PlayerId::new(u32::try_from(seat).unwrap())))
209 .collect()
210 }
211}
212
213struct Node<A> {
216 visits: u32,
217 avails: u32,
218 value: Vec<f64>,
219 children: Vec<(A, usize)>,
220}
221
222impl<A> Node<A> {
223 fn new(players: usize) -> Self {
224 Self {
225 visits: 0,
226 avails: 0,
227 value: vec![0.0; players],
228 children: Vec::new(),
229 }
230 }
231
232 fn mean(&self, seat: usize) -> f64 {
233 if self.visits == 0 {
234 0.0
235 } else {
236 self.value[seat] / f64::from(self.visits)
237 }
238 }
239}
240
241fn legal_children<A: PartialEq>(node: &Node<A>, legal: &[A]) -> Vec<usize> {
243 node.children
244 .iter()
245 .filter(|(action, _)| legal.contains(action))
246 .map(|(_, id)| *id)
247 .collect()
248}
249
250fn child_for<A: PartialEq + Clone>(
252 nodes: &mut Vec<Node<A>>,
253 parent: usize,
254 action: &A,
255 players: usize,
256) -> usize {
257 if let Some((_, id)) = nodes[parent].children.iter().find(|(a, _)| a == action) {
258 return *id;
259 }
260 nodes.push(Node::new(players));
261 let id = nodes.len() - 1;
262 nodes[parent].children.push((action.clone(), id));
263 id
264}
265
266impl<G> Bot<G> for Ismcts
267where
268 G: Determinize,
269 G::State: Clone,
270 G::Action: Clone,
271{
272 fn choose(&mut self, game: &G, state: &G::State, player: PlayerId) -> Option<G::Action> {
273 if game.legal_actions(state, player).is_empty() {
274 return None;
275 }
276 let tree = self.run(game, state, player);
277 tree[0]
278 .children
279 .iter()
280 .max_by_key(|(_, id)| tree[*id].visits)
281 .map(|(action, _)| action.clone())
282 }
283}
284
285impl<G> RankedBot<G> for Ismcts
286where
287 G: Determinize,
288 G::State: Clone,
289 G::Action: Clone,
290{
291 fn rank(&mut self, game: &G, state: &G::State, player: PlayerId) -> Vec<(G::Action, f64)> {
294 let tree = self.run(game, state, player);
295 let total = f64::from(tree[0].visits.max(1));
296 let mut ranked: Vec<(G::Action, f64)> = tree[0]
297 .children
298 .iter()
299 .map(|(action, id)| (action.clone(), f64::from(tree[*id].visits) / total))
300 .collect();
301 ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
302 ranked
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::Ismcts;
309 use crate::{Bot, Random};
310 use coup::{Coup, CoupState};
311 use tic_tac_toe::{Move, TicTacToe};
312 use turnbase::{Game, PlayerId};
313
314 const P0: PlayerId = PlayerId::new(0);
315
316 fn play<G, A, B>(game: &G, mut state: G::State, a: &mut A, b: &mut B) -> G::State
318 where
319 G: Game,
320 A: Bot<G>,
321 B: Bot<G>,
322 {
323 let mut steps = 0;
324 while !game.is_terminal(&state) {
325 let player = game.active_players(&state).iter().next().unwrap();
326 let action = if player.index() == 0 {
327 a.choose(game, &state, player)
328 } else {
329 b.choose(game, &state, player)
330 }
331 .expect("an active player has a move");
332 game.apply(&mut state, player, action);
333 steps += 1;
334 assert!(steps < 20_000, "match did not terminate");
335 }
336 state
337 }
338
339 #[test]
340 fn ismcts_reduces_to_mcts_on_perfect_information() {
341 let game = TicTacToe;
344 for seed in 0..6 {
345 let mut x = Ismcts::new(2000, seed);
346 let mut o = Random::new(seed + 100);
347 let end = play(&game, game.new_initial_state(0), &mut x, &mut o);
348 assert!(
349 game.reward(&end, P0) >= 0.0,
350 "ISMCTS X lost to random O (seed {seed})"
351 );
352 }
353 }
354
355 #[test]
356 fn ismcts_takes_an_immediate_win() {
357 let game = TicTacToe;
358 let mut state = game.new_initial_state(0);
359 for (seat, cell) in [(0u32, 0u8), (1, 3), (0, 1), (1, 4)] {
360 game.apply(&mut state, PlayerId::new(seat), Move(cell));
361 }
362 let mut bot = Ismcts::new(3000, 1);
363 assert_eq!(bot.choose(&game, &state, P0), Some(Move(2)));
364 }
365
366 #[test]
367 fn ismcts_beats_random_at_coup() {
368 let game = Coup::new(2);
372 let mut wins = 0;
373 let matches = 30;
374 for seed in 0..matches {
375 let start = game.new_initial_state(seed);
376 let end: CoupState = if seed % 2 == 0 {
377 let mut a = Ismcts::new(400, seed);
378 let mut b = Random::new(seed ^ 0x55);
379 play(&game, start, &mut a, &mut b)
380 } else {
381 let mut a = Ismcts::new(400, seed ^ 0xAA);
383 let mut b = Random::new(seed);
384 play(&game, start, &mut a, &mut b)
385 };
386 if game.reward(&end, P0) > 0.0 {
387 wins += 1;
388 }
389 }
390 assert!(
391 wins * 2 > matches,
392 "ISMCTS won only {wins}/{matches} vs random"
393 );
394 }
395}