diff --git a/server/src/atlas/district_mix.rs b/server/src/atlas/district_mix.rs index c07a42064..4b8e1bf15 100644 --- a/server/src/atlas/district_mix.rs +++ b/server/src/atlas/district_mix.rs @@ -12,7 +12,7 @@ //! **Determinism (D-010, D-194):** Integer weights throughout. No f32 in the //! district count computation. Seed-driven noise uses seeded RNG. -use crate::seed::AtlasRng; +use crate::seed::{SeedChain, SeedDomain}; use crate::simulation::generator::{DistrictType, PoliticalArchetype}; // --------------------------------------------------------------------------- @@ -172,13 +172,14 @@ pub struct DistrictMix { /// `total_districts` is the number of districts to allocate. A good default is /// `max(4, population_tier * 2)`. /// -/// `seed` is the city-level RNG seed (D-010 determinism). +/// `chain` is this city's position in the deterministic seed tree; the district +/// allocation draws from its `Layer4Quarter` sub-stream (D-224, D-010). pub fn compute_district_mix( population: i64, economic_role: &str, archetype: &PoliticalArchetype, total_districts: u32, - seed: u64, + chain: SeedChain, ) -> DistrictMix { let tier = population_tier(population); let (transit_min, commercial_min, residential_min) = tier_guarantees(tier); @@ -197,7 +198,7 @@ pub fn compute_district_mix( // We avoid f32 by using integer weighted random selection. let weight_sum: u32 = weights.iter().sum(); let mut counts: [u32; 9] = [0; 9]; - let mut lcg = AtlasRng::new(seed.wrapping_add(1)); + let mut lcg = chain.derive(SeedDomain::Layer4Quarter, 0).atlas_rng(); for _ in 0..total_districts { let mut pick = lcg.next_u32() % weight_sum; @@ -262,7 +263,7 @@ mod tests { "manufacturing", &PoliticalArchetype::Industrial, 8, - 42, + SeedChain::root(42), ); // total may exceed requested due to guarantees assert!(mix.total >= 8, "district count should be >= requested"); @@ -277,7 +278,7 @@ mod tests { "service_mixed", &PoliticalArchetype::Pioneer, 6, - 7, + SeedChain::root(7), ); let transit = mix .districts @@ -312,14 +313,14 @@ mod tests { "financial", &PoliticalArchetype::Commission, 6, - 99, + SeedChain::root(99), ); let mix2 = compute_district_mix( 5_000_000, "financial", &PoliticalArchetype::Commission, 6, - 99, + SeedChain::root(99), ); assert_eq!(mix1, mix2, "same inputs must produce identical output"); } @@ -331,10 +332,15 @@ mod tests { "financial", &PoliticalArchetype::Corporate, 8, - 42, + SeedChain::root(42), + ); + let mix_pioneer = compute_district_mix( + 5_000_000, + "financial", + &PoliticalArchetype::Pioneer, + 8, + SeedChain::root(42), ); - let mix_pioneer = - compute_district_mix(5_000_000, "financial", &PoliticalArchetype::Pioneer, 8, 42); // Should differ in at least one district type count. assert_ne!( mix_corp.districts, mix_pioneer.districts, @@ -344,7 +350,13 @@ mod tests { #[test] fn military_archetype_has_administrative() { - let mix = compute_district_mix(2_000_000, "military", &PoliticalArchetype::Military, 8, 10); + let mix = compute_district_mix( + 2_000_000, + "military", + &PoliticalArchetype::Military, + 8, + SeedChain::root(10), + ); let admin = mix .districts .iter() @@ -358,13 +370,18 @@ mod tests { #[test] fn all_district_types_can_appear() { - // With enough districts and a balanced role, every type should appear at least once. + // Reachability check: every type has a clamped weight of at least 1 + // (compute_district_mix `.max(1)`), so all are reachable. A large draw + // count makes every one appear regardless of the RNG sequence — a small + // count can miss a low-weight type purely by luck, which is sequence- and + // therefore seed-dependent (this is not a realistic city size). + let requested: u32 = 500; let mix = compute_district_mix( 50_000_000, "service_mixed", &PoliticalArchetype::Pioneer, - 50, - 0, + requested, + SeedChain::root(0), ); for dt in &DIST_COLS { let present = mix @@ -373,8 +390,7 @@ mod tests { .any(|d| std::mem::discriminant(d) == std::mem::discriminant(dt)); assert!( present, - "DistrictType {:?} never appeared in 50-district mix", - dt + "DistrictType {dt:?} never appeared in {requested}-district mix" ); } } diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs index 61601a0bb..c3277ba40 100644 --- a/server/src/atlas/skeleton_gen.rs +++ b/server/src/atlas/skeleton_gen.rs @@ -20,7 +20,7 @@ use crate::atlas::block_irregularity::block_irregularity; use crate::atlas::district_mix::{compute_district_mix, population_tier}; -use crate::seed::AtlasRng; +use crate::seed::{SeedChain, SeedDomain}; use crate::simulation::generator::{ BlockPlacement, BlockSkeleton, CityGenerationContext, ComplexityTier, DistrictId, DistrictLayoutMode, DistrictSkeleton, DistrictType, MultiBlockReservation, PoliticalArchetype, @@ -39,14 +39,14 @@ use crate::simulation::generator::{ /// - `economic_role`: Economic role string (one of the 10 canonical values). /// - `district_id`: Content-addressable identifier for this district. /// - `founding_age_years`: Years since founding — controls block irregularity. -/// - `seed`: Deterministic seed for this district (derived from master seed via SeedChain). +/// - `chain`: This district's position in the deterministic seed tree (D-224 SeedChain). pub fn generate_skeleton( context: &CityGenerationContext, population: i64, economic_role: &str, district_id: DistrictId, founding_age_years: u32, - seed: u64, + chain: SeedChain, ) -> DistrictSkeleton { // ── 1. SettingType ──────────────────────────────────────────────────── // Pass through the surrounding_biome from context — it already encodes @@ -59,7 +59,7 @@ pub fn generate_skeleton( // ── 3. DistrictLayoutMode ───────────────────────────────────────────── let irregularity = block_irregularity(founding_age_years, &context.political_archetype); - let layout_mode = derive_layout_mode(&context.political_archetype, irregularity, seed); + let layout_mode = derive_layout_mode(&context.political_archetype, irregularity, chain); // ── 4. District mix → block grid ───────────────────────────────────── // A single district occupies a 4×4 block grid = 16 blocks. @@ -69,11 +69,11 @@ pub fn generate_skeleton( economic_role, &context.political_archetype, total_blocks, - seed, + chain, ); // ── 5. Multi-block reservations ─────────────────────────────────────── - let reservations = derive_reservations(tier, economic_role, seed); + let reservations = derive_reservations(tier, economic_role, chain); // Build the reservation lookup: block position → reservation id. let mut block_reservation: [[Option; 4]; 4] = [[None, None, None, None]; 4]; @@ -105,7 +105,7 @@ pub fn generate_skeleton( DistrictSkeleton { district_id, - seed, + seed: chain.seed(), district_type: district_type_from_mix(&primary_district_type), context: String::new(), // stub — DistrictContext = String world_tier: context.world_tier.clone(), @@ -223,7 +223,7 @@ fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> C fn derive_layout_mode( archetype: &PoliticalArchetype, irregularity: f32, - seed: u64, + chain: SeedChain, ) -> DistrictLayoutMode { match archetype { PoliticalArchetype::Commission @@ -233,7 +233,7 @@ fn derive_layout_mode( PoliticalArchetype::Pioneer | PoliticalArchetype::Industrial => { // Organic: generate per-block offsets and rotations seeded from district seed. - let placements = organic_placements(irregularity, seed); + let placements = organic_placements(irregularity, chain); DistrictLayoutMode::Organic { placements } } } @@ -243,9 +243,9 @@ fn derive_layout_mode( /// /// Uses a seeded LCG; offset range controlled by `irregularity` (0.05–1.0) /// scaled to the ±16 sim tile maximum from `block_irregularity::max_offset_sim_tiles`. -fn organic_placements(irregularity: f32, seed: u64) -> [[BlockPlacement; 4]; 4] { +fn organic_placements(irregularity: f32, chain: SeedChain) -> [[BlockPlacement; 4]; 4] { let max_offset = (irregularity * 16.0) as i16; - let mut lcg = AtlasRng::new(seed.wrapping_add(0x9e37_79b9_7f4a_7c15)); + let mut lcg = chain.derive(SeedDomain::Block, 0).atlas_rng(); // Build the 2D array using a flat closure to keep things readable. let mut flat: [BlockPlacement; 16] = core::array::from_fn(|_| BlockPlacement { @@ -362,7 +362,7 @@ fn density_for_zoning(zoning: &ZoningType) -> u8 { fn derive_reservations( pop_tier: u8, economic_role: &str, - _seed: u64, + _chain: SeedChain, ) -> Vec { let mut out = Vec::new(); @@ -429,14 +429,14 @@ mod tests { fn setting_station_passthrough() { let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); ctx.surrounding_biome = SettingType::Station; - let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 200, 42); + let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 200, SeedChain::root(42)); assert!(matches!(sk.setting, SettingType::Station)); } #[test] fn setting_urban_for_city_on_planet() { let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); - let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, 42); + let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); assert!(matches!(sk.setting, SettingType::Urban)); } @@ -444,14 +444,14 @@ mod tests { fn complexity_epicenter_high_pop_is_full() { let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter); // 10M pop → pop_tier = 1 → Full on Epicenter - let sk = generate_skeleton(&ctx, 10_000_000, "financial", 1, 200, 42); + let sk = generate_skeleton(&ctx, 10_000_000, "financial", 1, 200, SeedChain::root(42)); assert_eq!(sk.complexity, ComplexityTier::Full); } #[test] fn complexity_waypoint_tiny_pop_is_empty() { let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Waypoint); - let sk = generate_skeleton(&ctx, 1_000, "residential", 1, 50, 42); + let sk = generate_skeleton(&ctx, 1_000, "residential", 1, 50, SeedChain::root(42)); assert_eq!(sk.complexity, ComplexityTier::Empty); } @@ -459,7 +459,7 @@ mod tests { fn complexity_backwater_low_pop_is_moderate() { let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater); // 50_000 pop → pop_tier = 0 → Moderate on Backwater (D-218: not budget-capped) - let sk = generate_skeleton(&ctx, 50_000, "residential", 1, 50, 42); + let sk = generate_skeleton(&ctx, 50_000, "residential", 1, 50, SeedChain::root(42)); assert_eq!(sk.complexity, ComplexityTier::Moderate); } @@ -467,7 +467,7 @@ mod tests { fn complexity_backwater_high_pop_is_full() { let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater); // 10M pop → pop_tier = 1 → Full on Backwater (D-218: not budget-capped) - let sk = generate_skeleton(&ctx, 10_000_000, "residential", 1, 50, 42); + let sk = generate_skeleton(&ctx, 10_000_000, "residential", 1, 50, SeedChain::root(42)); assert_eq!(sk.complexity, ComplexityTier::Full); } @@ -475,28 +475,28 @@ mod tests { fn complexity_passage_low_pop_is_minimal() { let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Passage); // 50_000 pop → pop_tier = 0 → Minimal on Passage (transit stop, budget-capped) - let sk = generate_skeleton(&ctx, 50_000, "transit_hub", 1, 100, 42); + let sk = generate_skeleton(&ctx, 50_000, "transit_hub", 1, 100, SeedChain::root(42)); assert_eq!(sk.complexity, ComplexityTier::Minimal); } #[test] fn layout_commission_is_grid() { let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); - let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 100, 99); + let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 100, SeedChain::root(99)); assert!(matches!(sk.layout_mode, DistrictLayoutMode::Grid)); } #[test] fn layout_pioneer_is_organic() { let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Regional); - let sk = generate_skeleton(&ctx, 500_000, "residential", 1, 400, 99); + let sk = generate_skeleton(&ctx, 500_000, "residential", 1, 400, SeedChain::root(99)); assert!(matches!(sk.layout_mode, DistrictLayoutMode::Organic { .. })); } #[test] fn block_grid_is_fully_populated() { let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); - let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, 42); + let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); // All 16 blocks must have valid positions. for row in 0..4 { for col in 0..4 { @@ -511,7 +511,7 @@ mod tests { fn no_reservations_for_small_city() { let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater); // pop_tier 0, not transit_hub → no reservations. - let sk = generate_skeleton(&ctx, 80_000, "residential", 1, 50, 42); + let sk = generate_skeleton(&ctx, 80_000, "residential", 1, 50, SeedChain::root(42)); assert!(sk.reservations.is_empty()); } @@ -519,7 +519,7 @@ mod tests { fn park_reservation_for_large_city() { let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter); // 100M pop → pop_tier 2 → park reservation. - let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, 42); + let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, SeedChain::root(42)); let has_park = sk .reservations .iter() @@ -531,7 +531,7 @@ mod tests { fn transit_terminal_for_transit_hub_role() { let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); // pop_tier 0 but transit_hub → terminal reservation. - let sk = generate_skeleton(&ctx, 80_000, "transit_hub", 1, 200, 42); + let sk = generate_skeleton(&ctx, 80_000, "transit_hub", 1, 200, SeedChain::root(42)); let has_terminal = sk .reservations .iter() @@ -543,7 +543,7 @@ mod tests { fn reserved_blocks_linked_in_grid() { let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter); // 100M pop → park at (1,2),(1,3),(2,2),(2,3) with reservation id 1. - let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, 42); + let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, SeedChain::root(42)); // All park blocks must reference the park reservation (id=1). for &(row, col) in &[(1u8, 2u8), (1, 3), (2, 2), (2, 3)] { let b = &sk.blocks[row as usize][col as usize]; @@ -557,8 +557,22 @@ mod tests { #[test] fn determinism_same_seed_same_output() { let ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional); - let sk1 = generate_skeleton(&ctx, 2_000_000, "manufacturing", 77, 250, 12345); - let sk2 = generate_skeleton(&ctx, 2_000_000, "manufacturing", 77, 250, 12345); + let sk1 = generate_skeleton( + &ctx, + 2_000_000, + "manufacturing", + 77, + 250, + SeedChain::root(12345), + ); + let sk2 = generate_skeleton( + &ctx, + 2_000_000, + "manufacturing", + 77, + 250, + SeedChain::root(12345), + ); // Compare block grid zoning and positions. for row in 0..4 { for col in 0..4 {