New top-level seed module that owns every deterministic-RNG primitive: - splitmix64 — the one canonical mixer (was duplicated as a private fn in simulation/rng.rs; EntityRng now imports the shared one, no behavior change) - AtlasRng — moved here from atlas/rng.rs (it is a generation-RNG primitive, not atlas-specific); atlas now depends on seed, not the reverse - SeedDomain — append-only domain tags (Body/Layer1Topography/Layer3Settlement/ Layer4Quarter/Block/Npc) for collision-proof per-domain seed separation - SeedChain — root(world_seed) → derive(domain, id) → atlas_rng()/seed(), per the D-224 formula splitmix64(self ^ splitmix64(domain)) ^ splitmix64(id) Structural move only — SeedChain is not yet threaded through the cascade callers (skeleton_gen still uses ad-hoc wrapping_add pre-mixing); that is the next step. Unit tests cover the splitmix64 known-vector, avalanche, determinism, domain/id separation, and chain composition. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
382 lines
13 KiB
Rust
382 lines
13 KiB
Rust
//! Three-component district mix algorithm for city district type distribution (D-194).
|
||
//!
|
||
//! Given a city's population, economic role, and political archetype, produces
|
||
//! a district type distribution (count of each DistrictType) used by the
|
||
//! Phase 1 district skeleton generator.
|
||
//!
|
||
//! **Components (D-194):**
|
||
//! 1. Population tier guarantees — minimum district counts by city size.
|
||
//! 2. 10×9 economic multiplier table — economic role × DistrictType weights.
|
||
//! 3. Political archetype modifiers — shift weights for specific district types.
|
||
//!
|
||
//! **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::simulation::generator::{DistrictType, PoliticalArchetype};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Population tier
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Population tier: `floor(log10(pop / 1_000_000))`, capped at [0, 5].
|
||
pub fn population_tier(population: i64) -> u8 {
|
||
if population <= 0 {
|
||
return 0;
|
||
}
|
||
let ratio = population as f64 / 1_000_000.0;
|
||
if ratio <= 0.0 {
|
||
return 0;
|
||
}
|
||
let tier = ratio.log10().floor() as i32;
|
||
tier.clamp(0, 5) as u8
|
||
}
|
||
|
||
/// Minimum district counts guaranteed by population tier (D-194).
|
||
///
|
||
/// Returns `(transit_min, commercial_min, residential_min)`.
|
||
pub fn tier_guarantees(tier: u8) -> (u32, u32, u32) {
|
||
match tier {
|
||
0 => (0, 0, 1),
|
||
1 => (0, 1, 1),
|
||
2 => (1, 1, 2),
|
||
3 => (1, 2, 3),
|
||
4 => (2, 3, 4),
|
||
5 => (3, 4, 6),
|
||
_ => (3, 4, 6),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Economic multiplier table (10×9, integer weights × 10 for precision)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// District type column order (0–8).
|
||
/// Matches DistrictType enum variants: LogisticsHub, Residential, Commercial,
|
||
/// Industrial, Administrative, Entertainment, MixedUse, Transit, Specialized.
|
||
const DIST_COLS: [DistrictType; 9] = [
|
||
DistrictType::LogisticsHub,
|
||
DistrictType::Residential,
|
||
DistrictType::Commercial,
|
||
DistrictType::Industrial,
|
||
DistrictType::Administrative,
|
||
DistrictType::Entertainment,
|
||
DistrictType::MixedUse,
|
||
DistrictType::Transit,
|
||
DistrictType::Specialized,
|
||
];
|
||
|
||
/// Map a DistrictType to its column index.
|
||
fn dist_col(dt: &DistrictType) -> usize {
|
||
match dt {
|
||
DistrictType::LogisticsHub => 0,
|
||
DistrictType::Residential => 1,
|
||
DistrictType::Commercial => 2,
|
||
DistrictType::Industrial => 3,
|
||
DistrictType::Administrative => 4,
|
||
DistrictType::Entertainment => 5,
|
||
DistrictType::MixedUse => 6,
|
||
DistrictType::Transit => 7,
|
||
DistrictType::Specialized => 8,
|
||
}
|
||
}
|
||
|
||
/// Map an economic role to its row index (0–9).
|
||
fn role_row(economic_role: &str) -> usize {
|
||
match economic_role {
|
||
"manufacturing" => 0,
|
||
"financial" => 1,
|
||
"agricultural" => 2,
|
||
"extraction" => 3,
|
||
"service_mixed" => 4,
|
||
"institutional" => 5,
|
||
"transit_hub" => 6,
|
||
"research" => 7,
|
||
"military" => 8,
|
||
_ => 9,
|
||
}
|
||
}
|
||
|
||
/// 10×9 economic multiplier table. Values are integer weights × 10.
|
||
/// Rows: manufacturing(0), financial(1), agricultural(2), extraction(3),
|
||
/// service_mixed(4), institutional(5), transit_hub(6), research(7),
|
||
/// military(8), residential(9).
|
||
/// Columns: LogisticsHub(0), Residential(1), Commercial(2), Industrial(3),
|
||
/// Administrative(4), Entertainment(5), MixedUse(6), Transit(7),
|
||
/// Specialized(8).
|
||
#[rustfmt::skip]
|
||
const ECON_TABLE: [[u32; 9]; 10] = [
|
||
// LH Re Co In Ad En Mu Tr Sp
|
||
[25, 10, 15, 30, 10, 5, 10, 20, 10], // manufacturing
|
||
[10, 15, 30, 10, 20, 15, 20, 15, 10], // financial
|
||
[20, 20, 10, 15, 10, 5, 20, 10, 5], // agricultural
|
||
[30, 10, 10, 30, 10, 5, 5, 15, 10], // extraction
|
||
[15, 20, 25, 10, 10, 20, 25, 20, 10], // service_mixed
|
||
[10, 15, 10, 10, 30, 10, 10, 10, 20], // institutional
|
||
[25, 10, 15, 10, 10, 10, 10, 30, 10], // transit_hub
|
||
[10, 15, 10, 15, 20, 10, 10, 10, 30], // research
|
||
[10, 20, 5, 15, 20, 5, 5, 10, 15], // military
|
||
[10, 30, 15, 5, 10, 15, 25, 10, 5], // residential
|
||
];
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Political archetype modifiers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Additive integer modifiers to column weights based on `PoliticalArchetype`.
|
||
/// Returns `[mod; 9]` for columns in `DIST_COLS` order.
|
||
fn archetype_modifiers(archetype: &PoliticalArchetype) -> [i32; 9] {
|
||
match archetype {
|
||
PoliticalArchetype::Commission => {
|
||
// Boosts Administrative + Institutional-style Specialized.
|
||
[0, 0, 0, 0, 10, 0, 0, 0, 5]
|
||
}
|
||
PoliticalArchetype::Corporate => {
|
||
// Boosts Commercial + Specialized (restricted campus zones).
|
||
[0, -5, 15, 0, 0, 5, 0, 0, 10]
|
||
}
|
||
PoliticalArchetype::Pioneer => {
|
||
// Boosts MixedUse + organic Residential.
|
||
[0, 10, 5, 0, -5, 5, 15, 0, 0]
|
||
}
|
||
PoliticalArchetype::Military => {
|
||
// Boosts Administrative + reduces Entertainment.
|
||
[0, 5, -5, 5, 15, -10, 0, 0, 10]
|
||
}
|
||
PoliticalArchetype::Academic => {
|
||
// Boosts Specialized (research labs) + Administrative.
|
||
[0, 5, 0, 0, 10, 5, 5, 0, 20]
|
||
}
|
||
PoliticalArchetype::Industrial => {
|
||
// Boosts Industrial + LogisticsHub.
|
||
[10, -5, 5, 20, 0, -5, 0, 5, 5]
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// District mix computation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// The district type distribution for a generated city.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct DistrictMix {
|
||
/// Ordered list of district types for the city, with repetition (district_count items total).
|
||
pub districts: Vec<DistrictType>,
|
||
/// Total district count.
|
||
pub total: u32,
|
||
}
|
||
|
||
/// Compute the district mix for one city (D-194).
|
||
///
|
||
/// `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).
|
||
pub fn compute_district_mix(
|
||
population: i64,
|
||
economic_role: &str,
|
||
archetype: &PoliticalArchetype,
|
||
total_districts: u32,
|
||
seed: u64,
|
||
) -> DistrictMix {
|
||
let tier = population_tier(population);
|
||
let (transit_min, commercial_min, residential_min) = tier_guarantees(tier);
|
||
let row = role_row(economic_role);
|
||
let arch_mods = archetype_modifiers(archetype);
|
||
|
||
// Build effective weights (integer, clamped to ≥ 1).
|
||
let mut weights: [u32; 9] = [0; 9];
|
||
for col in 0..9 {
|
||
let base = ECON_TABLE[row][col] as i32;
|
||
let modified = base + arch_mods[col];
|
||
weights[col] = modified.max(1) as u32;
|
||
}
|
||
|
||
// Allocate districts proportionally from weights using a seeded LCG.
|
||
// 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));
|
||
|
||
for _ in 0..total_districts {
|
||
let mut pick = lcg.next_u32() % weight_sum;
|
||
for col in 0..9 {
|
||
if pick < weights[col] {
|
||
counts[col] += 1;
|
||
break;
|
||
}
|
||
pick -= weights[col];
|
||
}
|
||
}
|
||
|
||
// Apply tier guarantees (add if under minimum).
|
||
let transit_col = dist_col(&DistrictType::Transit);
|
||
let commercial_col = dist_col(&DistrictType::Commercial);
|
||
let residential_col = dist_col(&DistrictType::Residential);
|
||
|
||
if counts[transit_col] < transit_min {
|
||
counts[transit_col] = transit_min;
|
||
}
|
||
if counts[commercial_col] < commercial_min {
|
||
counts[commercial_col] = commercial_min;
|
||
}
|
||
if counts[residential_col] < residential_min {
|
||
counts[residential_col] = residential_min;
|
||
}
|
||
|
||
// Build the flat ordered list.
|
||
let mut districts: Vec<DistrictType> = Vec::new();
|
||
for (col, &count) in counts.iter().enumerate() {
|
||
for _ in 0..count {
|
||
districts.push(DIST_COLS[col].clone());
|
||
}
|
||
}
|
||
|
||
let total = districts.len() as u32;
|
||
DistrictMix { districts, total }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn population_tier_values() {
|
||
assert_eq!(population_tier(0), 0);
|
||
assert_eq!(population_tier(50_000), 0); // 0.05M → log10 < 0 → tier 0
|
||
assert_eq!(population_tier(1_000_000), 0); // 1M → log10(1) = 0 → tier 0
|
||
assert_eq!(population_tier(10_000_000), 1); // 10M → log10(10) = 1 → tier 1
|
||
assert_eq!(population_tier(100_000_000), 2); // 100M → tier 2
|
||
assert_eq!(population_tier(1_000_000_000_000), 5); // capped at 5
|
||
}
|
||
|
||
#[test]
|
||
fn mix_sums_at_least_to_requested() {
|
||
let mix = compute_district_mix(
|
||
5_000_000,
|
||
"manufacturing",
|
||
&PoliticalArchetype::Industrial,
|
||
8,
|
||
42,
|
||
);
|
||
// total may exceed requested due to guarantees
|
||
assert!(mix.total >= 8, "district count should be >= requested");
|
||
}
|
||
|
||
#[test]
|
||
fn tier_guarantees_applied() {
|
||
// Tier 2 city: pop/1M = 100–999, log10(100) = 2.
|
||
// 100M population → pop_tier = floor(log10(100)) = 2 → (1 Transit, 1 Commercial, 2 Residential).
|
||
let mix = compute_district_mix(
|
||
100_000_000,
|
||
"service_mixed",
|
||
&PoliticalArchetype::Pioneer,
|
||
6,
|
||
7,
|
||
);
|
||
let transit = mix
|
||
.districts
|
||
.iter()
|
||
.filter(|d| matches!(d, DistrictType::Transit))
|
||
.count();
|
||
let commercial = mix
|
||
.districts
|
||
.iter()
|
||
.filter(|d| matches!(d, DistrictType::Commercial))
|
||
.count();
|
||
let residential = mix
|
||
.districts
|
||
.iter()
|
||
.filter(|d| matches!(d, DistrictType::Residential))
|
||
.count();
|
||
assert!(transit >= 1, "transit guarantee not met: {transit}");
|
||
assert!(
|
||
commercial >= 1,
|
||
"commercial guarantee not met: {commercial}"
|
||
);
|
||
assert!(
|
||
residential >= 2,
|
||
"residential guarantee not met: {residential}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn determinism_same_seed() {
|
||
let mix1 = compute_district_mix(
|
||
5_000_000,
|
||
"financial",
|
||
&PoliticalArchetype::Commission,
|
||
6,
|
||
99,
|
||
);
|
||
let mix2 = compute_district_mix(
|
||
5_000_000,
|
||
"financial",
|
||
&PoliticalArchetype::Commission,
|
||
6,
|
||
99,
|
||
);
|
||
assert_eq!(mix1, mix2, "same inputs must produce identical output");
|
||
}
|
||
|
||
#[test]
|
||
fn different_archetypes_produce_different_mixes() {
|
||
let mix_corp = compute_district_mix(
|
||
5_000_000,
|
||
"financial",
|
||
&PoliticalArchetype::Corporate,
|
||
8,
|
||
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,
|
||
"Corporate and Pioneer archetypes should produce different district mixes"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn military_archetype_has_administrative() {
|
||
let mix = compute_district_mix(2_000_000, "military", &PoliticalArchetype::Military, 8, 10);
|
||
let admin = mix
|
||
.districts
|
||
.iter()
|
||
.filter(|d| matches!(d, DistrictType::Administrative))
|
||
.count();
|
||
assert!(
|
||
admin >= 1,
|
||
"military archetype should have Administrative districts"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn all_district_types_can_appear() {
|
||
// With enough districts and a balanced role, every type should appear at least once.
|
||
let mix = compute_district_mix(
|
||
50_000_000,
|
||
"service_mixed",
|
||
&PoliticalArchetype::Pioneer,
|
||
50,
|
||
0,
|
||
);
|
||
for dt in &DIST_COLS {
|
||
let present = mix
|
||
.districts
|
||
.iter()
|
||
.any(|d| std::mem::discriminant(d) == std::mem::discriminant(dt));
|
||
assert!(
|
||
present,
|
||
"DistrictType {:?} never appeared in 50-district mix",
|
||
dt
|
||
);
|
||
}
|
||
}
|
||
}
|