- Widen world_seed entropy from u32 to full u64 by combining two randi() calls (Hoshe warning #1) - Persist world_seed to save directory and restore on resume_game() so loaded sessions maintain D-010 deterministic replay (Tyre warning #2) - Constrain EntanglementConfig intrigue range based on flat value so mundane_ratio stays within D-029 spec [45,55]% (both reviewers) - Remove dead VIS_PERIPHERAL constant and _grow_bounds() method - Update test_client_p1 peripheral test for forward-only simplification - Fix misleading exp_fade shader comment (filter_nearest = hard step) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
244 lines
9.6 KiB
Rust
244 lines
9.6 KiB
Rust
//! 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<u64> = (0u64..100).collect();
|
|
let configs: Vec<EntanglementConfig> =
|
|
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"
|
|
);
|
|
}
|
|
}
|