//! EntanglementConfig — per-seed NPC population entanglement ratios (D-029, #175, #178). //! //! Per D-029: NPC population split is ~30% flat / ~50% mundane / ~20% intrigue. //! The entanglement rate varies per world seed to prevent player metagaming calibration //! across playthroughs. Two runs with the same seed must produce identical ratios; //! two runs with different seeds must (in ≥90% of cases) produce different ratios. //! //! ## Acceptance criteria (#175 / #178) //! //! 1. `EntanglementConfig::from_seed(seed_a) == EntanglementConfig::from_seed(seed_a)` (deterministic) //! 2. `EntanglementConfig::from_seed(seed_a) != EntanglementConfig::from_seed(seed_b)` for ≥90% of random pairs //! 3. `flat_ratio + mundane_ratio + intrigue_ratio == 100` //! 4. Ratios stay within bounds: flat ∈ [25,35], mundane ∈ [45,55], intrigue ∈ [15,25] //! //! ## Wire format (#175) //! //! The world seed flows: client new_game() → world_seed field in session startup IPC → //! server reads seed → SimRng::from_seed(seed) → EntanglementConfig::from_rng(&mut rng). //! This means two clients using the same seed produce identical NPC populations. use crate::simulation::rng::SimRng; use rand::Rng; /// NPC population entanglement ratios for one world seed. /// /// All ratios are percentages (integer, sum to 100). /// Ranges per D-029: flat 25-35%, mundane 45-55%, intrigue 15-25%. #[derive(Debug, Clone, PartialEq, Eq)] pub struct EntanglementConfig { /// % of NPCs with purely flat routines — social wallpaper, no triangle involvement pub flat_ratio: u8, /// % of NPCs in mundane triangles — neighbor disputes, workplace rivalries, no conspiracy pub mundane_ratio: u8, /// % of NPCs entangled with intrigue content — connected to conspiracy modules pub intrigue_ratio: u8, } impl EntanglementConfig { /// Sample entanglement ratios from the given RNG. /// /// Must be called exactly once at session start after `SimRng::new(world_seed)`. /// Subsequent calls to the same seeded RNG will produce different values /// (the RNG state advances), so `from_seed()` is the canonical API for tests. pub fn from_rng(rng: &mut SimRng) -> Self { // Sample flat_ratio ∈ [25, 35] — step of 1% let flat: u8 = rng.rng.random_range(25u8..=35u8); // Constrain intrigue range so mundane = 100 - flat - intrigue stays in [45, 55]. // mundane ≥ 45 → intrigue ≤ 55 - flat; mundane ≤ 55 → intrigue ≥ 45 - flat. // Intersect with D-029 base range [15, 25]. let intrigue_min: u8 = (45u8.saturating_sub(flat)).max(15); let intrigue_max: u8 = (55u8.saturating_sub(flat)).min(25); let intrigue: u8 = rng.rng.random_range(intrigue_min..=intrigue_max); // Mundane fills the remainder (ensures sum = 100, stays in [45, 55]) let mundane: u8 = 100 - flat - intrigue; Self { flat_ratio: flat, mundane_ratio: mundane, intrigue_ratio: intrigue, } } /// Convenience: create EntanglementConfig from a raw seed value. /// /// Equivalent to `EntanglementConfig::from_rng(&mut SimRng::new(seed))`. /// Use in tests for determinism assertions. pub fn from_seed(seed: u64) -> Self { let mut rng = SimRng::new(seed); Self::from_rng(&mut rng) } /// Verify internal consistency: ratios must sum to 100. pub fn is_valid(&self) -> bool { self.flat_ratio as u16 + self.mundane_ratio as u16 + self.intrigue_ratio as u16 == 100 } } #[cfg(test)] mod tests { use super::*; // ------------------------------------------------------------------------- // Acceptance criterion 1: Determinism (#178) // EntanglementConfig::from_seed(seed_A) == EntanglementConfig::from_seed(seed_A) // ------------------------------------------------------------------------- #[test] fn same_seed_produces_same_config() { // D-010 / D-029: deterministic simulation must produce identical NPC populations // for the same world seed across all playthroughs. let config_a = EntanglementConfig::from_seed(42); let config_b = EntanglementConfig::from_seed(42); assert_eq!( config_a, config_b, "Same world seed must produce identical EntanglementConfig (D-010 determinism)" ); } #[test] fn determinism_holds_for_multiple_seeds() { // Spot-check several seeds to ensure the determinism invariant holds broadly. for seed in [0u64, 1, 100, 9999, u64::MAX / 2, u64::MAX] { let c1 = EntanglementConfig::from_seed(seed); let c2 = EntanglementConfig::from_seed(seed); assert_eq!( c1, c2, "Seed {seed}: EntanglementConfig must be deterministic" ); } } // ------------------------------------------------------------------------- // Acceptance criterion 2: Variation (#178) // from_seed(A) != from_seed(B) for ≥90% of random seed pairs // ------------------------------------------------------------------------- #[test] fn different_seeds_produce_different_configs_at_least_90_percent() { // D-029: entanglement rate varies per seed to prevent metagaming calibration. // ≥90% of random seed pairs must produce distinct EntanglementConfig values. let test_seeds: Vec = (0u64..100).collect(); let configs: Vec = test_seeds.iter().map(|&s| EntanglementConfig::from_seed(s)).collect(); let mut distinct_pairs: usize = 0; let mut total_pairs: usize = 0; for i in 0..configs.len() { for j in (i + 1)..configs.len() { total_pairs += 1; if configs[i] != configs[j] { distinct_pairs += 1; } } } let ratio = distinct_pairs as f64 / total_pairs as f64; assert!( ratio >= 0.90, "Only {}/{} ({:.1}%) seed pairs produced distinct EntanglementConfig — need ≥90% (D-029)", distinct_pairs, total_pairs, ratio * 100.0 ); } // ------------------------------------------------------------------------- // Acceptance criterion 3: Ratios sum to 100 // ------------------------------------------------------------------------- #[test] fn ratios_sum_to_100() { // Invariant: flat + mundane + intrigue == 100 for any seed. for seed in [0u64, 1, 42, 12345, u64::MAX] { let c = EntanglementConfig::from_seed(seed); assert!( c.is_valid(), "Seed {seed}: ratios must sum to 100, got {}+{}+{}={}", c.flat_ratio, c.mundane_ratio, c.intrigue_ratio, c.flat_ratio as u16 + c.mundane_ratio as u16 + c.intrigue_ratio as u16 ); } } // ------------------------------------------------------------------------- // Acceptance criterion 4: Ratios within D-029 bounds // ------------------------------------------------------------------------- #[test] fn flat_ratio_within_bounds() { // D-029: flat ∈ [25, 35]% for seed in 0u64..200 { let c = EntanglementConfig::from_seed(seed); assert!( c.flat_ratio >= 25 && c.flat_ratio <= 35, "Seed {seed}: flat_ratio {} out of [25, 35] bounds", c.flat_ratio ); } } #[test] fn mundane_ratio_within_bounds() { // D-029: mundane ∈ [45, 55]% // Achieved by constraining intrigue range based on flat value so that // mundane = 100 - flat - intrigue always stays within spec bounds. for seed in 0u64..200 { let c = EntanglementConfig::from_seed(seed); assert!( c.is_valid(), "Seed {seed}: ratios must sum to 100" ); assert!( c.mundane_ratio >= 45 && c.mundane_ratio <= 55, "Seed {seed}: mundane_ratio {} out of D-029 [45, 55] bounds", c.mundane_ratio ); } } #[test] fn intrigue_ratio_within_bounds() { // D-029: intrigue ∈ [15, 25]% for seed in 0u64..200 { let c = EntanglementConfig::from_seed(seed); assert!( c.intrigue_ratio >= 15 && c.intrigue_ratio <= 25, "Seed {seed}: intrigue_ratio {} out of [15, 25] bounds", c.intrigue_ratio ); } } // ------------------------------------------------------------------------- // Edge cases // ------------------------------------------------------------------------- #[test] fn seed_zero_produces_valid_config() { let c = EntanglementConfig::from_seed(0); assert!(c.is_valid(), "Seed 0 must produce valid config"); } #[test] fn seed_max_produces_valid_config() { let c = EntanglementConfig::from_seed(u64::MAX); assert!(c.is_valid(), "Seed u64::MAX must produce valid config"); } #[test] fn from_rng_and_from_seed_are_consistent() { // from_seed() is the canonical API; from_rng() is the runtime API. // When given a freshly-seeded SimRng, from_rng() must match from_seed(). let seed = 999u64; let via_seed = EntanglementConfig::from_seed(seed); let mut rng = SimRng::new(seed); let via_rng = EntanglementConfig::from_rng(&mut rng); assert_eq!( via_seed, via_rng, "from_seed() and from_rng(SimRng::new(seed)) must produce identical results" ); } }