Skip to main content

wowlab_engine_rng/stochastic/
rppm.rs

1const MAX_INTERVAL_S: f64 = 3.5;
2
3// #t(rust_reinvented_constant) Domain-specific BLP safety cap in this zero-dependency leaf crate.
4const MAX_BAD_LUCK_PROT_S: f64 = 1000.0;
5
6const SAME_TIME_TOLERANCE_S: f64 = 0.001;
7
8const BLP_THRESHOLD: f64 = 1.5;
9const BLP_SLOPE: f64 = 3.0;
10// #t(rust_reinvented_constant) Keep this zero-dependency leaf independent of wowlab-types.
11const PERCENT: f64 = 100.0;
12
13#[inline]
14fn blp_factor(accumulated: f64, expected_interval: f64) -> f64 {
15    (1.0 + ((accumulated / expected_interval) - BLP_THRESHOLD) * BLP_SLOPE).max(1.0)
16}
17
18/// RPPM (real procs per minute) state for one proc source.
19// docref:start procs-rppm-tracker
20#[derive(Clone, Copy, Debug)]
21#[expect(
22    clippy::struct_excessive_bools,
23    reason = "three independent DBC scaling flags and the independent SimC bad-luck-protection switch form the tracker policy"
24)]
25pub struct RppmTracker {
26    pub rppm: f64,
27    pub last_attempt_time: f64,
28    pub last_proc_time: f64,
29    pub accumulated_blp: f64,
30    pub haste_scales: bool,
31    pub crit_scales: bool,
32    pub auto_attack_speed_scales: bool,
33    pub blp_enabled: bool,
34}
35// docref:end procs-rppm-tracker
36
37/// Current multiplicative inputs for RPPM scaling.
38#[derive(Clone, Copy, Debug)]
39pub struct RppmScaling {
40    pub haste_pct: f64,
41    pub crit_pct: f64,
42    pub auto_attack_speed_multiplier: f64,
43}
44
45impl Default for RppmScaling {
46    fn default() -> Self {
47        Self {
48            haste_pct: 0.0,
49            crit_pct: 0.0,
50            auto_attack_speed_multiplier: 1.0,
51        }
52    }
53}
54
55/// Rolls an RPPM check with DBC-selected scaling and bad-luck protection.
56pub fn roll_rppm(
57    tracker: &mut RppmTracker,
58    now: f64,
59    scaling: RppmScaling,
60    rng: &mut dyn FnMut() -> f64,
61) -> bool {
62    if (now - tracker.last_attempt_time).abs() < SAME_TIME_TOLERANCE_S {
63        return false;
64    }
65
66    let raw_elapsed = (now - tracker.last_attempt_time).max(0.0);
67    let elapsed = raw_elapsed.min(MAX_INTERVAL_S);
68
69    let haste_factor = if tracker.haste_scales {
70        1.0 + scaling.haste_pct / PERCENT
71    } else {
72        1.0
73    };
74    let crit_factor = if tracker.crit_scales {
75        1.0 + scaling.crit_pct / PERCENT
76    } else {
77        1.0
78    };
79    let auto_attack_speed_factor = if tracker.auto_attack_speed_scales {
80        scaling.auto_attack_speed_multiplier.max(0.0)
81    } else {
82        1.0
83    };
84
85    let real_ppm = tracker.rppm * haste_factor * crit_factor * auto_attack_speed_factor;
86    // #t(rust_magic_numbers) 60.0 is seconds-per-minute, fundamental to RPPM formula
87    let base_chance = real_ppm * (elapsed / 60.0);
88
89    tracker.accumulated_blp += elapsed;
90
91    let chance = if tracker.blp_enabled && real_ppm > 0.0 {
92        let accumulated = tracker.accumulated_blp.min(MAX_BAD_LUCK_PROT_S);
93        // #t(rust_magic_numbers) 60.0 is seconds-per-minute, fundamental to RPPM formula
94        let expected_interval = 60.0 / real_ppm;
95        let factor = blp_factor(accumulated, expected_interval);
96
97        (base_chance * factor).clamp(0.0, 1.0)
98    } else {
99        base_chance.clamp(0.0, 1.0)
100    };
101
102    let success = rng() < chance;
103
104    tracker.last_attempt_time = now;
105
106    if success {
107        tracker.last_proc_time = now;
108        tracker.accumulated_blp = 0.0;
109
110        true
111    } else {
112        false
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use googletest::prelude::*;
119
120    use super::*;
121
122    fn make_tracker(rppm: f64, haste_scales: bool) -> RppmTracker {
123        RppmTracker {
124            rppm,
125            last_attempt_time: 0.0,
126            last_proc_time: 0.0,
127            accumulated_blp: 0.0,
128            haste_scales,
129            crit_scales: false,
130            auto_attack_speed_scales: false,
131            blp_enabled: true,
132        }
133    }
134
135    #[gtest]
136    fn zero_elapsed_never_procs_or_draws() -> Result<()> {
137        let mut tracker = make_tracker(1.0, false);
138
139        tracker.last_attempt_time = 10.0;
140        let mut draws = 0;
141        let mut rng = || {
142            draws += 1;
143
144            0.0
145        };
146
147        verify_that!(
148            roll_rppm(&mut tracker, 10.0, RppmScaling::default(), &mut rng),
149            eq(false)
150        )?;
151
152        verify_that!(draws, eq(0))
153    }
154
155    #[gtest]
156    fn large_elapsed_always_procs() -> Result<()> {
157        let mut tracker = make_tracker(1000.0, false);
158        let mut rng = || 0.99;
159
160        verify_that!(
161            roll_rppm(&mut tracker, 120.0, RppmScaling::default(), &mut rng),
162            eq(true)
163        )
164    }
165
166    #[gtest]
167    fn haste_scaling_doubles_chance() -> Result<()> {
168        let mut tracker_no_haste = make_tracker(1.0, true);
169
170        tracker_no_haste.blp_enabled = false;
171        let mut rng = || 0.09;
172
173        verify_that!(
174            roll_rppm(&mut tracker_no_haste, 6.0, RppmScaling::default(), &mut rng),
175            eq(false)
176        )?;
177
178        let mut tracker_haste = make_tracker(1.0, true);
179
180        tracker_haste.blp_enabled = false;
181        let mut rng = || 0.09;
182
183        verify_that!(
184            roll_rppm(
185                &mut tracker_haste,
186                6.0,
187                RppmScaling {
188                    haste_pct: 100.0,
189                    ..RppmScaling::default()
190                },
191                &mut rng
192            ),
193            eq(true)
194        )
195    }
196
197    #[gtest]
198    fn last_attempt_time_updates_each_call() -> Result<()> {
199        let mut tracker = make_tracker(1.0, false);
200        let mut rng = || 1.0;
201
202        roll_rppm(&mut tracker, 5.0, RppmScaling::default(), &mut rng);
203        verify_that!(tracker.last_attempt_time, near(5.0, f64::EPSILON))?;
204
205        roll_rppm(&mut tracker, 10.0, RppmScaling::default(), &mut rng);
206
207        verify_that!(tracker.last_attempt_time, near(10.0, f64::EPSILON))
208    }
209
210    #[gtest]
211    fn last_proc_time_updates_only_on_success() -> Result<()> {
212        let mut miss_tracker = make_tracker(1.0, false);
213        let mut miss_rng = || 1.0;
214
215        roll_rppm(
216            &mut miss_tracker,
217            60.0,
218            RppmScaling::default(),
219            &mut miss_rng,
220        );
221        verify_that!(miss_tracker.last_proc_time, near(0.0, f64::EPSILON))?;
222
223        let mut proc_tracker = make_tracker(1.0, false);
224        let mut proc_rng = || 0.0;
225
226        roll_rppm(
227            &mut proc_tracker,
228            60.0,
229            RppmScaling::default(),
230            &mut proc_rng,
231        );
232
233        verify_that!(proc_tracker.last_proc_time, near(60.0, f64::EPSILON))
234    }
235
236    #[gtest]
237    fn blp_is_neutral_within_expected_interval() -> Result<()> {
238        let mut tracker = make_tracker(1.0, false);
239        let mut rng = || 1.0;
240        let mut time = MAX_INTERVAL_S;
241
242        while time <= 60.0 {
243            roll_rppm(&mut tracker, time, RppmScaling::default(), &mut rng);
244            time += MAX_INTERVAL_S;
245        }
246
247        let accumulated = tracker.accumulated_blp.min(MAX_BAD_LUCK_PROT_S);
248        let expected_interval = 60.0 / tracker.rppm;
249
250        verify_that!(
251            blp_factor(accumulated, expected_interval),
252            near(1.0, f64::EPSILON)
253        )
254    }
255
256    #[gtest]
257    fn blp_scales_chance_above_threshold() -> Result<()> {
258        let mut tracker = make_tracker(1.0, false);
259        let mut rng = || 1.0;
260        let mut time = MAX_INTERVAL_S;
261
262        while time <= 120.0 {
263            roll_rppm(&mut tracker, time, RppmScaling::default(), &mut rng);
264            time += MAX_INTERVAL_S;
265        }
266
267        let accumulated = tracker.accumulated_blp.min(MAX_BAD_LUCK_PROT_S);
268        let expected_interval = 60.0 / tracker.rppm;
269
270        verify_that!(blp_factor(accumulated, expected_interval), gt(1.0))
271    }
272
273    #[gtest]
274    fn successful_proc_resets_blp() -> Result<()> {
275        let mut tracker = make_tracker(1.0, false);
276        let mut miss_rng = || 1.0;
277
278        roll_rppm(&mut tracker, 3.5, RppmScaling::default(), &mut miss_rng);
279        roll_rppm(&mut tracker, 7.0, RppmScaling::default(), &mut miss_rng);
280        roll_rppm(&mut tracker, 10.5, RppmScaling::default(), &mut miss_rng);
281        verify_that!(tracker.accumulated_blp, gt(0.0))?;
282
283        let mut proc_rng = || 0.0;
284
285        verify_that!(
286            roll_rppm(&mut tracker, 14.0, RppmScaling::default(), &mut proc_rng),
287            eq(true)
288        )?;
289
290        verify_that!(tracker.accumulated_blp, near(0.0, f64::EPSILON))
291    }
292
293    #[gtest]
294    fn elapsed_per_call_is_capped_at_maximum_interval() -> Result<()> {
295        let mut tracker = make_tracker(1.0, false);
296        let mut rng = || 1.0;
297
298        roll_rppm(&mut tracker, 100.0, RppmScaling::default(), &mut rng);
299
300        verify_that!(tracker.accumulated_blp, near(MAX_INTERVAL_S, f64::EPSILON))
301    }
302}