Skip to main content

turnbase/
chance.rs

1//! Sampling committed chance outcomes.
2
3use crate::{Game, Prng};
4
5/// Samples one outcome from `game.chance_outcomes(state)`, weighted by
6/// probability, using `rng`. Returns `None` if there are no outcomes.
7///
8/// `rng` is the chance sampler's generator, owned by the driver or search
9/// loop, deliberately separate from any per-state generator a game uses for
10/// implicit rolls inside `apply`. Given the same generator position and the
11/// same outcome list, the draw is reproducible, which is what makes replay and
12/// undo/redo of a dealt card land on the same result.
13///
14/// The returned action is one of the outcomes; the caller applies it with
15/// [`PlayerId::CHANCE`](crate::PlayerId::CHANCE) to commit it to state. Use it
16/// in a driver or rollout loop whenever the chance pseudo-player is active.
17///
18/// # Example
19/// ```
20/// use turnbase::{ActivePlayers, Game, PlayerId, Prng, sample_chance};
21///
22/// // One chance node that reveals a card 0, 1, or 2 (uniform by default).
23/// struct Reveal;
24/// impl Game for Reveal {
25///     type State = ();
26///     type Action = u8;
27///     type View = ();
28///     fn new_initial_state(&self, _seed: u64) {}
29///     fn num_players(&self) -> usize { 0 }
30///     fn active_players(&self, _s: &()) -> ActivePlayers { ActivePlayers::one(PlayerId::CHANCE) }
31///     fn legal_actions(&self, _s: &(), p: PlayerId) -> Vec<u8> {
32///         if p.is_chance() { vec![0, 1, 2] } else { vec![] }
33///     }
34///     fn apply(&self, _s: &mut (), _p: PlayerId, _a: u8) {}
35///     fn is_terminal(&self, _s: &()) -> bool { false }
36///     fn reward(&self, _s: &(), _p: PlayerId) -> f64 { 0.0 }
37///     fn view(&self, _s: &(), _v: Option<PlayerId>) {}
38/// }
39///
40/// let mut sampler = Prng::new(42);
41/// let mut state = ();
42/// if Reveal.active_players(&state).contains(PlayerId::CHANCE) {
43///     let card = sample_chance(&Reveal, &state, &mut sampler).unwrap();
44///     Reveal.apply(&mut state, PlayerId::CHANCE, card); // commit the draw
45///     assert!(card <= 2);
46/// }
47/// ```
48pub fn sample_chance<G: Game>(game: &G, state: &G::State, rng: &mut Prng) -> Option<G::Action> {
49    let outcomes = game.chance_outcomes(state);
50    if outcomes.is_empty() {
51        return None;
52    }
53    let total: f64 = outcomes.iter().map(|(_, weight)| *weight).sum();
54
55    // A uniform point in [0, total). next_u64 / 2^64 lands in [0, 1); the loss
56    // of the low bits when widening to f64 is irrelevant for outcome selection.
57    #[allow(clippy::cast_precision_loss)]
58    let unit = rng.next_u64() as f64 / (u64::MAX as f64 + 1.0);
59    let mut remaining = unit * total;
60
61    let mut chosen = None;
62    for (action, weight) in outcomes {
63        chosen = Some(action);
64        remaining -= weight;
65        if remaining < 0.0 {
66            break;
67        }
68    }
69    chosen
70}
71
72#[cfg(test)]
73mod tests {
74    use super::sample_chance;
75    use crate::{ActivePlayers, Game, PlayerId, Prng};
76
77    /// A single chance node offering three weighted outcomes.
78    struct Weighted;
79
80    impl Game for Weighted {
81        type State = ();
82        type Action = u8;
83        type View = ();
84
85        fn new_initial_state(&self, _seed: u64) -> Self::State {}
86        fn num_players(&self) -> usize {
87            0
88        }
89        fn active_players(&self, _state: &Self::State) -> ActivePlayers {
90            ActivePlayers::one(PlayerId::CHANCE)
91        }
92        fn legal_actions(&self, _state: &Self::State, player: PlayerId) -> Vec<Self::Action> {
93            if player.is_chance() {
94                vec![0, 1, 2]
95            } else {
96                Vec::new()
97            }
98        }
99        fn chance_outcomes(&self, _state: &Self::State) -> Vec<(Self::Action, f64)> {
100            vec![(0, 0.1), (1, 0.3), (2, 0.6)]
101        }
102        fn apply(&self, _state: &mut Self::State, _player: PlayerId, _action: Self::Action) {}
103        fn is_terminal(&self, _state: &Self::State) -> bool {
104            false
105        }
106        fn reward(&self, _state: &Self::State, _player: PlayerId) -> f64 {
107            0.0
108        }
109        fn view(&self, _state: &Self::State, _viewer: Option<PlayerId>) -> Self::View {}
110    }
111
112    #[test]
113    fn only_returns_offered_outcomes() {
114        let mut rng = Prng::new(1);
115        for _ in 0..1000 {
116            let outcome = sample_chance(&Weighted, &(), &mut rng).unwrap();
117            assert!(outcome <= 2);
118        }
119    }
120
121    #[test]
122    fn empty_distribution_yields_none() {
123        struct NoChance;
124        impl Game for NoChance {
125            type State = ();
126            type Action = u8;
127            type View = ();
128            fn new_initial_state(&self, _seed: u64) -> Self::State {}
129            fn num_players(&self) -> usize {
130                0
131            }
132            fn active_players(&self, _s: &Self::State) -> ActivePlayers {
133                ActivePlayers::none()
134            }
135            fn legal_actions(&self, _s: &Self::State, _p: PlayerId) -> Vec<Self::Action> {
136                Vec::new()
137            }
138            fn apply(&self, _s: &mut Self::State, _p: PlayerId, _a: Self::Action) {}
139            fn is_terminal(&self, _s: &Self::State) -> bool {
140                true
141            }
142            fn reward(&self, _s: &Self::State, _p: PlayerId) -> f64 {
143                0.0
144            }
145            fn view(&self, _s: &Self::State, _v: Option<PlayerId>) -> Self::View {}
146        }
147        let mut rng = Prng::new(1);
148        assert!(sample_chance(&NoChance, &(), &mut rng).is_none());
149    }
150
151    #[test]
152    fn empirical_frequencies_track_the_weights() {
153        let mut rng = Prng::new(12345);
154        let mut counts = [0u32; 3];
155        let trials = 100_000;
156        for _ in 0..trials {
157            let outcome = sample_chance(&Weighted, &(), &mut rng).unwrap();
158            counts[outcome as usize] += 1;
159        }
160        let freq = counts.map(|c| f64::from(c) / f64::from(trials));
161        // Expected 0.1 / 0.3 / 0.6; generous tolerance for sampling noise.
162        assert!((freq[0] - 0.1).abs() < 0.01, "freq {freq:?}");
163        assert!((freq[1] - 0.3).abs() < 0.01, "freq {freq:?}");
164        assert!((freq[2] - 0.6).abs() < 0.01, "freq {freq:?}");
165    }
166
167    #[test]
168    fn same_seed_reproduces_the_draw() {
169        let mut a = Prng::new(99);
170        let mut b = Prng::new(99);
171        for _ in 0..100 {
172            assert_eq!(
173                sample_chance(&Weighted, &(), &mut a),
174                sample_chance(&Weighted, &(), &mut b)
175            );
176        }
177    }
178}