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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user