wowlab_engine_rng/stochastic/
shuffled.rs1#[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 #[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 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 #[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}