Skip to main content

brink_runtime/
rng.rs

1//! Pluggable PRNG for story randomization.
2//!
3//! Two built-in implementations:
4//! - [`FastRng`]: xorshift32, the default for production use.
5//! - [`DotNetRng`]: port of .NET `System.Random` (Knuth subtractive generator),
6//!   used for corpus tests requiring exact match with the reference C# runtime.
7
8/// Trait for story-level random number generation.
9///
10/// Implementations must be seedable from an `i32` and produce non-negative
11/// `i32` values. The runtime constructs a fresh RNG for each random operation
12/// using a deterministic seed derived from story state.
13pub trait StoryRng {
14    /// Create a new RNG from the given seed.
15    fn from_seed(seed: i32) -> Self;
16    /// Return a non-negative random `i32`.
17    fn next_int(&mut self) -> i32;
18}
19
20// ── FastRng ─────────────────────────────────────────────────────────────────
21
22/// Xorshift32-based PRNG. Fast, decent distribution, not .NET-compatible.
23#[derive(Clone)]
24pub struct FastRng {
25    state: u32,
26}
27
28impl StoryRng for FastRng {
29    #[expect(clippy::cast_sign_loss)]
30    fn from_seed(seed: i32) -> Self {
31        // Avoid zero state (xorshift32 fixpoint).
32        let s = seed as u32;
33        Self {
34            state: if s == 0 { 1 } else { s },
35        }
36    }
37
38    #[expect(clippy::cast_possible_wrap)]
39    fn next_int(&mut self) -> i32 {
40        let mut x = self.state;
41        x ^= x << 13;
42        x ^= x >> 17;
43        x ^= x << 5;
44        self.state = x;
45        // Mask off sign bit to guarantee non-negative.
46        (x & 0x7FFF_FFFF) as i32
47    }
48}
49
50// ── DotNetRng ───────────────────────────────────────────────────────────────
51
52const MBIG: i32 = i32::MAX; // 2147483647
53const MSEED: i32 = 161_803_398;
54const SEED_ARRAY_LEN: usize = 56; // index 0 unused, 1..55 active
55
56/// Port of .NET `System.Random` (Knuth subtractive generator).
57///
58/// Reproduces the exact sequence of the reference ink C# runtime so that
59/// corpus tests can match expected transcripts.
60#[derive(Clone)]
61pub struct DotNetRng {
62    seed_array: [i32; SEED_ARRAY_LEN],
63    inext: i32,
64    inextp: i32,
65}
66
67impl StoryRng for DotNetRng {
68    fn from_seed(seed: i32) -> Self {
69        let mut seed_array = [0i32; SEED_ARRAY_LEN];
70
71        // .NET constructor logic (mscorlib System.Random)
72        let subtraction = if seed == i32::MIN {
73            i32::MAX
74        } else {
75            seed.abs()
76        };
77        let mut mj = MSEED.wrapping_sub(subtraction);
78        seed_array[55] = mj;
79        let mut mk: i32 = 1;
80
81        for i in 1..55 {
82            // Scatter: map i → index in [1..55] via (21*i) % 55
83            let ii = (21_usize.wrapping_mul(i)) % 55;
84            seed_array[ii] = mk;
85            mk = mj.wrapping_sub(mk);
86            if mk < 0 {
87                mk = mk.wrapping_add(MBIG);
88            }
89            mj = seed_array[ii];
90        }
91
92        for _k in 1..5 {
93            for i in 1..56 {
94                let idx = 1 + (i + 30) % 55;
95                seed_array[i] = seed_array[i].wrapping_sub(seed_array[idx]);
96                if seed_array[i] < 0 {
97                    seed_array[i] = seed_array[i].wrapping_add(MBIG);
98                }
99            }
100        }
101
102        Self {
103            seed_array,
104            inext: 0,
105            inextp: 21,
106        }
107    }
108
109    #[expect(clippy::cast_sign_loss)]
110    fn next_int(&mut self) -> i32 {
111        let mut inext = self.inext + 1;
112        if inext >= 56 {
113            inext = 1;
114        }
115        let mut inextp = self.inextp + 1;
116        if inextp >= 56 {
117            inextp = 1;
118        }
119
120        let mut num =
121            self.seed_array[inext as usize].wrapping_sub(self.seed_array[inextp as usize]);
122        if num == MBIG {
123            num -= 1;
124        }
125        if num < 0 {
126            num = num.wrapping_add(MBIG);
127        }
128
129        self.seed_array[inext as usize] = num;
130        self.inext = inext;
131        self.inextp = inextp;
132
133        num
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    /// Validate `DotNetRng` against known .NET System.Random output.
142    /// Seed = 0: first 5 values from .NET are:
143    ///   1559595546, 1755192844, 1649316166, 1198642031, 442452829
144    #[test]
145    fn dotnet_rng_seed_0_sequence() {
146        let mut rng = DotNetRng::from_seed(0);
147        assert_eq!(rng.next_int(), 1_559_595_546);
148        assert_eq!(rng.next_int(), 1_755_192_844);
149        assert_eq!(rng.next_int(), 1_649_316_166);
150        assert_eq!(rng.next_int(), 1_198_642_031);
151        assert_eq!(rng.next_int(), 442_452_829);
152    }
153
154    /// Validate `DotNetRng` negative seed (`i32::MIN` edge case).
155    #[test]
156    fn dotnet_rng_negative_seed() {
157        let mut rng = DotNetRng::from_seed(-1);
158        let v = rng.next_int();
159        assert!(
160            v >= 0,
161            "negative seed should still produce non-negative values"
162        );
163    }
164
165    /// All values must be non-negative.
166    #[test]
167    fn dotnet_rng_all_non_negative() {
168        let mut rng = DotNetRng::from_seed(42);
169        for _ in 0..1000 {
170            assert!(rng.next_int() >= 0);
171        }
172    }
173
174    /// `FastRng` should produce non-negative values.
175    #[test]
176    fn fast_rng_all_non_negative() {
177        let mut rng = FastRng::from_seed(42);
178        for _ in 0..1000 {
179            assert!(rng.next_int() >= 0);
180        }
181    }
182
183    /// `FastRng` with seed 0 should not get stuck (zero-state avoidance).
184    #[test]
185    fn fast_rng_seed_zero_not_stuck() {
186        let mut rng = FastRng::from_seed(0);
187        let first = rng.next_int();
188        let second = rng.next_int();
189        assert_ne!(first, 0);
190        assert_ne!(first, second);
191    }
192}