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}