1use alloc::vec::Vec;
12
13use serde::{Deserialize, Serialize};
14
15pub trait RandomSource {
17 fn roll_percent(&mut self) -> u8;
19
20 fn roll_below(&mut self, n: u32) -> u32;
23}
24
25const PCG_MULTIPLIER: u64 = 6_364_136_223_846_793_005;
29
30const DEFAULT_STREAM: u64 = 54;
33
34const PERCENT_LIMIT: u32 = (u32::MAX / 100) * 100;
37
38const MAX_REDRAWS: u32 = 16;
40
41#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
43#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
44pub struct SimRng {
45 state: u64,
46 inc: u64,
47}
48
49impl SimRng {
50 pub fn new(seed: u64) -> Self {
52 Self::with_stream(seed, DEFAULT_STREAM)
53 }
54
55 fn with_stream(seed: u64, stream: u64) -> Self {
57 let mut rng = Self {
58 state: 0,
59 inc: (stream << 1) + 1,
60 };
61 rng.next_u32();
62 rng.state = rng.state.wrapping_add(seed);
63 rng.next_u32();
64 rng
65 }
66
67 pub fn next_u32(&mut self) -> u32 {
69 let old = self.state;
70 self.state = old.wrapping_mul(PCG_MULTIPLIER).wrapping_add(self.inc);
71 #[allow(clippy::cast_possible_truncation)]
74 let xorshifted = (((old >> 18) ^ old) >> 27) as u32;
75 #[allow(clippy::cast_possible_truncation)]
76 let rot = (old >> 59) as u32;
77 xorshifted.rotate_right(rot)
78 }
79
80 pub fn roll_percent(&mut self) -> u8 {
86 let mut x = self.next_u32();
87 for _ in 0..MAX_REDRAWS {
88 if accepted(x) {
89 break;
90 }
91 x = self.next_u32();
92 }
93 u8::try_from(x % 100).unwrap_or(u8::MAX)
94 }
95
96 pub fn roll_below(&mut self, n: u32) -> u32 {
100 if n == 0 {
101 return 0;
102 }
103 let mut x = self.next_u32();
104 for _ in 0..MAX_REDRAWS {
105 if accepted_below(x, n) {
106 break;
107 }
108 x = self.next_u32();
109 }
110 x % n
111 }
112}
113
114fn below_limit(n: u32) -> u32 {
117 (u32::MAX / n) * n
118}
119
120fn accepted_below(x: u32, n: u32) -> bool {
122 x < below_limit(n)
123}
124
125fn accepted(x: u32) -> bool {
127 x < PERCENT_LIMIT
128}
129
130impl RandomSource for SimRng {
131 fn roll_percent(&mut self) -> u8 {
132 SimRng::roll_percent(self)
133 }
134
135 fn roll_below(&mut self, n: u32) -> u32 {
136 SimRng::roll_below(self, n)
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Default)]
145pub struct ScriptedRng {
146 rolls: Vec<u8>,
147 next: usize,
148 below: Vec<u32>,
149 next_below: usize,
150}
151
152impl ScriptedRng {
153 pub fn new(rolls: impl Into<Vec<u8>>) -> Self {
155 Self {
156 rolls: rolls.into(),
157 ..Self::default()
158 }
159 }
160
161 #[must_use]
164 pub fn with_below(mut self, draws: impl Into<Vec<u32>>) -> Self {
165 self.below = draws.into();
166 self
167 }
168
169 pub fn consumed(&self) -> usize {
171 self.next
172 }
173
174 pub fn consumed_below(&self) -> usize {
176 self.next_below
177 }
178}
179
180impl RandomSource for ScriptedRng {
181 fn roll_percent(&mut self) -> u8 {
185 let Some(&roll) = self.rolls.get(self.next) else {
186 panic!("ScriptedRng ran out after {} rolls", self.next);
187 };
188 self.next += 1;
189 roll
190 }
191
192 fn roll_below(&mut self, n: u32) -> u32 {
197 let Some(&draw) = self.below.get(self.next_below) else {
198 panic!("ScriptedRng ran out after {} draws", self.next_below);
199 };
200 assert!(draw < n.max(1), "scripted draw {draw} is not below {n}");
201 self.next_below += 1;
202 draw
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn percent_limit_is_the_largest_multiple_of_100_in_u32() {
212 assert_eq!(PERCENT_LIMIT % 100, 0);
213 assert_eq!(u64::from(PERCENT_LIMIT) + 100, (1u64 << 32) + 4);
214 assert_eq!(PERCENT_LIMIT, 4_294_967_200);
215 }
216
217 #[test]
218 fn biased_draws_are_rejected() {
219 assert!(accepted(0));
220 assert!(accepted(PERCENT_LIMIT - 1));
221 assert!(!accepted(PERCENT_LIMIT));
222 assert!(!accepted(u32::MAX));
223 }
224
225 #[test]
226 fn matches_pcg32_reference_output() {
227 let mut rng = SimRng::with_stream(42, 54);
229 let got: Vec<u32> = (0..6).map(|_| rng.next_u32()).collect();
230 assert_eq!(
231 got,
232 [
233 0xa15c_02b7,
234 0x7b47_f409,
235 0xba1d_3330,
236 0x83d2_f293,
237 0xbfa4_784b,
238 0xcbed_606e
239 ]
240 );
241 }
242
243 #[test]
244 fn fixed_seed_gives_fixed_rolls() {
245 let mut rng = SimRng::new(2026);
246 let got: Vec<u8> = (0..10).map(|_| rng.roll_percent()).collect();
247 assert_eq!(got, [74, 36, 25, 49, 16, 67, 87, 89, 28, 12]);
248 }
249
250 #[test]
251 fn different_seeds_differ() {
252 let a: Vec<u32> = {
253 let mut r = SimRng::new(1);
254 (0..4).map(|_| r.next_u32()).collect()
255 };
256 let b: Vec<u32> = {
257 let mut r = SimRng::new(2);
258 (0..4).map(|_| r.next_u32()).collect()
259 };
260 assert_ne!(a, b);
261 }
262
263 #[test]
264 fn serialised_rng_resumes_the_same_sequence() {
265 let mut rng = SimRng::new(7);
266 for _ in 0..5 {
267 rng.next_u32();
268 }
269 let text = ron::to_string(&rng).expect("serialise");
270 let mut copy: SimRng = ron::from_str(&text).expect("deserialise");
271 assert_eq!(copy, rng);
272 let a: Vec<u8> = (0..20).map(|_| rng.roll_percent()).collect();
273 let b: Vec<u8> = (0..20).map(|_| copy.roll_percent()).collect();
274 assert_eq!(a, b);
275 }
276
277 #[test]
278 fn roll_percent_is_roughly_uniform() {
279 let mut rng = SimRng::new(12_345);
282 let n = 100_000;
283 let mut counts = [0u32; 100];
284 for _ in 0..n {
285 counts[usize::from(rng.roll_percent())] += 1;
286 }
287 let expected = f64::from(n) / 100.0;
288 let chi2: f64 = counts
289 .iter()
290 .map(|&c| (f64::from(c) - expected).powi(2) / expected)
291 .sum();
292 assert!(chi2 < 155.0, "chi-square {chi2}");
293 }
294
295 #[test]
296 fn roll_percent_trait_matches_inherent() {
297 let mut a = SimRng::new(9);
298 let mut b = SimRng::new(9);
299 for _ in 0..50 {
300 assert_eq!(RandomSource::roll_percent(&mut a), b.roll_percent());
301 }
302 }
303
304 #[test]
305 fn scripted_rng_replays_its_script() {
306 let mut rng = ScriptedRng::new([3, 99, 0]);
307 assert_eq!(rng.consumed(), 0);
308 assert_eq!(rng.roll_percent(), 3);
309 assert_eq!(rng.roll_percent(), 99);
310 assert_eq!(rng.consumed(), 2);
311 assert_eq!(rng.roll_percent(), 0);
312 assert_eq!(rng.consumed(), 3);
313 }
314
315 #[test]
316 fn below_limit_is_a_multiple_of_n() {
317 assert_eq!(below_limit(1), u32::MAX);
318 assert_eq!(below_limit(100), PERCENT_LIMIT);
319 assert_eq!(below_limit(3), u32::MAX);
320 assert_eq!(below_limit(280), 4_294_967_040);
321 assert_eq!(below_limit(u32::MAX), u32::MAX);
322 }
323
324 #[test]
325 fn biased_below_draws_are_rejected() {
326 assert!(accepted_below(0, 280));
328 assert!(accepted_below(4_294_967_039, 280));
329 assert!(!accepted_below(4_294_967_040, 280));
330 assert!(!accepted_below(u32::MAX, 280));
331 }
332
333 #[test]
334 fn roll_below_stays_in_range_and_is_fixed_by_the_seed() {
335 let mut rng = SimRng::new(2026);
336 let got: Vec<u32> = (0..10).map(|_| rng.roll_below(280)).collect();
337 assert!(got.iter().all(|&r| r < 280));
338 let mut again = SimRng::new(2026);
339 let same: Vec<u32> = (0..10)
340 .map(|_| RandomSource::roll_below(&mut again, 280))
341 .collect();
342 assert_eq!(got, same);
343 assert_eq!(rng.roll_below(0), 0);
344 assert_eq!(rng.roll_below(1), 0);
345 }
346
347 #[test]
348 fn roll_below_is_roughly_uniform() {
349 let mut rng = SimRng::new(99);
351 let n = 70_000;
352 let mut counts = [0u32; 7];
353 for _ in 0..n {
354 counts[rng.roll_below(7) as usize] += 1;
355 }
356 let expected = f64::from(n) / 7.0;
357 let chi2: f64 = counts
358 .iter()
359 .map(|&c| (f64::from(c) - expected).powi(2) / expected)
360 .sum();
361 assert!(chi2 < 25.0, "chi-square {chi2}");
362 }
363
364 #[test]
365 fn scripted_rng_replays_its_draws() {
366 let mut rng = ScriptedRng::new([4]).with_below([7, 0]);
367 assert_eq!(rng.roll_below(10), 7);
368 assert_eq!(rng.roll_percent(), 4);
369 assert_eq!(rng.roll_below(1), 0);
370 assert_eq!((rng.consumed(), rng.consumed_below()), (1, 2));
371 }
372
373 #[test]
374 #[should_panic(expected = "not below 5")]
375 fn scripted_rng_checks_draws_are_in_range() {
376 ScriptedRng::new([]).with_below([5]).roll_below(5);
377 }
378
379 #[test]
380 #[should_panic(expected = "ran out after 0 draws")]
381 fn scripted_rng_panics_when_draws_run_out() {
382 ScriptedRng::new([]).roll_below(5);
383 }
384
385 #[test]
386 #[should_panic(expected = "ran out after 1 rolls")]
387 fn scripted_rng_panics_when_exhausted() {
388 let mut rng = ScriptedRng::new([5]);
389 rng.roll_percent();
390 rng.roll_percent();
391 }
392}