t4t/strategy.rs
1use crate::{Distribution, Finite, GameTree, Move, Outcome, Playable, PlayerIndex, State, Utility};
2use ordered_float::OrderedFloat;
3use std::sync::Arc;
4
5/// The strategic context in which a player makes a move during a game.
6///
7/// This type includes all information, besides the definition of the stage game, that a strategy
8/// may use to compute its next move. It includes the player's index and the player's view of the
9/// game state.
10#[derive(Clone)]
11pub struct Context<'a, G: Playable<P>, const P: usize> {
12 game: &'a G,
13 location: &'a GameTree<G::State, G::Move, G::Utility, G::Outcome, P>,
14 state_view: &'a G::View,
15 index: PlayerIndex<P>,
16}
17
18/// A function computing the next move for a player given a strategic context.
19///
20/// This trait is effectively a type synonym for the function type it extends. A blanket
21/// implementation covers all possible instances, so it should not be implemented directly.
22pub trait NextMove<G: Playable<P>, const P: usize>:
23 FnMut(Context<'_, G, P>) -> G::Move + Send + Sync + 'static
24{
25}
26
27impl<F, G: Playable<P>, const P: usize> NextMove<G, P> for F where
28 F: FnMut(Context<'_, G, P>) -> G::Move + Send + Sync + 'static
29{
30}
31
32impl<'a, G: Playable<P>, const P: usize> Context<'a, G, P> {
33 /// Construct a new context from the index of the player whose turn it is to move and that
34 /// player's view of the current state.
35 pub fn new(
36 game: &'a G,
37 location: &'a GameTree<G::State, G::Move, G::Utility, G::Outcome, P>,
38 state_view: &'a G::View,
39 index: PlayerIndex<P>,
40 ) -> Self {
41 Context {
42 game,
43 location,
44 state_view,
45 index,
46 }
47 }
48
49 /// The game being played.
50 pub fn game(&self) -> &'a G {
51 self.game
52 }
53
54 /// Get the player's view of the current state of the game.
55 pub fn state_view(&self) -> &'a G::View {
56 self.state_view
57 }
58
59 /// Get the index of the player whose turn it is to move. The method is named "my" from the
60 /// perspective of the strategy that receives this context.
61 pub fn my_index(&self) -> PlayerIndex<P> {
62 self.index
63 }
64}
65
66impl<'a, G: Playable<2>> Context<'a, G, 2> {
67 /// Get the index of the other player in a two player game. The method is named "their"
68 /// (singular) from the perspective of the strategy that receives this context.
69 pub fn their_index(&self) -> PlayerIndex<2> {
70 self.index.next()
71 }
72}
73
74impl<'a, S, G: Playable<P, State = S, View = S>, const P: usize> Context<'a, G, P> {
75 /// Get the current location in the game tree.
76 ///
77 /// # Note
78 /// This method should only be used in strategies for
79 /// [perfect-information](https://en.wikipedia.org/wiki/Perfect_information) games, that is,
80 /// games where the player's view of the state is the same as the state itself.
81 ///
82 /// Game implementors can ensure that this method is unavailable for games with imperfect
83 /// information by making the state and view types different.
84 pub fn location(&self) -> &'a GameTree<S, G::Move, G::Utility, G::Outcome, P> {
85 self.location
86 }
87}
88
89/// A strategy is a function from an intermediate game context to a move.
90pub struct Strategy<G: Playable<P>, const P: usize> {
91 #[allow(clippy::type_complexity)]
92 next_move: Box<dyn NextMove<G, P>>,
93}
94
95impl<G: Playable<P> + 'static, const P: usize> Strategy<G, P> {
96 /// Construct a new strategy from a function that computes the next move given a strategic
97 /// context.
98 pub fn new(next_move: impl NextMove<G, P>) -> Self {
99 Strategy {
100 next_move: Box::new(next_move),
101 }
102 }
103
104 /// Construct a [pure strategy](https://en.wikipedia.org/wiki/Strategy_(game_theory)#Pure_and_mixed_strategies)
105 /// that always plays the same move regardless of the context.
106 pub fn pure(the_move: G::Move) -> Self {
107 Strategy::new(move |_| the_move)
108 }
109
110 /// Construct a [mixed strategy](https://en.wikipedia.org/wiki/Strategy_(game_theory)#Mixed_strategy)
111 /// that plays a move according to the given probability distribution over moves.
112 pub fn mixed(dist: Distribution<G::Move>) -> Self {
113 Strategy::new(move |_| dist.sample().to_owned())
114 }
115
116 /// Construct a [mixed strategy](https://en.wikipedia.org/wiki/Strategy_(game_theory)#Mixed_strategy)
117 /// from a flat distribution over the given moves. This strategy will pick one move randomly,
118 /// each with equal probability.
119 ///
120 /// # Errors
121 ///
122 /// Logs an error and returns `None` if:
123 /// - The vector is empty.
124 /// - The vector is longer than u32::MAX.
125 pub fn mixed_flat(moves: Vec<G::Move>) -> Option<Self> {
126 Distribution::flat(moves).map(|dist| Strategy::mixed(dist))
127 }
128
129 /// Construct a probabilistic strategy that plays another strategy according to the given
130 /// probability distribution.
131 ///
132 /// A distribution of pure strategies is equivalent to a [mixed](Strategy::mixed) strategy.
133 pub fn probabilistic(mut dist: Distribution<Strategy<G, P>>) -> Self {
134 Strategy::new(move |context| dist.sample_mut().next_move(context))
135 }
136
137 /// Construct a periodic strategy that plays the given sequence of strategies in order, then
138 /// repeats.
139 pub fn periodic(mut strategies: Vec<Strategy<G, P>>) -> Self {
140 let mut next_index = 0;
141 Strategy::new(move |context| {
142 let the_move = strategies[next_index].next_move(context);
143 next_index = (next_index + 1) % strategies.len();
144 the_move
145 })
146 }
147 /// Construct a periodic strategy of pure strategies. That is, play the given moves in order
148 /// and repeat indefinitely.
149 pub fn periodic_pure(moves: Vec<G::Move>) -> Self {
150 let strategies = Vec::from_iter(moves.into_iter().map(|m| Strategy::pure(m)));
151 Strategy::periodic(strategies)
152 }
153
154 /// Construct a new conditional strategy that plays the `on_true` strategy if `condition`
155 /// returns true for the current context, and plays the `on_false` strategy otherwise.
156 pub fn conditional(
157 mut condition: impl FnMut(Context<G, P>) -> bool + Send + Sync + 'static,
158 mut on_true: Strategy<G, P>,
159 mut on_false: Strategy<G, P>,
160 ) -> Self {
161 Strategy::new(move |context| {
162 if condition(context.clone()) {
163 on_true.next_move(context)
164 } else {
165 on_false.next_move(context)
166 }
167 })
168 }
169
170 /// Construct a new trigger strategy that plays the `before` strategy until `trigger` returns
171 /// true, then plays the `after` strategy thereafter.
172 pub fn trigger(
173 mut trigger: impl FnMut(Context<G, P>) -> bool + Send + Sync + 'static,
174 mut before: Strategy<G, P>,
175 mut after: Strategy<G, P>,
176 ) -> Self {
177 let mut triggered = false;
178 Strategy::new(move |context| {
179 if !triggered {
180 triggered = trigger(context.clone());
181 }
182 if triggered {
183 after.next_move(context)
184 } else {
185 before.next_move(context)
186 }
187 })
188 }
189
190 /// Get the next move to play given the current play context.
191 pub fn next_move(&mut self, context: Context<G, P>) -> G::Move {
192 (self.next_move)(context)
193 }
194}
195
196impl<S, G, const P: usize> Strategy<G, P>
197where
198 S: State,
199 G: Finite<P, State = S, View = S> + Playable<P>,
200{
201 /// For a finite [perfect-information](https://en.wikipedia.org/wiki/Perfect_information) game,
202 /// construct a strategy that chooses a move randomly from the set of possible moves.
203 ///
204 /// # Panics
205 ///
206 /// Panics if the number of possible moves is 0 or larger than `u32::MAX`.
207 pub fn randomly() -> Self {
208 Strategy::new(|context: Context<G, P>| {
209 let player = context.my_index();
210 let state = context.state_view();
211 let moves = context
212 .game()
213 .possible_moves(player, state)
214 .collect::<Vec<_>>();
215 let dist = Distribution::flat(moves);
216 match dist {
217 Some(dist) => dist.sample().to_owned(),
218 None => panic!("randomly: Could not build distribution."),
219 }
220 })
221 }
222}
223
224impl<S, M, U, O, G, const P: usize> Strategy<G, P>
225where
226 S: State,
227 M: Move,
228 U: Utility + Into<f64>,
229 O: Outcome<M, U, P>,
230 G: Finite<P, Move = M, Utility = U, State = S, View = S> + Playable<P, Outcome = O>,
231{
232 /// Construct a strategy that uses the
233 /// [expectiminimax](https://en.wikipedia.org/wiki/Expectiminimax) algorithm to choose the move
234 /// that maximizes the minimum utility of the possible outcomes for the player.
235 ///
236 /// The algorithm uses [alpha-beta pruning](https://en.wikipedia.org/wiki/Alpha%E2%80%93beta_pruning)
237 /// to reduce the search space where possible.
238 ///
239 /// The heuristic function will be applied to the game state when the maximum search depth is
240 /// reached. The heuristic function should return a value that is between the minimum and
241 /// maximum payoff values achievable by the player.
242 ///
243 /// # TODO
244 /// The "expecti-" part of the algorithm is not yet implemented. That is, the strategy will
245 /// panic if it encounters a chance node in the game tree.
246 pub fn minimax(
247 max_depth: usize,
248 heuristic: impl Fn(&S) -> f64 + Send + Sync + 'static,
249 ) -> Self {
250 let heuristic = Arc::new(heuristic);
251 Strategy::new(move |context: Context<G, P>| {
252 let game_tree = context
253 .location()
254 .clone()
255 .sequentialize(Some(context.my_index()));
256
257 let next = if let GameTree::Turn { next, .. } = game_tree {
258 next
259 } else {
260 panic!("minimax: expected a turn node")
261 };
262
263 let best_move = context
264 .game()
265 .possible_moves(context.my_index(), context.state_view())
266 .max_by_key(|the_move| {
267 let child = next(context.state_view().clone(), vec![*the_move])
268 .expect("malformed game tree: possible move is invalid");
269 let value = Strategy::minimax_value(
270 context.game(),
271 context.my_index(),
272 Arc::clone(&heuristic),
273 max_depth,
274 &child,
275 f64::NEG_INFINITY,
276 f64::INFINITY,
277 );
278 OrderedFloat(value)
279 });
280
281 best_move.expect("minimax: no possible moves")
282 })
283 }
284
285 /// Construct a version of the [minimax](Strategy::minimax) strategy with no maximum search
286 /// depth.
287 ///
288 /// This strategy will always perform a total search of the game tree starting from the
289 /// player's location, and so is only suitable for relatively small games.
290 pub fn total_minimax() -> Self {
291 Strategy::minimax(usize::MAX, |_| 0.0)
292 }
293
294 /// Recursive helper function for the minimax strategy.
295 fn minimax_value(
296 game: &G,
297 my_index: PlayerIndex<P>,
298 heuristic: Arc<impl Fn(&S) -> f64>,
299 depth: usize,
300 node: &GameTree<S, M, U, O, P>,
301 mut alpha: f64,
302 mut beta: f64,
303 ) -> f64 {
304 match node.clone().sequentialize(Some(my_index)) {
305 GameTree::Turn {
306 state,
307 to_move,
308 next,
309 } => {
310 assert_eq!(to_move.len(), 1);
311 let player = to_move[0];
312 if depth == 0 {
313 heuristic(&state)
314 } else if alpha >= beta {
315 if player == my_index {
316 alpha
317 } else {
318 beta
319 }
320 } else if player == my_index {
321 // maximizing player
322 let mut value = f64::NEG_INFINITY;
323 for the_move in game.possible_moves(player, &state) {
324 let child = next(state.clone(), vec![the_move])
325 .expect("malformed game tree: possible move is invalid");
326 let child_value = Strategy::minimax_value(
327 game,
328 my_index,
329 Arc::clone(&heuristic),
330 depth - 1,
331 &child,
332 alpha,
333 beta,
334 );
335 value = f64::max(value, child_value);
336 alpha = f64::max(alpha, value);
337 if value >= beta {
338 break;
339 }
340 }
341 value
342 } else {
343 // minimizing player
344 let mut value = f64::INFINITY;
345 for the_move in game.possible_moves(player, &state) {
346 let child = next(state.clone(), vec![the_move])
347 .expect("malformed game tree: possible move is invalid");
348 let child_value = Strategy::minimax_value(
349 game,
350 my_index,
351 Arc::clone(&heuristic),
352 depth - 1,
353 &child,
354 alpha,
355 beta,
356 );
357 value = f64::min(value, child_value);
358 beta = f64::min(beta, value);
359 if value <= alpha {
360 break;
361 }
362 }
363 value
364 }
365 }
366
367 GameTree::Chance {
368 state,
369 distribution: _,
370 next: _,
371 } => {
372 if depth == 0 {
373 heuristic(&state)
374 } else {
375 todo! {"Minimax with chance nodes not yet implemented"}
376 }
377 }
378
379 GameTree::End { outcome, .. } => outcome.payoff()[my_index].into(),
380 }
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use crate::Normal;
388 use impls::impls;
389 use test_log::test;
390
391 #[test]
392 fn strategy_is_send_sync() {
393 assert!(impls!(Strategy<Normal<(), u8, 2>, 2>: Send & Sync));
394 }
395}