wowlab_engine_rng/stochastic/
rppm.rs1const MAX_INTERVAL_S: f64 = 3.5;
2
3const 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;
10const 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#[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#[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
55pub 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 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 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}