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
+561
View File
@@ -0,0 +1,561 @@
//! Phase 1 district skeleton generator (D-194, D-196, D-211, D-213, D-214).
//!
//! Entry point: [`generate_skeleton`]. Consumes a [`CityGenerationContext`]
//! together with the city's raw population and economic role, and produces a
//! fully classified [`DistrictSkeleton`] with:
//!
//! - [`SettingType`] derived from the surrounding biome context.
//! - [`ComplexityTier`] derived from population tier × [`WorldTier`].
//! - [`DistrictLayoutMode`] derived from [`PoliticalArchetype`].
//! - 4×4 block grid with [`ZoningType`] assignments from the district-mix
//! algorithm (D-194).
//! - [`MultiBlockReservation`]s for parks (pop tier ≥ 2) and transit
//! terminals (transit_hub role or pop tier ≥ 3).
//!
//! **Phase 1 scope only** — no chunk-level tiles, no NPC placement, no tile
//! condition data. All stub fields (corridors, social_sites, etc.) are empty.
//!
//! **Determinism (D-010):** Seeded LCG via the district seed; no floating-point
//! in block assignment.
use crate::atlas::block_irregularity::block_irregularity;
use crate::atlas::district_mix::{compute_district_mix, population_tier};
use crate::simulation::generator::{
BlockPlacement, BlockSkeleton, ComplexityTier, DistrictId, DistrictLayoutMode,
DistrictSkeleton, DistrictType, MultiBlockReservation, PoliticalArchetype,
ReservationFunction, ReservationId, SettingType, WorldTier, ZoningType,
CityGenerationContext,
};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Generate a Phase 1 [`DistrictSkeleton`] from a city's generation context.
///
/// # Parameters
/// - `context`: Build-time city context (archetype, world tier, orientation…).
/// - `population`: Raw population count from atlas_city_names.
/// - `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).
pub fn generate_skeleton(
context: &CityGenerationContext,
population: i64,
economic_role: &str,
district_id: DistrictId,
founding_age_years: u32,
seed: u64,
) -> DistrictSkeleton {
// ── 1. SettingType ────────────────────────────────────────────────────
// Pass through the surrounding_biome from context — it already encodes
// the planet/station/wilderness classification established at atlas time.
let setting = derive_setting(&context.surrounding_biome, economic_role);
// ── 2. ComplexityTier ─────────────────────────────────────────────────
let tier = population_tier(population);
let complexity = derive_complexity(&context.world_tier, tier, population);
// ── 3. DistrictLayoutMode ─────────────────────────────────────────────
let irregularity = block_irregularity(founding_age_years, &context.political_archetype);
let layout_mode = derive_layout_mode(&context.political_archetype, irregularity, seed);
// ── 4. District mix → block grid ─────────────────────────────────────
// A single district occupies a 4×4 block grid = 16 blocks.
let total_blocks: u32 = 16;
let mix = compute_district_mix(
population,
economic_role,
&context.political_archetype,
total_blocks,
seed,
);
// ── 5. Multi-block reservations ───────────────────────────────────────
let reservations = derive_reservations(tier, economic_role, seed);
// Build the reservation lookup: block position → reservation id.
let mut block_reservation: [[Option<ReservationId>; 4]; 4] =
[[None, None, None, None]; 4];
for (idx, res) in reservations.iter().enumerate() {
let rid = idx as u64 + 1; // 1-based stable id within this district
for &(row, col) in &res.blocks {
let r = row as usize;
let c = col as usize;
if r < 4 && c < 4 {
block_reservation[r][c] = Some(rid);
}
}
}
// ── Build 4×4 block grid ──────────────────────────────────────────────
// Flat district-mix list is already in deterministic order; assign
// row-major (row 0 col 0 → row 0 col 3 → row 1 col 0 …).
let primary_district_type = mix.districts.first().cloned()
.unwrap_or(DistrictType::MixedUse);
let blocks = build_block_grid(&mix.districts, &block_reservation, &primary_district_type);
// ── Compute z_levels ──────────────────────────────────────────────────
// Phase 1: single-storey above ground for all non-reserved blocks.
// Reserved blocks carry their own z_levels count.
let z_levels: u8 = 1;
DistrictSkeleton {
district_id,
seed,
district_type: district_type_from_mix(&primary_district_type),
context: String::new(), // stub — DistrictContext = String
world_tier: context.world_tier.clone(),
complexity,
setting,
layout_mode,
blocks,
reservations,
corridors: Vec::new(),
z_levels,
social_sites: Vec::new(),
access_points: Vec::new(),
society_profile: String::new(),
zone_palette: Vec::new(),
boundaries: String::new(),
guarantee_audit: None,
}
}
// ---------------------------------------------------------------------------
// SettingType derivation
// ---------------------------------------------------------------------------
/// Derive SettingType from the city's surrounding biome context.
///
/// The surrounding_biome on CityGenerationContext already encodes the
/// planet/station classification. For city districts we map it to
/// Urban (the default for settled cities) or pass Station/Maritime/etc.
/// through directly.
fn derive_setting(surrounding_biome: &SettingType, economic_role: &str) -> SettingType {
match surrounding_biome {
// Station bodies → always Station setting regardless of role.
SettingType::Station => SettingType::Station,
// Orbital platforms.
SettingType::Orbital => SettingType::Orbital,
// Maritime worlds — coastal city districts are Maritime.
SettingType::Maritime => SettingType::Maritime,
// Agricultural worlds → Agricultural districts.
SettingType::Agricultural => SettingType::Agricultural,
// For all other planet classes, city districts are Urban.
// Exception: extraction role on wilderness worlds → Specialized.
SettingType::Wilderness { biome } => {
if economic_role == "extraction" {
SettingType::Specialized {
function: format!("extraction-{biome}"),
}
} else {
SettingType::Urban
}
}
// Transit nodes get Transitional setting.
SettingType::Transitional => SettingType::Transitional,
// Water bodies → Water districts don't host cities; treat as Specialized.
SettingType::Water { .. } => SettingType::Specialized {
function: "waterfront".into(),
},
// Generic Specialized pass-through.
SettingType::Specialized { function } => SettingType::Specialized {
function: function.clone(),
},
// Default for Urban and any unknown variant: Urban.
SettingType::Urban => SettingType::Urban,
}
}
// ---------------------------------------------------------------------------
// ComplexityTier derivation
// ---------------------------------------------------------------------------
/// Derive ComplexityTier from WorldTier + population tier (D-194, D-218).
///
/// | WorldTier | pop_tier ≥ 1 | pop_tier = 0 |
/// |-----------------|---------------|----------------------|
/// | Epicenter | Full | Moderate |
/// | Regional | Full | Moderate |
/// | Backwater | Moderate | Minimal |
/// | Passage | Moderate | Minimal |
/// | Waypoint | Minimal | Minimal (→ Empty <5K)|
fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> ComplexityTier {
// Ghost stub threshold: pop < 5000 on Waypoint → Empty.
if population < 5_000 && matches!(world_tier, WorldTier::Waypoint) {
return ComplexityTier::Empty;
}
match world_tier {
WorldTier::Epicenter | WorldTier::Regional => {
if pop_tier >= 1 { ComplexityTier::Full } else { ComplexityTier::Moderate }
}
WorldTier::Backwater | WorldTier::Passage => {
if pop_tier >= 1 { ComplexityTier::Moderate } else { ComplexityTier::Minimal }
}
WorldTier::Waypoint => ComplexityTier::Minimal,
}
}
// ---------------------------------------------------------------------------
// DistrictLayoutMode derivation
// ---------------------------------------------------------------------------
/// Derive DistrictLayoutMode from PoliticalArchetype + block irregularity (D-213, D-214).
///
/// Commission / Military / Corporate / Academic → Grid (planned geometry).
/// Pioneer / Industrial → Organic (organic growth with per-block offsets).
fn derive_layout_mode(
archetype: &PoliticalArchetype,
irregularity: f32,
seed: u64,
) -> DistrictLayoutMode {
match archetype {
PoliticalArchetype::Commission
| PoliticalArchetype::Military
| PoliticalArchetype::Corporate
| PoliticalArchetype::Academic => DistrictLayoutMode::Grid,
PoliticalArchetype::Pioneer | PoliticalArchetype::Industrial => {
// Organic: generate per-block offsets and rotations seeded from district seed.
let placements = organic_placements(irregularity, seed);
DistrictLayoutMode::Organic { placements }
}
}
}
/// Generate 4×4 organic block placements seeded deterministically (D-010).
///
/// 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] {
let max_offset = (irregularity * 16.0) as i16;
let mut lcg = SkeletonLcg::new(seed);
// Build the 2D array using a flat closure to keep things readable.
let mut flat: [BlockPlacement; 16] = core::array::from_fn(|_| BlockPlacement {
offset: (0, 0),
rotation_steps: 0,
street_width_bps: 10_000,
});
for item in flat.iter_mut() {
let raw_x = (lcg.next_u32() % (2 * max_offset as u32 + 1)) as i16 - max_offset;
let raw_y = (lcg.next_u32() % (2 * max_offset as u32 + 1)) as i16 - max_offset;
let rot = (lcg.next_u32() % 4) as u8; // 0–3 (15° increments, max 45°)
// Street width 7500–20000 bps proportional to irregularity.
let width_range = 12_500u32; // 20000 - 7500
let width = 7_500u32 + (lcg.next_u32() % (width_range + 1));
*item = BlockPlacement {
offset: (raw_x, raw_y),
rotation_steps: rot,
street_width_bps: width as u16,
};
}
// Safety: BlockPlacement is Copy-able; reshape flat array to [[_; 4]; 4].
core::array::from_fn(|row| {
core::array::from_fn(|col| flat[row * 4 + col].clone())
})
}
// ---------------------------------------------------------------------------
// Block grid construction
// ---------------------------------------------------------------------------
/// Map a DistrictType to its primary ZoningType (D-194).
fn zoning_for_district(dt: &DistrictType) -> ZoningType {
match dt {
DistrictType::LogisticsHub => ZoningType::Industrial,
DistrictType::Residential => ZoningType::Residential,
DistrictType::Commercial => ZoningType::Commercial,
DistrictType::Industrial => ZoningType::Industrial,
DistrictType::Administrative => ZoningType::Administrative,
DistrictType::Entertainment => ZoningType::Commercial,
DistrictType::MixedUse => ZoningType::Mixed,
DistrictType::Transit => ZoningType::Transit,
DistrictType::Specialized => ZoningType::Restricted,
}
}
/// Map the primary district type to the DistrictType field on DistrictSkeleton.
fn district_type_from_mix(primary: &DistrictType) -> DistrictType {
primary.clone()
}
/// Build the 4×4 BlockSkeleton grid from the district mix list and reservation map.
///
/// Blocks are assigned row-major (index = row * 4 + col).
/// Reserved blocks retain their zoning from the district mix but link to the reservation.
fn build_block_grid(
districts: &[DistrictType],
block_reservation: &[[Option<ReservationId>; 4]; 4],
primary: &DistrictType,
) -> [[BlockSkeleton; 4]; 4] {
// Pad or truncate district list to exactly 16.
let district_iter: Vec<&DistrictType> = (0..16)
.map(|i| districts.get(i).unwrap_or(primary))
.collect();
core::array::from_fn(|row| {
core::array::from_fn(|col| {
let idx = row * 4 + col;
let dt = district_iter[idx];
let zoning = zoning_for_district(dt);
let reservation = block_reservation[row][col];
let density = density_for_zoning(&zoning);
BlockSkeleton {
position: (row as u8, col as u8),
zoning,
reservation,
chunk_layout: String::new(), // stub
hosted_sites: Vec::new(),
era: String::new(), // stub
era_modifications: Vec::new(),
era_cause: None,
density_pct: density,
landmark: None,
}
})
})
}
/// Default build density percentage for a zoning type.
fn density_for_zoning(zoning: &ZoningType) -> u8 {
match zoning {
ZoningType::Residential => 60,
ZoningType::Commercial => 80,
ZoningType::Industrial => 70,
ZoningType::Administrative => 75,
ZoningType::Transit => 50,
ZoningType::Recreational => 30,
ZoningType::Restricted => 85,
ZoningType::Mixed => 65,
}
}
// ---------------------------------------------------------------------------
// Multi-block reservations
// ---------------------------------------------------------------------------
/// Derive Phase 1 multi-block reservations for a city (D-211, D-194).
///
/// Reservation rules:
/// - Pop tier ≥ 2 → one 2×2 park reservation at the center-right (blocks (1,2),(1,3),(2,2),(2,3)).
/// - Transit_hub role OR pop tier ≥ 3 → one 1×2 transit terminal at row 0 cols 0–1.
///
/// Phase 1 produces skeleton-only reservations — floor_zones and vertical_corridors
/// are deferred to Phase 2.
fn derive_reservations(pop_tier: u8, economic_role: &str, _seed: u64) -> Vec<MultiBlockReservation> {
let mut out = Vec::new();
// Park: large cities need open space.
if pop_tier >= 2 {
out.push(MultiBlockReservation {
blocks: vec![(1, 2), (1, 3), (2, 2), (2, 3)],
template_tag: "park-central".into(),
function: ReservationFunction::Park,
z_levels: 1,
base_z: 0,
floor_zones: Vec::new(),
z_band_count: 1,
z_band_zones: Vec::new(),
vertical_corridors: Vec::new(),
hosted_sites: Vec::new(),
});
}
// Transit terminal: transit-hub economies and major cities.
if economic_role == "transit_hub" || pop_tier >= 3 {
out.push(MultiBlockReservation {
blocks: vec![(0, 0), (0, 1)],
template_tag: "transit-terminal".into(),
function: ReservationFunction::Terminal,
z_levels: 2,
base_z: -1, // one level of underground rail
floor_zones: Vec::new(),
z_band_count: 2,
z_band_zones: Vec::new(),
vertical_corridors: Vec::new(),
hosted_sites: Vec::new(),
});
}
out
}
// ---------------------------------------------------------------------------
// Minimal seeded LCG (D-010 determinism)
// ---------------------------------------------------------------------------
struct SkeletonLcg {
state: u64,
}
impl SkeletonLcg {
fn new(seed: u64) -> Self {
// Mix seed to avoid degenerate state at 0.
Self { state: seed.wrapping_add(0x9e37_79b9_7f4a_7c15) }
}
fn next_u64(&mut self) -> u64 {
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::*;
use crate::simulation::generator::{
CityGenerationContext, FoundingOrientation, PoliticalArchetype, SettingType, WorldTier,
};
fn make_context(archetype: PoliticalArchetype, world_tier: WorldTier) -> CityGenerationContext {
CityGenerationContext {
city_id: 1,
political_archetype: archetype,
prosperity_baseline: 0.7,
surrounding_biome: SettingType::Urban,
road_entry_directions: vec![0, 4],
footprint_radius_km: 10.0,
founding_orientation: FoundingOrientation::Cardinal,
world_tier,
}
}
#[test]
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);
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);
assert!(matches!(sk.setting, SettingType::Urban));
}
#[test]
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);
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);
assert_eq!(sk.complexity, ComplexityTier::Empty);
}
#[test]
fn complexity_backwater_low_pop_is_minimal() {
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
// 50_000 pop → pop_tier = 0 → Minimal on Backwater
let sk = generate_skeleton(&ctx, 50_000, "residential", 1, 50, 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);
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);
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);
// All 16 blocks must have valid positions.
for row in 0..4 {
for col in 0..4 {
let b = &sk.blocks[row][col];
assert_eq!(b.position, (row as u8, col as u8));
assert!(b.density_pct <= 100);
}
}
}
#[test]
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);
assert!(sk.reservations.is_empty());
}
#[test]
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 has_park = sk.reservations.iter()
.any(|r| matches!(r.function, ReservationFunction::Park));
assert!(has_park);
}
#[test]
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 has_terminal = sk.reservations.iter()
.any(|r| matches!(r.function, ReservationFunction::Terminal));
assert!(has_terminal);
}
#[test]
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);
// 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];
assert!(b.reservation.is_some(), "block ({row},{col}) should be reserved");
}
}
#[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);
// Compare block grid zoning and positions.
for row in 0..4 {
for col in 0..4 {
assert_eq!(sk1.blocks[row][col].zoning, sk2.blocks[row][col].zoning);
assert_eq!(sk1.blocks[row][col].position, sk2.blocks[row][col].position);
assert_eq!(sk1.blocks[row][col].density_pct, sk2.blocks[row][col].density_pct);
}
}
assert_eq!(sk1.reservations.len(), sk2.reservations.len());
}
}