feat(simulation): add server/src/atlas/ — full Phase 1 generation pipeline

Ten-module atlas package implementing the D-194–D-218 district generation
stack: heightmap loader, BodyWorldState LRU cache, D8 drainage routing,
background generation queue, five-phase attractor-matching, three-component
district mix, block irregularity, tile condition thresholds, and the Phase 1
skeleton generator that wires them into DistrictSkeleton.

Closes #916 #917 #918 #919 #920 #922 #923 #924 #899.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 18:11:13 +02:00
co-authored by Claude Sonnet 4.6
parent f9fdfb7712
commit b9fd7a3fc8
10 changed files with 3326 additions and 0 deletions
+359
View File
@@ -0,0 +1,359 @@
//! 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::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,
"residential" | _ => 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 = LcgRng::new(seed);
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 }
}
// ---------------------------------------------------------------------------
// Minimal seeded LCG (no f32, D-010 compliant)
// ---------------------------------------------------------------------------
struct LcgRng {
state: u64,
}
impl LcgRng {
fn new(seed: u64) -> Self {
Self { state: seed.wrapping_add(1) }
}
fn next_u64(&mut self) -> u64 {
// LCG parameters from Knuth
self.state = self.state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
self.state
}
fn next_u32(&mut self) -> u32 {
(self.next_u64() >> 33) as u32
}
}
// ---------------------------------------------------------------------------
// 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);
}
}
}