Skip to main content

wowlab_engine_rng/stochastic/
sampling.rs

1/// Rolls a proc with the given fractional probability.
2#[inline]
3pub fn proc_chance(rng: &mut dyn FnMut() -> f64, chance: f64) -> bool {
4    if chance <= 0.0 {
5        false
6    } else if chance >= 1.0 {
7        true
8    } else {
9        rng() < chance
10    }
11}
12
13/// Converts a classic procs-per-minute rate into one swing's proc probability.
14#[inline]
15#[must_use]
16pub const fn ppm_proc_chance(ppm: f64, swing_period_seconds: f64) -> f64 {
17    // #t(rust_magic_numbers) 60.0 is seconds per minute, fundamental to the PPM formula.
18    ppm * swing_period_seconds / 60.0
19}
20
21/// Draws uniformly from the closed interval `[low, high]`, mirroring `SimC`'s `rng().range(a, b)`.
22#[inline]
23pub fn roll_range(rng: &mut dyn FnMut() -> f64, low: f64, high: f64) -> f64 {
24    low + rng() * (high - low)
25}
26
27/// Returns the index of the first cumulative threshold above the RNG draw.
28#[inline]
29pub fn roll_tier(rng: &mut dyn FnMut() -> f64, thresholds: &[f64]) -> usize {
30    let roll = rng();
31
32    for (index, &threshold) in thresholds.iter().enumerate() {
33        if roll < threshold {
34            return index;
35        }
36    }
37
38    thresholds.len()
39}
40
41/// Moves up to `n` randomly selected items to the front of `items`.
42pub fn shuffle_pick<T>(rng: &mut dyn FnMut() -> f64, items: &mut [T], n: usize) {
43    let len = items.len();
44    let picks = n.min(len);
45
46    for index in 0..picks {
47        let remaining = len - index;
48        #[expect(
49            clippy::cast_possible_truncation,
50            clippy::cast_precision_loss,
51            clippy::cast_sign_loss,
52            reason = "Fisher-Yates maps a unit-interval float to a bounded slice index"
53        )]
54        let offset = (rng() * remaining as f64) as usize;
55        let swap_index = (index + offset).min(len - 1);
56
57        items.swap(index, swap_index);
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use googletest::prelude::*;
64    use rstest::rstest;
65
66    use super::*;
67
68    #[gtest]
69    #[rstest]
70    #[case::chance_zero_never(0.0, 0.0, false)]
71    #[case::chance_one_always(0.0, 1.0, true)]
72    #[case::chance_one_high_roll(0.999, 1.0, true)]
73    #[case::below_threshold(0.4, 0.5, true)]
74    #[case::at_threshold_strict(0.5, 0.5, false)]
75    #[case::above_threshold(0.6, 0.5, false)]
76    fn proc_chance_boundary_contract(
77        #[case] roll: f64,
78        #[case] chance: f64,
79        #[case] expected: bool,
80    ) -> Result<()> {
81        let mut rng = || roll;
82
83        verify_that!(proc_chance(&mut rng, chance), eq(expected))
84    }
85
86    #[gtest]
87    #[rstest]
88    #[case::zero(0.0, false)]
89    #[case::negative(-0.5, false)]
90    #[case::one(1.0, true)]
91    #[case::above_one(1.5, true)]
92    fn deterministic_proc_chances_do_not_consume_rng(
93        #[case] chance: f64,
94        #[case] expected: bool,
95    ) -> Result<()> {
96        let mut draws = 0;
97
98        let mut rng = || {
99            draws += 1;
100
101            0.5
102        };
103
104        verify_that!(proc_chance(&mut rng, chance), eq(expected))?;
105
106        verify_that!(draws, eq(0))
107    }
108
109    #[gtest]
110    #[rstest]
111    #[case::one_second(7.0, 1.0, 7.0 / 60.0)]
112    #[case::unusual_slow_swing(7.0, 2.4, 0.28)]
113    #[case::moment_of_clarity_on_unusual_swing(9.1, 2.4, 0.364)]
114    #[case::zero_rate(0.0, 2.4, 0.0)]
115    fn ppm_proc_chance_scales_with_the_unhasted_swing_period(
116        #[case] ppm: f64,
117        #[case] swing_period_seconds: f64,
118        #[case] expected: f64,
119    ) -> Result<()> {
120        verify_that!(
121            ppm_proc_chance(ppm, swing_period_seconds),
122            near(expected, f64::EPSILON)
123        )
124    }
125
126    #[gtest]
127    #[rstest]
128    #[case::first_tier(vec![0.2, 0.5, 1.0], 0.1, 0)]
129    #[case::second_tier(vec![0.2, 0.5, 1.0], 0.3, 1)]
130    #[case::third_tier(vec![0.2, 0.5, 1.0], 0.7, 2)]
131    #[case::fall_through(vec![0.2, 0.5, 0.8], 0.9, 3)]
132    #[case::boundary_not_less(vec![0.2, 0.5, 0.8], 0.2, 1)]
133    #[case::empty_slice(vec![], 0.5, 0)]
134    fn roll_tier_threshold_arms(
135        #[case] thresholds: Vec<f64>,
136        #[case] roll: f64,
137        #[case] expected: usize,
138    ) -> Result<()> {
139        let mut rng = || roll;
140
141        verify_that!(roll_tier(&mut rng, &thresholds), eq(expected))
142    }
143
144    #[gtest]
145    #[rstest]
146    #[case::n_zero(vec![1, 2, 3, 4], 0.0, 0, vec![1, 2, 3, 4])]
147    #[case::identity_self_swap(vec![1, 2, 3, 4], 0.0, 4, vec![1, 2, 3, 4])]
148    #[case::high_roll_permutation(vec![1, 2, 3, 4], 0.999, 2, vec![4, 1, 3, 2])]
149    #[case::n_exceeds_len(vec![1, 2], 0.0, 10, vec![1, 2])]
150    fn shuffle_pick_known_answer(
151        #[case] mut items: Vec<i32>,
152        #[case] rng_val: f64,
153        #[case] n: usize,
154        #[case] expected: Vec<i32>,
155    ) -> Result<()> {
156        let mut rng = || rng_val;
157
158        shuffle_pick(&mut rng, &mut items, n);
159
160        verify_that!(items, container_eq(expected))
161    }
162
163    #[gtest]
164    fn shuffle_pick_consumes_one_draw_per_pick() -> Result<()> {
165        let mut draws = 0;
166        let mut rng = || {
167            draws += 1;
168
169            0.0
170        };
171        let mut items = [1, 2, 3];
172
173        shuffle_pick(&mut rng, &mut items, 10);
174
175        verify_that!(draws, eq(items.len()))
176    }
177}