Skip to main content

wowlab_engine_rng/stochastic/
shuffled.rs

1/// A shuffled finite deck containing a fixed number of successful entries.
2#[derive(Clone, Debug)]
3pub struct ShuffledRng {
4    entries: Vec<bool>,
5    position: usize,
6}
7
8impl ShuffledRng {
9    #[must_use]
10    pub fn new(success_entries: u32, total_entries: u32) -> Option<Self> {
11        if total_entries == 0 || success_entries > total_entries {
12            return None;
13        }
14
15        let success_count = usize::try_from(success_entries).ok()?;
16        let total_count = usize::try_from(total_entries).ok()?;
17        let mut entries = vec![false; total_count];
18
19        entries.get_mut(..success_count)?.fill(true);
20
21        Some(Self {
22            entries,
23            position: total_count,
24        })
25    }
26
27    /// Constructs a deck whose counts were validated by the authoring boundary.
28    ///
29    /// # Panics
30    ///
31    /// Panics when the deck is empty or contains more successes than entries.
32    #[must_use]
33    pub fn from_valid_counts(success_entries: u32, total_entries: u32) -> Self {
34        assert!(
35            total_entries > 0 && success_entries <= total_entries,
36            "shuffled proc deck requires 0 <= successes ({success_entries}) <= total entries ({total_entries}) and a non-empty deck"
37        );
38
39        let success_count =
40            usize::try_from(success_entries).expect("u32 success count fits supported usize");
41        let total_count =
42            usize::try_from(total_entries).expect("u32 total count fits supported usize");
43        let mut entries = vec![false; total_count];
44
45        entries
46            .get_mut(..success_count)
47            .expect("validated success count is within the deck")
48            .fill(true);
49
50        Self {
51            entries,
52            position: total_count,
53        }
54    }
55
56    pub fn reset(&mut self) {
57        self.position = self.entries.len();
58    }
59
60    /// Draws the next entry, shuffling a fresh deck after exhaustion.
61    ///
62    /// # Panics
63    ///
64    /// Panics only if the private deck-position invariant is violated.
65    pub fn trigger(&mut self, rng: &mut dyn FnMut() -> f64) -> bool {
66        if self.position == self.entries.len() {
67            shuffle(rng, &mut self.entries);
68            self.position = 0;
69        }
70
71        let result = self
72            .entries
73            .get(self.position)
74            .copied()
75            .expect("deck position is reset before drawing");
76
77        self.position += 1;
78
79        result
80    }
81
82    /// Returns the successful entries not yet drawn from the current deck.
83    ///
84    /// # Panics
85    ///
86    /// Panics only if the private deck-position invariant is violated.
87    #[cfg(test)]
88    #[must_use]
89    pub(crate) fn successes_remaining(&self) -> usize {
90        self.entries
91            .get(self.position..)
92            .expect("deck position never exceeds deck length")
93            .iter()
94            .filter(|entry| **entry)
95            .count()
96    }
97
98    #[cfg(test)]
99    #[must_use]
100    pub(crate) fn entries_remaining(&self) -> usize {
101        self.entries.len() - self.position
102    }
103}
104
105fn shuffle(rng: &mut dyn FnMut() -> f64, entries: &mut [bool]) {
106    for index in 0..entries.len().saturating_sub(1) {
107        let remaining = entries.len() - index;
108        #[expect(
109            clippy::cast_possible_truncation,
110            clippy::cast_precision_loss,
111            clippy::cast_sign_loss,
112            reason = "Fisher-Yates maps a unit-interval float to a bounded slice index"
113        )]
114        let offset = (rng() * remaining as f64) as usize;
115        let swap_index = (index + offset).min(entries.len() - 1);
116
117        entries.swap(index, swap_index);
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use googletest::prelude::*;
124
125    use super::*;
126
127    #[gtest]
128    fn rejects_empty_and_overfull_decks() {
129        expect_that!(ShuffledRng::new(0, 0), none());
130        expect_that!(ShuffledRng::new(3, 2), none());
131    }
132
133    #[gtest]
134    fn each_deck_contains_exact_authored_distribution() -> Result<()> {
135        let mut deck = ShuffledRng::new(2, 5).or_fail()?;
136        let rolls = [0.9, 0.1, 0.6, 0.3, 0.8, 0.2, 0.7, 0.4];
137        let mut index = 0;
138        let mut rng = || {
139            let roll = rolls[index % rolls.len()];
140
141            index += 1;
142
143            roll
144        };
145
146        for _ in 0..20 {
147            let draws: Vec<_> = (0..5).map(|_| deck.trigger(&mut rng)).collect();
148
149            verify_that!(draws.iter().filter(|draw| **draw).count(), eq(2))?;
150            verify_that!(deck.entries_remaining(), eq(0))?;
151        }
152
153        Ok(())
154    }
155
156    #[gtest]
157    fn remaining_counts_follow_draw_position() -> Result<()> {
158        let mut deck = ShuffledRng::new(1, 3).or_fail()?;
159        let mut rng = || 0.0;
160
161        verify_that!(deck.trigger(&mut rng), eq(true))?;
162        verify_that!(deck.successes_remaining(), eq(0))?;
163
164        verify_that!(deck.entries_remaining(), eq(2))
165    }
166}