Review fixes from Hoshe + Tyre: - EntityRng seeding: splitmix64(seed) ^ splitmix64(id) instead of splitmix64(seed + id) — eliminates collision class where adjacent seeds produce identical streams - AtomicBool ordering: Relaxed → SeqCst for shutdown flag (correct on weakly-ordered architectures) - Worker Drop: join handles instead of detaching threads - Normalize stub API: remove ChunkGenWorker convenience wrappers, use .pool consistently across all 3 workers - trigger_monologue: downgrade &mut to shared refs (no-op anchor was blocking parallel systems) - Remove dead SimRng inserts from migrated monologue tests - Document determinism gap on poll_worker_results - Document bevy_tasks/rayon dep rationale in Cargo.toml Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
126 lines
4.1 KiB
Rust
126 lines
4.1 KiB
Rust
// Simulation RNG system
|
|
// Injectable ChaCha RNG resource for deterministic replay (D-030)
|
|
// Ensures same seed produces same outcomes
|
|
|
|
use bevy_ecs::prelude::*;
|
|
use rand::SeedableRng;
|
|
use rand_chacha::ChaCha20Rng;
|
|
|
|
/// Simulation RNG resource — shared world-level randomness.
|
|
///
|
|
/// Used for non-entity randomness: storyteller dice, world events, ticker rotation.
|
|
/// For entity-level randomness, use [`EntityRng`] instead — it enables parallelism
|
|
/// because each entity has its own independent, deterministic RNG stream.
|
|
#[derive(Resource)]
|
|
pub struct SimRng {
|
|
pub rng: ChaCha20Rng,
|
|
seed: u64,
|
|
}
|
|
|
|
impl SimRng {
|
|
/// Create a new SimRng with the given seed
|
|
pub fn new(seed: u64) -> Self {
|
|
Self {
|
|
rng: ChaCha20Rng::seed_from_u64(seed),
|
|
seed,
|
|
}
|
|
}
|
|
|
|
/// Get the seed used to initialize this RNG
|
|
pub fn seed(&self) -> u64 {
|
|
self.seed
|
|
}
|
|
}
|
|
|
|
/// Per-entity RNG component — deterministic, parallel-safe (#843).
|
|
///
|
|
/// Each entity gets its own ChaCha20 stream seeded from `world_seed + StableId`.
|
|
/// Systems that need randomness for a specific entity query `&mut EntityRng`
|
|
/// instead of `ResMut<SimRng>`. This unlocks parallelism: Bevy can run systems
|
|
/// on disjoint entity sets concurrently.
|
|
///
|
|
/// Determinism: same `world_seed` + same `StableId` = same RNG sequence,
|
|
/// regardless of thread execution order.
|
|
#[derive(Component)]
|
|
pub struct EntityRng {
|
|
pub rng: ChaCha20Rng,
|
|
}
|
|
|
|
impl EntityRng {
|
|
/// Create a new EntityRng from a world seed and entity stable ID.
|
|
///
|
|
/// Uses splitmix64 mixing to combine the two seeds with good avalanche
|
|
/// properties — avoids correlated sequences for entities with similar IDs.
|
|
pub fn from_seed_and_id(world_seed: u64, stable_id: u64) -> Self {
|
|
// Mix each input independently then XOR — avoids the collision class
|
|
// where (seed=0, id=N) == (seed=1, id=N-1) that wrapping_add creates.
|
|
let mixed = splitmix64(world_seed) ^ splitmix64(stable_id);
|
|
Self {
|
|
rng: ChaCha20Rng::seed_from_u64(mixed),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Splitmix64 mixing function — good avalanche properties for seed derivation.
|
|
/// Ensures that similar inputs (e.g., consecutive StableIds) produce
|
|
/// uncorrelated output seeds.
|
|
fn splitmix64(mut x: u64) -> u64 {
|
|
x = x.wrapping_add(0x9e3779b97f4a7c15);
|
|
x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
|
|
x = (x ^ (x >> 27)).wrapping_mul(0x94d049bb133111eb);
|
|
x ^ (x >> 31)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use rand::Rng;
|
|
|
|
#[test]
|
|
fn same_seed_same_sequence() {
|
|
let mut rng1 = SimRng::new(42);
|
|
let mut rng2 = SimRng::new(42);
|
|
let vals1: Vec<u32> = (0..100).map(|_| rng1.rng.random()).collect();
|
|
let vals2: Vec<u32> = (0..100).map(|_| rng2.rng.random()).collect();
|
|
assert_eq!(vals1, vals2);
|
|
}
|
|
|
|
#[test]
|
|
fn different_seed_different_sequence() {
|
|
let mut rng1 = SimRng::new(42);
|
|
let mut rng2 = SimRng::new(43);
|
|
let val1: u32 = rng1.rng.random();
|
|
let val2: u32 = rng2.rng.random();
|
|
assert_ne!(val1, val2);
|
|
}
|
|
|
|
#[test]
|
|
fn entity_rng_same_seed_same_id_same_sequence() {
|
|
let mut a = EntityRng::from_seed_and_id(42, 7);
|
|
let mut b = EntityRng::from_seed_and_id(42, 7);
|
|
let va: Vec<u32> = (0..100).map(|_| a.rng.random()).collect();
|
|
let vb: Vec<u32> = (0..100).map(|_| b.rng.random()).collect();
|
|
assert_eq!(va, vb);
|
|
}
|
|
|
|
#[test]
|
|
fn entity_rng_same_seed_different_id_different_sequence() {
|
|
let mut a = EntityRng::from_seed_and_id(42, 0);
|
|
let mut b = EntityRng::from_seed_and_id(42, 1);
|
|
let va: u32 = a.rng.random();
|
|
let vb: u32 = b.rng.random();
|
|
assert_ne!(va, vb);
|
|
}
|
|
|
|
#[test]
|
|
fn entity_rng_adjacent_seeds_no_collision() {
|
|
// Verify (seed=0, id=N) != (seed=1, id=N-1) — the collision class
|
|
// that wrapping_add would create.
|
|
let mut a = EntityRng::from_seed_and_id(0, 5);
|
|
let mut b = EntityRng::from_seed_and_id(1, 4);
|
|
let va: u32 = a.rng.random();
|
|
let vb: u32 = b.rng.random();
|
|
assert_ne!(va, vb);
|
|
}
|
|
}
|