From b9fd7a3fc8e8be98f4a7fe7d7b632f114850d612 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 2 May 2026 18:11:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(simulation):=20add=20server/src/atlas/=20?= =?UTF-8?q?=E2=80=94=20full=20Phase=201=20generation=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/atlas/attractor_matching.rs | 618 +++++++++++++++++++++++ server/src/atlas/block_irregularity.rs | 111 +++++ server/src/atlas/body_world_state.rs | 249 ++++++++++ server/src/atlas/district_mix.rs | 359 ++++++++++++++ server/src/atlas/drainage.rs | 661 +++++++++++++++++++++++++ server/src/atlas/gen_queue.rs | 361 ++++++++++++++ server/src/atlas/heightmap.rs | 201 ++++++++ server/src/atlas/mod.rs | 14 + server/src/atlas/skeleton_gen.rs | 561 +++++++++++++++++++++ server/src/atlas/tile_condition.rs | 191 +++++++ 10 files changed, 3326 insertions(+) create mode 100644 server/src/atlas/attractor_matching.rs create mode 100644 server/src/atlas/block_irregularity.rs create mode 100644 server/src/atlas/body_world_state.rs create mode 100644 server/src/atlas/district_mix.rs create mode 100644 server/src/atlas/drainage.rs create mode 100644 server/src/atlas/gen_queue.rs create mode 100644 server/src/atlas/heightmap.rs create mode 100644 server/src/atlas/mod.rs create mode 100644 server/src/atlas/skeleton_gen.rs create mode 100644 server/src/atlas/tile_condition.rs diff --git a/server/src/atlas/attractor_matching.rs b/server/src/atlas/attractor_matching.rs new file mode 100644 index 000000000..424c39ce2 --- /dev/null +++ b/server/src/atlas/attractor_matching.rs @@ -0,0 +1,618 @@ +//! Attractor-matching five-phase pipeline for settlement placement (D-211). +//! +//! Given a body's `Vec` and a list of cities, assigns +//! each city to the terrain feature that best fits its economic role and +//! population tier. +//! +//! **Phases (D-211):** +//! 1. Score matrix build: `CompatibilityMatrix[economic_role][attractor_type] × strength × (1/cost)` +//! 2. Tier A greedy: `NameLocked` or pop ≥ 1,000,000 — assigned first, highest-score greedy. +//! 3. Hungarian (Tier B+C): pop 50,000–999,999 cities — optimal global assignment. +//! 4. Synthetic overflow: any remaining city gets a synthetic `PlainCenter` attractor. +//! 5. Name fulfillment check: warn if any atlas city was not placed. +//! +//! **Mismatch flagging (D-211):** +//! - score < 0.35 → WARNING +//! - score < 0.15 → ERROR (flagged for manual review; generation continues) + +use tracing::{error, warn}; + +use crate::simulation::generator::{ + AttractorType, CompatibilityMatrix, GeographicAttractor, SettlementClass, +}; + +// --------------------------------------------------------------------------- +// Input types +// --------------------------------------------------------------------------- + +/// One city record from atlas_city_names, projected for matching. +#[derive(Debug, Clone)] +pub struct CityRecord { + pub city_id: u64, + pub name: String, + pub settlement_class: SettlementClass, + pub population: i64, + /// One of: manufacturing, financial, agricultural, extraction, + /// service_mixed, institutional, transit_hub, research, military, residential. + pub economic_role: String, +} + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +/// Result of matching one city to one attractor (real or synthetic). +#[derive(Debug, Clone)] +pub struct CityPlacement { + pub city_id: u64, + pub position: (u16, u16), + pub attractor_type: AttractorType, + pub score: f32, + pub synthetic: bool, +} + +// --------------------------------------------------------------------------- +// Score matrix helpers +// --------------------------------------------------------------------------- + +/// Row index in CompatibilityMatrix for an economic_role string. +/// Order from D-195: manufacturing(0), financial(1), agricultural(2), extraction(3), +/// service_mixed(4), institutional(5), transit_hub(6), research(7), military(8), residential(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, + } +} + +/// Column index in CompatibilityMatrix for an AttractorType. +/// Order from D-195: RiverMouth(0), CoastalAccess(1), RiverCrossing(2), ValleyFloor(3), +/// PassEntrance(4), LakeShore(5), PlainCenter(6). +fn attractor_col(at: &AttractorType) -> usize { + match at { + AttractorType::RiverMouth => 0, + AttractorType::CoastalAccess => 1, + AttractorType::RiverCrossing => 2, + AttractorType::ValleyFloor => 3, + AttractorType::PassEntrance => 4, + AttractorType::LakeShore => 5, + AttractorType::PlainCenter => 6, + } +} + +/// Compute the raw match score between a city and an attractor. +/// Score = matrix_weight × attractor.strength × (1.0 / terrain_modification_cost). +fn cell_score( + city: &CityRecord, + attractor: &GeographicAttractor, + matrix: &CompatibilityMatrix, + terrain_cost: f32, +) -> f32 { + let row = role_row(&city.economic_role); + let col = attractor_col(&attractor.attractor_type); + let weight = matrix.weights[row][col]; + let cost_factor = if terrain_cost > 0.0 { 1.0 / terrain_cost } else { 1.0 }; + weight * attractor.strength * cost_factor +} + +// --------------------------------------------------------------------------- +// Phase 3: Hungarian algorithm (minimization) +// --------------------------------------------------------------------------- + +/// O(n³) Hungarian algorithm for assignment problem. +/// +/// Input: `cost[i][j]` — cost of assigning task j to worker i. +/// Lower cost = better fit. Converts the maximization problem to minimization +/// by using `max_score - score` as cost. +/// +/// Returns `assignment[i] = j` for each row i. +fn hungarian(cost: &[Vec]) -> Vec { + let n = cost.len(); + if n == 0 { + return Vec::new(); + } + let m = cost[0].len(); + if m == 0 { + return vec![usize::MAX; n]; + } + + // Pad to square n×n if m < n (more cities than attractors handled by overflow). + let sz = n.max(m); + let mut c: Vec> = vec![vec![0.0; sz]; sz]; + for i in 0..n { + for j in 0..m { + c[i][j] = cost[i][j]; + } + // Pad extra columns with high cost so overflow cities pick them last. + for j in m..sz { + c[i][j] = f32::MAX / 2.0; + } + } + // Pad extra rows with 0 cost (dummy workers). + // Already initialized to 0. + + // Standard O(n³) Hungarian. + let inf = f32::MAX / 2.0; + let mut u = vec![0.0f32; sz + 1]; + let mut v = vec![0.0f32; sz + 1]; + let mut p = vec![0usize; sz + 1]; // p[j] = row assigned to column j (1-indexed) + let mut way = vec![0usize; sz + 1]; + + for i in 1..=sz { + p[0] = i; + let mut j0 = 0usize; + let mut minv = vec![inf; sz + 1]; + let mut used = vec![false; sz + 1]; + loop { + used[j0] = true; + let i0 = p[j0]; + let mut delta = inf; + let mut j1 = 0usize; + for j in 1..=sz { + if used[j] { + continue; + } + let cur = c[i0 - 1][j - 1] - u[i0] - v[j]; + if cur < minv[j] { + minv[j] = cur; + way[j] = j0; + } + if minv[j] < delta { + delta = minv[j]; + j1 = j; + } + } + for j in 0..=sz { + if used[j] { + u[p[j]] += delta; + v[j] -= delta; + } else { + minv[j] -= delta; + } + } + j0 = j1; + if p[j0] == 0 { + break; + } + } + loop { + let j1 = way[j0]; + p[j0] = p[j1]; + j0 = j1; + if j0 == 0 { + break; + } + } + } + + // Extract assignment: for each row i (1-indexed), find column j where p[j] == i. + let mut result = vec![usize::MAX; n]; + for j in 1..=sz { + if p[j] > 0 && p[j] <= n { + let col = j - 1; + if col < m { + result[p[j] - 1] = col; + } + } + } + result +} + +// --------------------------------------------------------------------------- +// Synthetic PlainCenter placement +// --------------------------------------------------------------------------- + +/// Minimum pixel separation between synthetic attractor positions. +const MIN_SPACING: u16 = 15; + +fn synthetic_attractor( + placed: &[CityPlacement], + grid_w: u32, + grid_h: u32, +) -> GeographicAttractor { + // Place at grid center as default, then walk until spacing is satisfied. + let mut row = (grid_h / 2) as u16; + let mut col = (grid_w / 4) as u16; + + // Simple search: try positions in a grid until spacing is met. + 'outer: for dr in 0..(grid_h as u16 / MIN_SPACING) { + for dc in 0..(grid_w as u16 / MIN_SPACING) { + let r = (dr * MIN_SPACING).min(grid_h as u16 - 1); + let c = (dc * MIN_SPACING).min(grid_w as u16 - 1); + let ok = placed.iter().all(|p| { + let dr2 = (p.position.0 as i32 - r as i32).abs() as u16; + let dc2 = (p.position.1 as i32 - c as i32).abs() as u16; + dr2.max(dc2) >= MIN_SPACING + }); + if ok { + row = r; + col = c; + break 'outer; + } + } + } + + GeographicAttractor { + position: (row, col), + attractor_type: AttractorType::PlainCenter, + strength: 0.5, + } +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +/// Run the five-phase attractor-matching pipeline (D-211). +/// +/// `terrain_costs` maps attractor index → terrain_modification_cost (1.0 = baseline). +/// If `None`, all costs default to 1.0. +pub fn match_cities( + cities: &[CityRecord], + attractors: &[GeographicAttractor], + matrix: &CompatibilityMatrix, + terrain_costs: Option<&[f32]>, + grid_w: u32, + grid_h: u32, +) -> Vec { + let default_cost = vec![1.0f32; attractors.len()]; + let costs = terrain_costs.unwrap_or(&default_cost); + + let mut placements: Vec = Vec::with_capacity(cities.len()); + let mut used_attractors: Vec = vec![false; attractors.len()]; + + // ------------------------------------------------------------------------- + // Phase 1: Score matrix + // ------------------------------------------------------------------------- + let scores: Vec> = cities + .iter() + .map(|city| { + attractors + .iter() + .zip(costs.iter()) + .map(|(att, &cost)| cell_score(city, att, matrix, cost)) + .collect() + }) + .collect(); + + // ------------------------------------------------------------------------- + // Phase 2: Tier A greedy — NameLocked or pop ≥ 1_000_000 + // ------------------------------------------------------------------------- + let tier_a_indices: Vec = cities + .iter() + .enumerate() + .filter(|(_, c)| { + c.settlement_class == SettlementClass::NameLocked || c.population >= 1_000_000 + }) + .map(|(i, _)| i) + .collect(); + + for &ci in &tier_a_indices { + if attractors.is_empty() { + break; + } + // Highest-scoring unused attractor. + let best = scores[ci] + .iter() + .enumerate() + .filter(|(ai, _)| !used_attractors[*ai]) + .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + if let Some((ai, &score)) = best { + used_attractors[ai] = true; + flag_mismatch(&cities[ci].name, score); + placements.push(CityPlacement { + city_id: cities[ci].city_id, + position: attractors[ai].position, + attractor_type: attractors[ai].attractor_type.clone(), + score, + synthetic: false, + }); + } + } + + // ------------------------------------------------------------------------- + // Phase 3: Hungarian — Tier B+C (50,000–999,999) + // ------------------------------------------------------------------------- + let tier_bc_indices: Vec = cities + .iter() + .enumerate() + .filter(|(i, c)| { + !tier_a_indices.contains(i) + && c.population >= 50_000 + && c.population < 1_000_000 + }) + .map(|(i, _)| i) + .collect(); + + let free_attractors: Vec = (0..attractors.len()) + .filter(|&ai| !used_attractors[ai]) + .collect(); + + if !tier_bc_indices.is_empty() && !free_attractors.is_empty() { + // Build cost sub-matrix (maximization → minimization via complement). + let scores_ref = &scores; + let max_score: f32 = tier_bc_indices + .iter() + .flat_map(|&ci| free_attractors.iter().map(move |&ai| scores_ref[ci][ai])) + .fold(0.0f32, f32::max); + + let cost: Vec> = tier_bc_indices + .iter() + .map(|&ci| { + free_attractors + .iter() + .map(|&ai| max_score - scores_ref[ci][ai]) + .collect() + }) + .collect(); + + let assignment = hungarian(&cost); + + for (local_i, &ci) in tier_bc_indices.iter().enumerate() { + let local_j = assignment[local_i]; + if local_j == usize::MAX || local_j >= free_attractors.len() { + continue; // overflow — handled in phase 4 + } + let ai = free_attractors[local_j]; + let score = scores[ci][ai]; + used_attractors[ai] = true; + flag_mismatch(&cities[ci].name, score); + placements.push(CityPlacement { + city_id: cities[ci].city_id, + position: attractors[ai].position, + attractor_type: attractors[ai].attractor_type.clone(), + score, + synthetic: false, + }); + } + } + + // ------------------------------------------------------------------------- + // Phase 4: Synthetic overflow — all remaining cities + // ------------------------------------------------------------------------- + let placed_ids: std::collections::HashSet = + placements.iter().map(|p| p.city_id).collect(); + + for city in cities { + if placed_ids.contains(&city.city_id) { + continue; + } + let synthetic = synthetic_attractor(&placements, grid_w, grid_h); + let score = cell_score(city, &synthetic, matrix, 1.0); + flag_mismatch(&city.name, score); + placements.push(CityPlacement { + city_id: city.city_id, + position: synthetic.position, + attractor_type: AttractorType::PlainCenter, + score, + synthetic: true, + }); + } + + // ------------------------------------------------------------------------- + // Phase 5: Name fulfillment check + // ------------------------------------------------------------------------- + let placed_ids: std::collections::HashSet = + placements.iter().map(|p| p.city_id).collect(); + for city in cities { + if !placed_ids.contains(&city.city_id) { + warn!( + city = %city.name, + city_id = city.city_id, + "atlas city was not placed — missing from pipeline output" + ); + } + } + + placements +} + +fn flag_mismatch(city_name: &str, score: f32) { + if score < 0.15 { + error!( + city = %city_name, + score, + "attractor mismatch score < 0.15 — flagged for manual review" + ); + } else if score < 0.35 { + warn!( + city = %city_name, + score, + "attractor mismatch score < 0.35 — below expected quality" + ); + } +} + +// --------------------------------------------------------------------------- +// FoundingOrientation derivation from matched attractor (D-211, D-213) +// --------------------------------------------------------------------------- + +use crate::simulation::generator::FoundingOrientation; +use crate::simulation::generator::TerritorialStatus; + +/// Derive `FoundingOrientation` from the attractor type that anchored the city (D-211, D-213). +/// +/// `river_bearing` and `coastal_facing` are compass degrees 0–359. +/// Pass 0 as default when the terrain doesn't dictate a specific bearing. +pub fn founding_orientation( + attractor_type: &AttractorType, + territorial_status: &TerritorialStatus, + river_bearing: u16, + coastal_facing: u16, +) -> FoundingOrientation { + match attractor_type { + AttractorType::RiverMouth | AttractorType::CoastalAccess => { + FoundingOrientation::Coastal { facing_degrees: coastal_facing } + } + AttractorType::RiverCrossing => { + FoundingOrientation::RiverAligned { bearing_degrees: river_bearing } + } + AttractorType::ValleyFloor => FoundingOrientation::TerrainFollowing, + AttractorType::PlainCenter => { + if matches!(territorial_status, TerritorialStatus::CommissionControlled) { + FoundingOrientation::Cardinal + } else { + FoundingOrientation::Free { bearing_degrees: 0 } + } + } + AttractorType::PassEntrance | AttractorType::LakeShore => { + FoundingOrientation::TerrainFollowing + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor}; + + fn uniform_matrix() -> CompatibilityMatrix { + CompatibilityMatrix { weights: [[1.0; 7]; 10] } + } + + fn make_attractor(row: u16, col: u16, at: AttractorType, strength: f32) -> GeographicAttractor { + GeographicAttractor { position: (row, col), attractor_type: at, strength } + } + + fn make_city(id: u64, class: SettlementClass, pop: i64) -> CityRecord { + CityRecord { + city_id: id, + name: format!("City{id}"), + settlement_class: class, + population: pop, + economic_role: "manufacturing".to_string(), + } + } + + #[test] + fn single_city_single_attractor() { + let cities = vec![make_city(1, SettlementClass::NameLocked, 500_000)]; + let attractors = vec![make_attractor(10, 20, AttractorType::RiverMouth, 0.8)]; + let matrix = uniform_matrix(); + let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256); + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].city_id, 1); + assert_eq!(placements[0].position, (10, 20)); + assert!(!placements[0].synthetic); + } + + #[test] + fn tier_a_gets_priority() { + // NameLocked city should get the best attractor (high strength). + let cities = vec![ + make_city(1, SettlementClass::NameLocked, 100_000), + make_city(2, SettlementClass::PopulationBudget, 200_000), + ]; + let attractors = vec![ + make_attractor(5, 5, AttractorType::RiverMouth, 0.9), // best + make_attractor(10, 10, AttractorType::ValleyFloor, 0.4), // second + ]; + let matrix = uniform_matrix(); + let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256); + let p1 = placements.iter().find(|p| p.city_id == 1).unwrap(); + assert_eq!(p1.position, (5, 5), "NameLocked should get best attractor"); + } + + #[test] + fn overflow_produces_synthetic() { + // 2 cities, 1 attractor → second city gets synthetic. + let cities = vec![ + make_city(1, SettlementClass::NameLocked, 2_000_000), + make_city(2, SettlementClass::PopulationBudget, 60_000), + ]; + let attractors = vec![make_attractor(0, 0, AttractorType::RiverMouth, 1.0)]; + let matrix = uniform_matrix(); + let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256); + assert_eq!(placements.len(), 2); + let p2 = placements.iter().find(|p| p.city_id == 2).unwrap(); + assert!(p2.synthetic); + } + + #[test] + fn all_cities_placed() { + let cities: Vec = (1..=5) + .map(|i| make_city(i, SettlementClass::PopulationBudget, 100_000)) + .collect(); + let attractors = vec![ + make_attractor(10, 10, AttractorType::RiverMouth, 0.9), + make_attractor(20, 20, AttractorType::CoastalAccess, 0.7), + ]; + let matrix = uniform_matrix(); + let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256); + assert_eq!(placements.len(), 5, "all cities must be placed"); + } + + #[test] + fn hungarian_assigns_optimally() { + // 2 cities, 2 attractors. City A scores best on attractor 0, city B best on attractor 1. + let mut matrix = uniform_matrix(); + // agricultural (row 2) scores high on ValleyFloor (col 3) = 3.0 + matrix.weights[2][3] = 3.0; + // transit_hub (row 6) scores high on RiverCrossing (col 2) = 3.0 + matrix.weights[6][2] = 3.0; + let cities = vec![ + CityRecord { + city_id: 1, + name: "Farm".to_string(), + settlement_class: SettlementClass::PopulationBudget, + population: 60_000, + economic_role: "agricultural".to_string(), + }, + CityRecord { + city_id: 2, + name: "Hub".to_string(), + settlement_class: SettlementClass::PopulationBudget, + population: 80_000, + economic_role: "transit_hub".to_string(), + }, + ]; + let attractors = vec![ + make_attractor(5, 5, AttractorType::ValleyFloor, 1.0), + make_attractor(10, 10, AttractorType::RiverCrossing, 1.0), + ]; + let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256); + assert_eq!(placements.len(), 2); + let farm = placements.iter().find(|p| p.city_id == 1).unwrap(); + let hub = placements.iter().find(|p| p.city_id == 2).unwrap(); + // Farm should be on ValleyFloor (5,5), Hub on RiverCrossing (10,10). + assert_eq!(farm.position, (5, 5)); + assert_eq!(hub.position, (10, 10)); + } + + #[test] + fn founding_orientation_from_attractor() { + use crate::simulation::generator::TerritorialStatus; + let status = TerritorialStatus::FrontierUnclaimed; + let o = founding_orientation(&AttractorType::RiverMouth, &status, 90, 270); + assert!(matches!(o, FoundingOrientation::Coastal { facing_degrees: 270 })); + + let o2 = founding_orientation( + &AttractorType::PlainCenter, + &TerritorialStatus::CommissionControlled, + 0, + 0, + ); + assert!(matches!(o2, FoundingOrientation::Cardinal)); + + let o3 = founding_orientation( + &AttractorType::ValleyFloor, + &status, + 0, + 0, + ); + assert!(matches!(o3, FoundingOrientation::TerrainFollowing)); + } +} diff --git a/server/src/atlas/block_irregularity.rs b/server/src/atlas/block_irregularity.rs new file mode 100644 index 000000000..b81279c01 --- /dev/null +++ b/server/src/atlas/block_irregularity.rs @@ -0,0 +1,111 @@ +//! BlockIrregularity derivation from founding_age and PoliticalArchetype (D-216). +//! +//! `block_irregularity` (0.0–1.0) controls how much a block deviates from +//! the district's canonical grid. Minimum 0.05 — no block is perfectly regular. +//! +//! **Formula (D-216):** +//! ```text +//! base_irregularity = (founding_age_years / 1000.0).min(1.0) +//! archetype_step = Commission|Military → -0.3, Corporate|Academic → -0.1, +//! Industrial → 0.0, Pioneer → +0.3 +//! block_irregularity = (base + step).clamp(0.05, 1.0) +//! ``` +//! +//! **Determinism (D-010, D-216):** Integer-scaled intermediates; archetype_step +//! stored as basis points (i32, 1 bp = 0.001). Final result is f32 from integer +//! arithmetic to match the D-216 formula. + +use crate::simulation::generator::PoliticalArchetype; + +/// Compute the `block_irregularity` value for one block. +/// +/// - `founding_age_years`: years since the settlement was founded (integer). +/// - `archetype`: the settlement's political archetype. +/// +/// Returns a value in [0.05, 1.0]. +pub fn block_irregularity(founding_age_years: u32, archetype: &PoliticalArchetype) -> f32 { + // base_irregularity in integer basis-points (0–1000, where 1000 = 1.0). + let base_bp: i32 = (founding_age_years as i32).min(1000); + + // archetype_step in basis-points. + let step_bp: i32 = archetype_step_bp(archetype); + + // block_irregularity_bp clamped to [50, 1000] (0.05–1.0). + let result_bp = (base_bp + step_bp).clamp(50, 1000); + + result_bp as f32 / 1000.0 +} + +fn archetype_step_bp(archetype: &PoliticalArchetype) -> i32 { + match archetype { + PoliticalArchetype::Commission | PoliticalArchetype::Military => -300, + PoliticalArchetype::Corporate | PoliticalArchetype::Academic => -100, + PoliticalArchetype::Industrial => 0, + PoliticalArchetype::Pioneer => 300, + } +} + +/// Derive the maximum block offset in sim tiles from `block_irregularity`. +/// +/// Used by `DistrictLayoutMode::Organic`: `max_offset = (irregularity × 16.0) as i16`. +pub fn max_offset_sim_tiles(irregularity: f32) -> i16 { + (irregularity * 16.0) as i16 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn minimum_is_0_05() { + // New Commission city (age 0) → base 0, step -300 → clamp to 50bp = 0.05. + let v = block_irregularity(0, &PoliticalArchetype::Commission); + assert!((v - 0.05).abs() < 1e-6, "expected 0.05, got {v}"); + } + + #[test] + fn maximum_is_1_0() { + // Old Pioneer city (age 1000+) → base 1000, step +300 → clamp to 1000bp = 1.0. + let v = block_irregularity(1500, &PoliticalArchetype::Pioneer); + assert!((v - 1.0).abs() < 1e-6, "expected 1.0, got {v}"); + } + + #[test] + fn pioneer_more_irregular_than_commission() { + let pioneer = block_irregularity(400, &PoliticalArchetype::Pioneer); + let commission = block_irregularity(400, &PoliticalArchetype::Commission); + assert!(pioneer > commission, + "Pioneer ({pioneer}) should be more irregular than Commission ({commission})"); + } + + #[test] + fn age_increases_irregularity() { + let young = block_irregularity(50, &PoliticalArchetype::Industrial); + let old = block_irregularity(800, &PoliticalArchetype::Industrial); + assert!(old > young, + "Older settlement ({old}) should be more irregular than young ({young})"); + } + + #[test] + fn max_offset_scales_with_irregularity() { + assert_eq!(max_offset_sim_tiles(0.05), 0); // 0.05 × 16 = 0.8 → 0 + assert_eq!(max_offset_sim_tiles(1.0), 16); + assert_eq!(max_offset_sim_tiles(0.5), 8); + } + + #[test] + fn all_archetypes_produce_valid_range() { + let archetypes = [ + PoliticalArchetype::Commission, + PoliticalArchetype::Corporate, + PoliticalArchetype::Pioneer, + PoliticalArchetype::Military, + PoliticalArchetype::Academic, + PoliticalArchetype::Industrial, + ]; + for a in &archetypes { + let v = block_irregularity(300, a); + assert!(v >= 0.05 && v <= 1.0, "archetype {:?} gave {v} out of [0.05, 1.0]", a); + } + } +} diff --git a/server/src/atlas/body_world_state.rs b/server/src/atlas/body_world_state.rs new file mode 100644 index 000000000..f1bcc9ae5 --- /dev/null +++ b/server/src/atlas/body_world_state.rs @@ -0,0 +1,249 @@ +//! BodyWorldState — per-body Layer 1–2 cache (D-203). +//! +//! `BodyWorldStateCache` is a Bevy `Resource` holding pre-computed generation +//! data for up to 50 planetary bodies. Populated by the runtime-background +//! tier (D-206) via Rayon tasks; read by the main tick thread without blocking. +//! +//! Eviction policy: LRU — the body with the oldest `last_accessed` tick is +//! evicted on overflow, unless it is pinned (current player location or an +//! adjacent-system neighbor). + +use std::collections::HashMap; + +use bevy_ecs::prelude::Resource; + +use crate::simulation::generator::GeographicAttractor; + +/// Simulation tick counter — monotonically increasing u64. +pub type SimTick = u64; + +/// Maximum number of bodies the cache holds before evicting the LRU entry. +pub const CACHE_CAPACITY: usize = 50; + +// --------------------------------------------------------------------------- +// Stub types — filled in by D-208 (#918) and D-205 (#907 Rust side) +// --------------------------------------------------------------------------- + +/// River network extracted by the D8 drainage algorithm (D-208). +/// Stub — replaced when #918 is implemented. +#[derive(Debug, Clone, Default)] +pub struct RiverNetwork { + /// Pixel positions (row, col) of all river cells (flow_accumulation > 200). + pub river_cells: Vec<(u16, u16)>, + /// Positions where two or more rivers merge. + pub confluences: Vec<(u16, u16)>, + /// Positions where rivers reach sea level or the heightmap edge. + pub mouths: Vec<(u16, u16)>, +} + +/// One drainage basin / province derived from watershed analysis (D-205). +/// Stub — boundary polyline data comes from atlas_province_boundaries. +#[derive(Debug, Clone)] +pub struct DrainageBasin { + pub basin_id: u32, + /// Boundary polyline as pixel-space (row, col) points. + pub boundary: Vec<(u16, u16)>, + /// Fraction of the body's surface area in this basin. + pub area_pct: f32, +} + +// --------------------------------------------------------------------------- +// BodyWorldState +// --------------------------------------------------------------------------- + +/// Pre-computed Layer 1–2 generation data for one planetary body. +/// +/// Produced by the runtime-background tier and stored in `BodyWorldStateCache`. +/// The main tick thread reads this data without performing any DB or CPU work. +#[derive(Debug, Clone)] +pub struct BodyWorldState { + pub body_id: String, + /// Downsampled working elevation grid (float32, row-major). + /// Full-resolution data lives in atlas_body_heightmaps; this is reduced + /// for the ~8KB working-resolution budget described in D-203. + pub heightmap: Vec, + pub heightmap_width: u32, + pub heightmap_height: u32, + /// D8 drainage analysis output (D-208). Empty until drainage task completes. + pub river_network: RiverNetwork, + /// Drainage basins from watershed analysis (D-205). + pub drainage_basins: Vec, + /// Geographic attractors (D-195, D-209). Empty until attractor task completes. + pub attractors: Vec, + /// Last sim tick this entry was read. Used for LRU eviction. + pub last_accessed: SimTick, +} + +// --------------------------------------------------------------------------- +// BodyWorldStateCache — Bevy Resource +// --------------------------------------------------------------------------- + +/// Bevy `Resource` holding the LRU cache of per-body world state (D-203). +/// +/// Initialized empty at server startup. Entries are inserted by the +/// background generation queue (D-206) and read by main-thread systems. +/// +/// All mutations go through the provided methods to maintain the +/// invariant that `entries.len() <= capacity`. +#[derive(Resource, Debug, Default)] +pub struct BodyWorldStateCache { + entries: HashMap, + /// Body IDs that must not be evicted regardless of `last_accessed`. + pinned: std::collections::HashSet, + capacity: usize, +} + +impl BodyWorldStateCache { + pub fn new(capacity: usize) -> Self { + Self { + entries: HashMap::with_capacity(capacity), + pinned: std::collections::HashSet::new(), + capacity, + } + } + + /// Insert or replace a `BodyWorldState` entry. + /// + /// If the cache is at capacity, evicts the LRU unpinned entry before + /// inserting. If all entries are pinned and the cache is full, the new + /// entry is inserted anyway (capacity is a soft limit against unbounded + /// growth, not a hard reject). + pub fn insert(&mut self, state: BodyWorldState) { + if self.entries.len() >= self.capacity && !self.entries.contains_key(&state.body_id) { + self.evict_lru(); + } + self.entries.insert(state.body_id.clone(), state); + } + + /// Get a reference to the state for `body_id`, bumping `last_accessed`. + pub fn get(&mut self, body_id: &str, current_tick: SimTick) -> Option<&BodyWorldState> { + if let Some(entry) = self.entries.get_mut(body_id) { + entry.last_accessed = current_tick; + } + self.entries.get(body_id) + } + + /// Get a reference without bumping `last_accessed` (read-only path). + pub fn peek(&self, body_id: &str) -> Option<&BodyWorldState> { + self.entries.get(body_id) + } + + /// Returns `true` if the cache has an entry for `body_id`. + pub fn contains(&self, body_id: &str) -> bool { + self.entries.contains_key(body_id) + } + + /// Pin `body_id` — exempt from LRU eviction. + pub fn pin(&mut self, body_id: &str) { + self.pinned.insert(body_id.to_string()); + } + + /// Unpin `body_id` — allow eviction again. + pub fn unpin(&mut self, body_id: &str) { + self.pinned.remove(body_id); + } + + /// Number of entries currently in the cache. + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + fn evict_lru(&mut self) { + // Find the unpinned entry with the smallest last_accessed tick. + let victim = self + .entries + .iter() + .filter(|(id, _)| !self.pinned.contains(*id)) + .min_by_key(|(_, s)| s.last_accessed) + .map(|(id, _)| id.clone()); + + if let Some(id) = victim { + self.entries.remove(&id); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[allow(unused_imports)] + use crate::simulation::generator::GeographicAttractor; + + fn make_state(body_id: &str, tick: SimTick) -> BodyWorldState { + BodyWorldState { + body_id: body_id.to_string(), + heightmap: vec![0.5; 16], + heightmap_width: 4, + heightmap_height: 4, + river_network: RiverNetwork::default(), + drainage_basins: vec![], + attractors: vec![], + last_accessed: tick, + } + } + + #[test] + fn insert_and_get() { + let mut cache = BodyWorldStateCache::new(50); + cache.insert(make_state("Alpha", 1)); + assert!(cache.contains("Alpha")); + assert!(!cache.contains("Beta")); + let entry = cache.get("Alpha", 5).unwrap(); + assert_eq!(entry.body_id, "Alpha"); + assert_eq!(entry.last_accessed, 5); + } + + #[test] + fn evicts_lru_on_overflow() { + let mut cache = BodyWorldStateCache::new(3); + cache.insert(make_state("A", 10)); + cache.insert(make_state("B", 20)); + cache.insert(make_state("C", 30)); + // Cache is full; inserting D should evict A (oldest tick = 10). + cache.insert(make_state("D", 40)); + assert_eq!(cache.len(), 3); + assert!(!cache.contains("A"), "A should have been evicted"); + assert!(cache.contains("B")); + assert!(cache.contains("C")); + assert!(cache.contains("D")); + } + + #[test] + fn pinned_body_not_evicted() { + let mut cache = BodyWorldStateCache::new(3); + cache.insert(make_state("A", 10)); + cache.insert(make_state("B", 20)); + cache.insert(make_state("C", 30)); + // Pin A so it cannot be evicted. + cache.pin("A"); + // Inserting D must evict B (oldest unpinned). + cache.insert(make_state("D", 40)); + assert!(cache.contains("A"), "pinned A must not be evicted"); + assert!(!cache.contains("B"), "B should have been evicted instead"); + } + + #[test] + fn update_last_accessed_on_get() { + let mut cache = BodyWorldStateCache::new(3); + cache.insert(make_state("A", 1)); + cache.insert(make_state("B", 2)); + cache.insert(make_state("C", 3)); + // Cache is full. Get A at tick 100 — bumps its last_accessed above C and B. + cache.get("A", 100); + // Insert D to trigger eviction; B (tick 2) is now LRU, not A (tick 100). + cache.insert(make_state("D", 4)); + assert!(cache.contains("A"), "A was recently accessed — must survive"); + assert!(!cache.contains("B"), "B had oldest access time — should be evicted"); + } + + #[test] + fn default_capacity_is_zero() { + // Default resource starts empty. + let cache = BodyWorldStateCache::default(); + assert!(cache.is_empty()); + } +} diff --git a/server/src/atlas/district_mix.rs b/server/src/atlas/district_mix.rs new file mode 100644 index 000000000..6e3fc9f5a --- /dev/null +++ b/server/src/atlas/district_mix.rs @@ -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, + /// 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 = 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); + } + } +} diff --git a/server/src/atlas/drainage.rs b/server/src/atlas/drainage.rs new file mode 100644 index 000000000..1deadb6de --- /dev/null +++ b/server/src/atlas/drainage.rs @@ -0,0 +1,661 @@ +//! D8 drainage routing — flow direction, flow accumulation, river network +//! extraction, and drainage basin delineation (D-208). +//! +//! **Determinism (D-010, D-208):** All flow-direction comparisons use integer +//! arithmetic on scaled elevation values (`(elev * 1_000_000.0) as i64`) to +//! avoid f32 comparison non-determinism. Tie-breaking uses a fixed D8 neighbor +//! priority order. The result is bit-identical across runs on the same inputs. +//! +//! **Algorithm:** +//! 1. Scale f32 elevation to i64 integers. +//! 2. Priority-flood depression fill (iterative, convergence in ≤10 passes). +//! 3. D8 flow direction: steepest descent, 8-neighbor, wraps horizontally. +//! 4. Flow accumulation via topological sort of the D8 DAG. +//! 5. River network extraction: cells with accumulation > RIVER_THRESHOLD. +//! 6. Basin labeling: flood-fill seeded at pour points. +//! +//! The grid is row-major. Row 0 is the north pole; row H-1 is the south pole. +//! Columns wrap horizontally (the globe is equirectangular). + +use std::collections::VecDeque; + +use crate::atlas::body_world_state::{DrainageBasin, RiverNetwork}; + +/// A cell is a river cell when its flow accumulation exceeds this threshold (D-208). +pub const RIVER_THRESHOLD: i32 = 200; + +/// Scale factor for converting f32 elevation to integer for deterministic comparison. +const ELEV_SCALE: f64 = 1_000_000.0; + +// D8 neighbor offsets (dr, dc) in fixed priority order for deterministic tie-breaking. +// Priority: cardinal directions first (N, S, E, W), then diagonals (NE, NW, SE, SW). +const D8: [(i32, i32); 8] = [ + (-1, 0), // N + (1, 0), // S + (0, 1), // E + (0, -1), // W + (-1, 1), // NE + (-1, -1), // NW + (1, 1), // SE + (1, -1), // SW +]; + +/// Result of the full D8 drainage analysis for one body. +#[derive(Debug, Clone)] +pub struct DrainageResult { + pub river_network: RiverNetwork, + pub drainage_basins: Vec, +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/// Run the full D8 drainage analysis on an elevation grid. +/// +/// `elevation` is a row-major float32 grid of shape `height × width`, values +/// in [0.0, 1.0]. `sea_level` is the fraction below which terrain is ocean. +/// +/// Returns `DrainageResult` with the river network and drainage basins. +pub fn analyze(elevation: &[f32], width: u32, height: u32, sea_level: f32) -> DrainageResult { + let w = width as usize; + let h = height as usize; + // 1. Scale to integers. + let scaled: Vec = elevation + .iter() + .map(|&e| (e as f64 * ELEV_SCALE) as i64) + .collect(); + + // 2. Depression fill. + let filled = depression_fill(&scaled, w, h); + + // 3. D8 flow direction. -1 = no outflow (edge or flat peak). + let fdir = flow_direction(&filled, w, h); + + // 4. Flow accumulation. + let accum = flow_accumulation(&fdir, w, h); + + // 5. River network. + let river_network = extract_river_network(&accum, &fdir, w, h, sea_level, elevation); + + // 6. Basin labeling. + let labels = label_basins(&fdir, &accum, w, h); + + // 7. Merge small basins + clamp count to [4, 12]. + let labels = merge_small_basins(labels, w, h, 4, 12); + + // 8. Build DrainageBasin structs. + let drainage_basins = build_basins(&labels, w, h); + + DrainageResult { + river_network, + drainage_basins, + } +} + +// --------------------------------------------------------------------------- +// Step 2: Depression fill +// --------------------------------------------------------------------------- + +fn depression_fill(scaled: &[i64], w: usize, h: usize) -> Vec { + let mut filled = scaled.to_vec(); + for _ in 0..10 { + let mut changed = false; + for r in 1..h.saturating_sub(1) { + for c in 0..w { + let mut nbr_min = i64::MAX; + for &(dr, dc) in &D8 { + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr >= 0 && nr < h as i32 { + let val = filled[nr as usize * w + nc]; + if val < nbr_min { + nbr_min = val; + } + } + } + if filled[r * w + c] < nbr_min { + filled[r * w + c] = nbr_min + 1; + changed = true; + } + } + } + if !changed { + break; + } + } + filled +} + +// --------------------------------------------------------------------------- +// Step 3: D8 flow direction +// --------------------------------------------------------------------------- + +/// Returns per-cell flow direction index into D8 (0–7), or -1 for no outflow. +fn flow_direction(filled: &[i64], w: usize, h: usize) -> Vec { + let mut fdir = vec![-1i8; w * h]; + for r in 0..h { + for c in 0..w { + let elev = filled[r * w + c]; + let mut best_drop = 0i64; + let mut best_k: i8 = -1; + for (k, &(dr, dc)) in D8.iter().enumerate() { + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr < 0 || nr >= h as i32 { + continue; + } + let drop = elev - filled[nr as usize * w + nc]; + if drop > best_drop { + best_drop = drop; + best_k = k as i8; + } + } + fdir[r * w + c] = best_k; + } + } + fdir +} + +// --------------------------------------------------------------------------- +// Step 4: Flow accumulation +// --------------------------------------------------------------------------- + +fn flow_accumulation(fdir: &[i8], w: usize, h: usize) -> Vec { + let n = w * h; + let mut in_degree = vec![0i32; n]; + + for r in 0..h { + for c in 0..w { + let k = fdir[r * w + c]; + if k < 0 { + continue; + } + let (dr, dc) = D8[k as usize]; + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr >= 0 && nr < h as i32 { + in_degree[nr as usize * w + nc] += 1; + } + } + } + + let mut queue = VecDeque::new(); + for i in 0..n { + if in_degree[i] == 0 { + queue.push_back(i); + } + } + + let mut accum = vec![1i32; n]; + while let Some(idx) = queue.pop_front() { + let r = idx / w; + let c = idx % w; + let k = fdir[idx]; + if k < 0 { + continue; + } + let (dr, dc) = D8[k as usize]; + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr >= 0 && nr < h as i32 { + let ni = nr as usize * w + nc; + accum[ni] += accum[idx]; + in_degree[ni] -= 1; + if in_degree[ni] == 0 { + queue.push_back(ni); + } + } + } + + accum +} + +// --------------------------------------------------------------------------- +// Step 5: River network extraction +// --------------------------------------------------------------------------- + +fn extract_river_network( + accum: &[i32], + fdir: &[i8], + w: usize, + h: usize, + sea_level: f32, + elevation: &[f32], +) -> RiverNetwork { + let n = w * h; + + // River cells: above threshold AND above sea level. + let is_river: Vec = (0..n) + .map(|i| accum[i] > RIVER_THRESHOLD && elevation[i] >= sea_level) + .collect(); + + let river_cells: Vec<(u16, u16)> = (0..n) + .filter(|&i| is_river[i]) + .map(|i| ((i / w) as u16, (i % w) as u16)) + .collect(); + + // Confluences: river cells with 2+ river neighbors flowing into them. + let mut inflow_count = vec![0u8; n]; + for r in 0..h { + for c in 0..w { + let i = r * w + c; + if !is_river[i] { + continue; + } + let k = fdir[i]; + if k < 0 { + continue; + } + let (dr, dc) = D8[k as usize]; + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr >= 0 && nr < h as i32 { + let ni = nr as usize * w + nc; + if is_river[ni] { + inflow_count[ni] = inflow_count[ni].saturating_add(1); + } + } + } + } + let confluences: Vec<(u16, u16)> = (0..n) + .filter(|&i| is_river[i] && inflow_count[i] >= 2) + .map(|i| ((i / w) as u16, (i % w) as u16)) + .collect(); + + // Mouths: river cells that flow to a sea cell or to the polar edge. + let mouths: Vec<(u16, u16)> = (0..n) + .filter(|&i| { + if !is_river[i] { + return false; + } + let r = i / w; + let c = i % w; + let k = fdir[i]; + if k < 0 { + return true; // no outflow — edge + } + let (dr, dc) = D8[k as usize]; + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr < 0 || nr >= h as i32 { + return true; // polar edge + } + // Flows into a sub-sea-level cell = mouth + elevation[nr as usize * w + nc] < sea_level + }) + .map(|i| ((i / w) as u16, (i % w) as u16)) + .collect(); + + RiverNetwork { + river_cells, + confluences, + mouths, + } +} + +// --------------------------------------------------------------------------- +// Step 6: Basin labeling +// --------------------------------------------------------------------------- + +fn label_basins(fdir: &[i8], accum: &[i32], w: usize, h: usize) -> Vec { + let n = w * h; + let mut labels = vec![-1i32; n]; + + // Pour points: local accumulation maxima above river threshold. + let mut pour_pts: Vec = Vec::new(); + for i in 0..n { + if accum[i] <= RIVER_THRESHOLD { + continue; + } + let r = i / w; + let c = i % w; + let mut is_max = true; + for &(dr, dc) in &D8 { + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr >= 0 && nr < h as i32 { + if accum[nr as usize * w + nc] > accum[i] { + is_max = false; + break; + } + } + } + if is_max { + pour_pts.push(i); + } + } + + if pour_pts.is_empty() { + // Flat/ocean world — single basin. + labels.iter_mut().for_each(|l| *l = 0); + return labels; + } + + for (basin_id, &idx) in pour_pts.iter().enumerate() { + labels[idx] = basin_id as i32; + } + + // Trace remaining cells: follow fdir until a labeled cell is reached. + for start in 0..n { + if labels[start] >= 0 { + continue; + } + // Walk forward, accumulate path. + let mut path: Vec = Vec::new(); + let mut cur = start; + let label = loop { + if labels[cur] >= 0 { + break labels[cur]; + } + path.push(cur); + let k = fdir[cur]; + if k < 0 { + break 0; // no outflow — assign to basin 0 + } + let r = cur / w; + let c = cur % w; + let (dr, dc) = D8[k as usize]; + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr < 0 || nr >= h as i32 { + break 0; // polar edge + } + let next = nr as usize * w + nc; + // Cycle guard: if we're visiting a cell already in path, stop. + if path.contains(&next) { + break 0; + } + cur = next; + }; + for idx in path { + labels[idx] = label; + } + } + + labels +} + +// --------------------------------------------------------------------------- +// Step 7: Merge small basins +// --------------------------------------------------------------------------- + +fn merge_small_basins( + mut labels: Vec, + w: usize, + h: usize, + min_count: usize, + max_count: usize, +) -> Vec { + let n = w * h; + let min_frac = 0.02f64; // 2% minimum basin area + + for _ in 0..200 { + // Count basin sizes. + let mut sizes: std::collections::HashMap = std::collections::HashMap::new(); + for &l in &labels { + *sizes.entry(l).or_insert(0) += 1; + } + let n_basins = sizes.len(); + + // Stop if within target range and all basins are large enough. + if n_basins <= max_count + && sizes.values().all(|&s| s as f64 / n as f64 >= min_frac) + { + break; + } + if n_basins <= min_count { + break; + } + + // Find the smallest basin. + let (&smallest_id, &smallest_size) = sizes + .iter() + .min_by_key(|(_, &s)| s) + .unwrap(); + + if n_basins <= max_count && smallest_size as f64 / n as f64 >= min_frac { + break; + } + + // Find its largest adjacent basin. + let nbr_id = find_largest_neighbor(&labels, smallest_id, &sizes, w, h); + let merge_into = nbr_id.unwrap_or(0); + + // Merge. + for l in labels.iter_mut() { + if *l == smallest_id { + *l = merge_into; + } + } + } + + // Renumber contiguously from 0. + let unique: Vec = { + let mut set: std::collections::HashSet = std::collections::HashSet::new(); + for &l in &labels { + set.insert(l); + } + let mut v: Vec = set.into_iter().collect(); + v.sort(); + v + }; + let remap: std::collections::HashMap = unique + .iter() + .enumerate() + .map(|(new, &old)| (old, new as i32)) + .collect(); + for l in labels.iter_mut() { + *l = remap[l]; + } + + labels +} + +fn find_largest_neighbor( + labels: &[i32], + target_id: i32, + sizes: &std::collections::HashMap, + w: usize, + h: usize, +) -> Option { + let n = w * h; + let mut neighbor_sizes: std::collections::HashMap = + std::collections::HashMap::new(); + + for i in 0..n { + if labels[i] != target_id { + continue; + } + let r = i / w; + let c = i % w; + for &(dr, dc) in &D8 { + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr >= 0 && nr < h as i32 { + let nbr_id = labels[nr as usize * w + nc]; + if nbr_id != target_id { + let size = sizes.get(&nbr_id).copied().unwrap_or(0); + let e = neighbor_sizes.entry(nbr_id).or_insert(0); + if size > *e { + *e = size; + } + } + } + } + } + + neighbor_sizes + .into_iter() + .max_by_key(|(_, s)| *s) + .map(|(id, _)| id) +} + +// --------------------------------------------------------------------------- +// Step 8: Build DrainageBasin structs +// --------------------------------------------------------------------------- + +fn build_basins(labels: &[i32], w: usize, h: usize) -> Vec { + let n = w * h; + let mut basin_map: std::collections::HashMap> = + std::collections::HashMap::new(); + + for (i, &l) in labels.iter().enumerate() { + basin_map.entry(l).or_default().push(i); + } + + let mut basins: Vec = Vec::with_capacity(basin_map.len()); + let mut ids: Vec = basin_map.keys().copied().collect(); + ids.sort(); + + for basin_id in ids { + let cells = &basin_map[&basin_id]; + let area_pct = cells.len() as f32 / n as f32; + + // Boundary cells: in this basin, adjacent to a different basin or edge. + let mut boundary: Vec<(u16, u16)> = Vec::new(); + for &idx in cells { + let r = idx / w; + let c = idx % w; + let mut on_boundary = false; + for &(dr, dc) in &D8 { + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr < 0 || nr >= h as i32 { + on_boundary = true; + break; + } + if labels[nr as usize * w + nc] != basin_id { + on_boundary = true; + break; + } + } + if on_boundary { + boundary.push((r as u16, c as u16)); + } + } + + // Sort boundary by angle from centroid for a coherent polygon. + if !boundary.is_empty() { + let cr = boundary.iter().map(|&(r, _)| r as f32).sum::() + / boundary.len() as f32; + let cc = boundary.iter().map(|&(_, c)| c as f32).sum::() + / boundary.len() as f32; + boundary.sort_by(|&(r1, c1), &(r2, c2)| { + let a1 = (r1 as f32 - cr).atan2(c1 as f32 - cc); + let a2 = (r2 as f32 - cr).atan2(c2 as f32 - cc); + a1.partial_cmp(&a2).unwrap_or(std::cmp::Ordering::Equal) + }); + // Subsample to ≤500 points. + if boundary.len() > 500 { + let step = boundary.len() / 500; + boundary = boundary.into_iter().step_by(step).collect(); + } + } + + basins.push(DrainageBasin { + basin_id: basin_id as u32, + boundary, + area_pct, + }); + } + + basins +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn flat_grid(w: u32, h: u32, val: f32) -> Vec { + vec![val; (w * h) as usize] + } + + fn slope_grid(w: u32, h: u32) -> Vec { + let n = (w * h) as usize; + (0..n) + .map(|i| { + let r = i / w as usize; + let c = i % w as usize; + // Slope: higher in top-left, drains toward bottom-right. + 1.0 - (r as f32 / h as f32 * 0.5 + c as f32 / w as f32 * 0.5) + }) + .collect() + } + + #[test] + fn flat_grid_produces_single_basin() { + let elev = flat_grid(16, 8, 0.5); + let result = analyze(&elev, 16, 8, 0.3); + // Flat world → no pour points → single basin + assert_eq!(result.drainage_basins.len(), 1); + assert!((result.drainage_basins[0].area_pct - 1.0).abs() < 0.01); + } + + #[test] + fn slope_grid_has_no_river_cells_below_threshold_by_default() { + // Small 8×4 grid: max flow_accum ≤ 32, below RIVER_THRESHOLD (200). + let elev = slope_grid(8, 4); + let result = analyze(&elev, 8, 4, 0.3); + // River cells may be empty on this tiny grid — that is acceptable. + // What matters: no panic and basin count ≥ 1. + assert!(!result.drainage_basins.is_empty()); + } + + #[test] + fn large_grid_river_cells_nonempty() { + // 512×256: max flow accumulation ~131K >> RIVER_THRESHOLD. + let elev = slope_grid(512, 256); + let result = analyze(&elev, 512, 256, 0.3); + assert!( + !result.river_network.river_cells.is_empty(), + "Expected river cells on a large sloped grid" + ); + } + + #[test] + fn basin_area_pcts_sum_to_one() { + let elev = slope_grid(64, 32); + let result = analyze(&elev, 64, 32, 0.3); + let total: f32 = result.drainage_basins.iter().map(|b| b.area_pct).sum(); + assert!( + (total - 1.0).abs() < 0.01, + "Basin area fractions must sum to 1, got {}", + total + ); + } + + #[test] + fn basin_count_within_target_range() { + let elev = slope_grid(128, 64); + let result = analyze(&elev, 128, 64, 0.3); + let n = result.drainage_basins.len(); + assert!( + n >= 1 && n <= 12, + "Basin count {} out of expected range [1, 12]", + n + ); + } + + #[test] + fn determinism() { + // Running analyze twice on the same input must produce identical results. + let elev = slope_grid(64, 32); + let r1 = analyze(&elev, 64, 32, 0.3); + let r2 = analyze(&elev, 64, 32, 0.3); + assert_eq!( + r1.river_network.river_cells, + r2.river_network.river_cells, + "River cells must be deterministic" + ); + assert_eq!( + r1.drainage_basins.len(), + r2.drainage_basins.len(), + "Basin count must be deterministic" + ); + } +} diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs new file mode 100644 index 000000000..90c45f40e --- /dev/null +++ b/server/src/atlas/gen_queue.rs @@ -0,0 +1,361 @@ +//! Background generation queue — prioritized Rayon thread pool (D-206). +//! +//! All runtime-background generation work runs through this queue. The main +//! tick thread submits work items (non-blocking) and drains completion events +//! once per tick via a `crossbeam` channel. +//! +//! **Priority levels (D-206):** +//! - `Immediate`: player arrives within 1 game-minute. Runs first. +//! - `High`: player arrives within 5 game-minutes. +//! - `Medium`: player is in the same system. +//! - `Low`: player has heard of this location via NPC/news. +//! +//! **Work item types (D-206):** +//! - `AnalyzeBody`: D8 drainage + attractor extraction for a body. +//! - `GenerateSkeleton`: Phase 1 DistrictSkeleton for a city. +//! - `FillChunk`: Phase 2 chunk fill for a pre-loaded district. +//! +//! Completion events are delivered to the main thread via +//! `GenerationQueue::drain_completions()`, called once per tick from a Bevy +//! system in `TickPhase::PreInput`. +//! +//! **Thread count (D-206):** `available_parallelism - 2`, minimum 1. + +use std::sync::{Arc, Mutex}; + +use bevy_ecs::prelude::Resource; +use crossbeam_channel::{Receiver, Sender}; + +// --------------------------------------------------------------------------- +// Priority +// --------------------------------------------------------------------------- + +/// Work priority levels — lower discriminant = higher priority. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum GenPriority { + /// Player arrives within ~1 game-minute. Runs before all other levels. + Immediate = 0, + /// Player arrives within ~5 game-minutes. + High = 1, + /// Player is in the same system. + Medium = 2, + /// Player has seen or heard of this location (NPC dialogue, news ticker). + Low = 3, +} + +// --------------------------------------------------------------------------- +// Work item types +// --------------------------------------------------------------------------- + +/// A unit of background generation work (D-206). +#[derive(Debug, Clone)] +pub enum GenWorkItem { + /// Run D8 drainage analysis + attractor extraction for this body. + AnalyzeBody { body_id: String }, + /// Generate a Phase 1 DistrictSkeleton for this city. + GenerateSkeleton { city_id: u64 }, + /// Pre-fill a chunk in an existing district. + FillChunk { district_id: u64, block_pos: (u32, u32) }, +} + +impl GenWorkItem { + pub fn body_id(&self) -> Option<&str> { + if let GenWorkItem::AnalyzeBody { body_id } = self { + Some(body_id) + } else { + None + } + } +} + +// --------------------------------------------------------------------------- +// Completion event +// --------------------------------------------------------------------------- + +/// Sent back to the main thread when a work item finishes (D-206). +#[derive(Debug)] +pub enum GenCompletion { + BodyAnalyzed { body_id: String }, + SkeletonGenerated { city_id: u64 }, + ChunkFilled { district_id: u64, block_pos: (u32, u32) }, + /// Work item failed — body_id or city_id for logging. + Failed { item: GenWorkItem, reason: String }, +} + +// --------------------------------------------------------------------------- +// Internal queued work +// --------------------------------------------------------------------------- + +struct QueuedWork { + priority: GenPriority, + item: GenWorkItem, +} + +// --------------------------------------------------------------------------- +// GenerationQueue — Bevy Resource +// --------------------------------------------------------------------------- + +/// Bevy `Resource` managing the background generation queue (D-206). +/// +/// Submit work with `submit()`. Drain completions with `drain_completions()` +/// once per tick. The Rayon thread pool runs tasks in priority order. +#[derive(Resource)] +pub struct GenerationQueue { + /// Pending work items, sorted by priority on submission. + pending: Arc>>, + /// Completions channel — background tasks send here; main thread reads. + completion_tx: Sender, + completion_rx: Receiver, + /// Rayon thread pool dedicated to generation work. + pool: rayon::ThreadPool, + /// Set of body_ids currently in-flight to avoid duplicate submissions. + in_flight: Arc>>, +} + +impl std::fmt::Debug for GenerationQueue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let pending_len = self + .pending + .lock() + .map(|p| p.len()) + .unwrap_or(0); + f.debug_struct("GenerationQueue") + .field("pending_count", &pending_len) + .finish() + } +} + +impl GenerationQueue { + /// Create a new queue with the D-206 thread count: + /// `available_parallelism - 2`, minimum 1. + pub fn new() -> Self { + let n_threads = std::thread::available_parallelism() + .map(|p| p.get().saturating_sub(2).max(1)) + .unwrap_or(1); + Self::with_threads(n_threads) + } + + /// Create a queue with a specific thread count (for testing). + pub fn with_threads(n_threads: usize) -> Self { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(n_threads) + .thread_name(|i| format!("gen-worker-{i}")) + .build() + .expect("failed to build generation rayon pool"); + + let (tx, rx) = crossbeam_channel::unbounded(); + + Self { + pending: Arc::new(Mutex::new(Vec::new())), + completion_tx: tx, + completion_rx: rx, + pool, + in_flight: Arc::new(Mutex::new(std::collections::HashSet::new())), + } + } + + /// Submit a work item at the given priority. + /// + /// If an `AnalyzeBody` item for the same body_id is already in-flight or + /// pending, the submission is silently ignored (idempotent). + pub fn submit(&self, item: GenWorkItem, priority: GenPriority) { + // Dedup AnalyzeBody submissions. + if let Some(body_id) = item.body_id() { + let in_flight = self.in_flight.lock().unwrap(); + if in_flight.contains(body_id) { + return; + } + drop(in_flight); + // Check pending list. + let pending = self.pending.lock().unwrap(); + if pending.iter().any(|q| { + q.item.body_id().map_or(false, |id| id == body_id) + }) { + return; + } + drop(pending); + } + + let mut pending = self.pending.lock().unwrap(); + let pos = pending + .iter() + .position(|q| q.priority > priority) + .unwrap_or(pending.len()); + pending.insert(pos, QueuedWork { priority, item }); + drop(pending); + + self.dispatch_next(); + } + + /// Drain all completed items from the channel. + /// + /// Call once per tick from the main thread. Returns all completions + /// available without blocking. + pub fn drain_completions(&self) -> Vec { + let mut out = Vec::new(); + loop { + match self.completion_rx.try_recv() { + Ok(c) => out.push(c), + Err(_) => break, + } + } + out + } + + /// Number of items waiting in the pending queue. + pub fn pending_count(&self) -> usize { + self.pending.lock().unwrap().len() + } + + // Dispatch the highest-priority pending item to the Rayon pool. + fn dispatch_next(&self) { + let item = { + let mut pending = self.pending.lock().unwrap(); + if pending.is_empty() { + return; + } + pending.remove(0).item + }; + + // Mark body as in-flight. + if let Some(body_id) = item.body_id() { + self.in_flight + .lock() + .unwrap() + .insert(body_id.to_string()); + } + + let tx = self.completion_tx.clone(); + let in_flight = Arc::clone(&self.in_flight); + let pending = Arc::clone(&self.pending); + + self.pool.spawn(move || { + let completion = run_work_item(&item); + + // Un-mark in-flight. + if let Some(body_id) = item.body_id() { + in_flight.lock().unwrap().remove(body_id); + } + + let _ = tx.send(completion); + + // After finishing, check if more pending work exists — in a real + // impl, the next Rayon task is dispatched by the main thread on + // the next tick. We don't self-recurse here to avoid pool saturation. + let _ = pending; // keep Arc alive until task exits + }); + } +} + +impl Default for GenerationQueue { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Work execution stub +// --------------------------------------------------------------------------- + +/// Execute one work item. This is the Rayon task body. +/// +/// Currently a stub — real implementations will call `drainage::analyze()`, +/// the attractor pipeline, and the district skeleton generator. Stubs return +/// immediate success to allow the queue infrastructure to be tested independently. +fn run_work_item(item: &GenWorkItem) -> GenCompletion { + match item { + GenWorkItem::AnalyzeBody { body_id } => { + GenCompletion::BodyAnalyzed { body_id: body_id.clone() } + } + GenWorkItem::GenerateSkeleton { city_id } => { + GenCompletion::SkeletonGenerated { city_id: *city_id } + } + GenWorkItem::FillChunk { district_id, block_pos } => { + GenCompletion::ChunkFilled { + district_id: *district_id, + block_pos: *block_pos, + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn make_queue() -> GenerationQueue { + GenerationQueue::with_threads(2) + } + + #[test] + fn submit_and_drain() { + let q = make_queue(); + q.submit( + GenWorkItem::AnalyzeBody { body_id: "TestBody".to_string() }, + GenPriority::Medium, + ); + // Give Rayon time to complete the (stub) task. + std::thread::sleep(Duration::from_millis(50)); + let completions = q.drain_completions(); + assert_eq!(completions.len(), 1); + assert!(matches!( + &completions[0], + GenCompletion::BodyAnalyzed { body_id } if body_id == "TestBody" + )); + } + + #[test] + fn dedup_analyze_body() { + let q = make_queue(); + // Submit the same body twice before it can complete. + q.submit( + GenWorkItem::AnalyzeBody { body_id: "Dup".to_string() }, + GenPriority::Low, + ); + q.submit( + GenWorkItem::AnalyzeBody { body_id: "Dup".to_string() }, + GenPriority::Low, + ); + std::thread::sleep(Duration::from_millis(50)); + let completions = q.drain_completions(); + // Should have completed exactly once. + assert_eq!(completions.len(), 1); + } + + #[test] + fn priority_ordering() { + // Submit three items rapidly; Immediate should be dispatched first. + let q = make_queue(); + // Using GenerateSkeleton (no dedup logic) to test ordering directly. + q.submit(GenWorkItem::GenerateSkeleton { city_id: 1 }, GenPriority::Low); + q.submit(GenWorkItem::GenerateSkeleton { city_id: 2 }, GenPriority::Immediate); + q.submit(GenWorkItem::GenerateSkeleton { city_id: 3 }, GenPriority::Medium); + std::thread::sleep(Duration::from_millis(100)); + let completions = q.drain_completions(); + assert_eq!(completions.len(), 3); + } + + #[test] + fn drain_empty_returns_empty() { + let q = make_queue(); + let result = q.drain_completions(); + assert!(result.is_empty()); + } + + #[test] + fn pending_count_decreases_after_completion() { + let q = make_queue(); + q.submit( + GenWorkItem::FillChunk { district_id: 99, block_pos: (0, 0) }, + GenPriority::High, + ); + std::thread::sleep(Duration::from_millis(50)); + let completions = q.drain_completions(); + assert!(!completions.is_empty() || q.pending_count() == 0); + } +} diff --git a/server/src/atlas/heightmap.rs b/server/src/atlas/heightmap.rs new file mode 100644 index 000000000..7f6c7c69e --- /dev/null +++ b/server/src/atlas/heightmap.rs @@ -0,0 +1,201 @@ +//! Heightmap BLOB loader — reads float32 LE elevation grids from systems.db. +//! +//! Implements the Rust side of D-202. The Python pipeline stores each body's +//! elevation grid as a contiguous float32 little-endian BLOB in +//! `atlas_body_heightmaps.data`. This module loads that BLOB via `rusqlite` +//! and reinterprets the bytes into a `Vec` using `bytemuck`. +//! +//! Values are normalized elevation in [0.0, 1.0]. `sea_level` is the fraction +//! below which terrain is underwater (0.0 = no ocean). +//! +//! Canonical grid size: 512 × 256 (GRID_W × GRID_H), row-major. + +use rusqlite::{params, Connection}; +use thiserror::Error; + +/// Canonical grid dimensions matching the Python pipeline (generate_atlas.py). +pub const GRID_W: u32 = 512; +pub const GRID_H: u32 = 256; + +/// A loaded heightmap for one planetary body. +#[derive(Debug, Clone)] +pub struct BodyHeightmap { + pub body_id: String, + pub width: u32, + pub height: u32, + /// Row-major elevation values, normalized to [0.0, 1.0]. + pub data: Vec, + /// Elevation fraction below which terrain is ocean/sea. + pub sea_level: f32, +} + +impl BodyHeightmap { + /// Returns the elevation at (row, col), or `None` if out of bounds. + #[inline] + pub fn get(&self, row: u32, col: u32) -> Option { + if row < self.height && col < self.width { + Some(self.data[(row * self.width + col) as usize]) + } else { + None + } + } + + /// Returns `true` if the cell at (row, col) is land (above sea level). + #[inline] + pub fn is_land(&self, row: u32, col: u32) -> bool { + self.get(row, col).map_or(false, |e| e >= self.sea_level) + } +} + +#[derive(Debug, Error)] +pub enum HeightmapLoadError { + #[error("no heightmap row for body '{0}'")] + NotFound(String), + #[error("BLOB size {actual} does not match declared grid {w}×{h}×4 = {expected}")] + BlobSizeMismatch { + actual: usize, + w: u32, + h: u32, + expected: usize, + }, + #[error("SQLite error: {0}")] + Sql(#[from] rusqlite::Error), +} + +/// Load the heightmap for `body_id` from the open `conn`. +/// +/// The BLOB is reinterpreted in-place via `bytemuck::cast_slice` — no copy +/// beyond the initial `Vec` read from SQLite. On little-endian hosts +/// (all current targets) this is a zero-cost reinterpret. On big-endian hosts +/// the bytes are already stored LE, so each f32 would be byte-swapped; this +/// function does not perform that swap — big-endian support is deferred. +pub fn load_heightmap( + conn: &Connection, + body_id: &str, +) -> Result { + let result = conn.query_row( + "SELECT width, height, data, sea_level \ + FROM atlas_body_heightmaps WHERE body_id = ?1", + params![body_id], + |row| { + let width: u32 = row.get(0)?; + let height: u32 = row.get(1)?; + let blob: Vec = row.get(2)?; + let sea_level: f64 = row.get(3)?; + Ok((width, height, blob, sea_level as f32)) + }, + ); + + match result { + Err(rusqlite::Error::QueryReturnedNoRows) => { + Err(HeightmapLoadError::NotFound(body_id.to_string())) + } + Err(e) => Err(HeightmapLoadError::Sql(e)), + Ok((width, height, blob, sea_level)) => { + let expected = (width * height * 4) as usize; + if blob.len() != expected { + return Err(HeightmapLoadError::BlobSizeMismatch { + actual: blob.len(), + w: width, + h: height, + expected, + }); + } + // Reinterpret the LE bytes as f32 values. bytemuck::cast_slice + // is safe here: we verified the length is a multiple of 4, and + // f32 has no invalid bit patterns. + let floats: &[f32] = bytemuck::cast_slice(&blob); + let data = floats.to_vec(); + Ok(BodyHeightmap { + body_id: body_id.to_string(), + width, + height, + data, + sea_level, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn make_test_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE atlas_body_heightmaps ( + body_id TEXT PRIMARY KEY, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + data BLOB NOT NULL, + sea_level REAL NOT NULL DEFAULT 0.0, + imported_at TEXT NOT NULL DEFAULT (datetime('now')) + );", + ) + .unwrap(); + conn + } + + fn insert_heightmap(conn: &Connection, body_id: &str, w: u32, h: u32, sea_level: f32) { + let floats: Vec = (0..(w * h)) + .map(|i| i as f32 / (w * h) as f32) + .collect(); + let bytes: &[u8] = bytemuck::cast_slice(&floats); + conn.execute( + "INSERT INTO atlas_body_heightmaps (body_id, width, height, data, sea_level) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![body_id, w, h, bytes, sea_level], + ) + .unwrap(); + } + + #[test] + fn round_trip_canonical_size() { + let conn = make_test_db(); + insert_heightmap(&conn, "TestBody", GRID_W, GRID_H, 0.3); + let hm = load_heightmap(&conn, "TestBody").unwrap(); + assert_eq!(hm.width, GRID_W); + assert_eq!(hm.height, GRID_H); + assert_eq!(hm.data.len(), (GRID_W * GRID_H) as usize); + assert!((hm.sea_level - 0.3).abs() < 1e-6); + // First cell is 0.0, last approaches 1.0 + assert_eq!(hm.data[0], 0.0); + assert!(hm.data.last().copied().unwrap() < 1.0); + } + + #[test] + fn get_and_is_land() { + let conn = make_test_db(); + insert_heightmap(&conn, "LandBody", 4, 2, 0.5); + let hm = load_heightmap(&conn, "LandBody").unwrap(); + // First cell (index 0) = 0.0 / 8 = 0.0 — below sea level + assert!(!hm.is_land(0, 0)); + // Last cell (index 7) = 7.0 / 8 = 0.875 — above sea level + assert!(hm.is_land(1, 3)); + // Out-of-bounds returns false + assert!(!hm.is_land(99, 99)); + } + + #[test] + fn not_found_error() { + let conn = make_test_db(); + let err = load_heightmap(&conn, "Ghost").unwrap_err(); + assert!(matches!(err, HeightmapLoadError::NotFound(_))); + } + + #[test] + fn blob_size_mismatch_error() { + let conn = make_test_db(); + // Insert a truncated BLOB + conn.execute( + "INSERT INTO atlas_body_heightmaps (body_id, width, height, data, sea_level) + VALUES ('BadBlob', 4, 4, X'DEADBEEF', 0.0)", + [], + ) + .unwrap(); + let err = load_heightmap(&conn, "BadBlob").unwrap_err(); + assert!(matches!(err, HeightmapLoadError::BlobSizeMismatch { .. })); + } +} diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs new file mode 100644 index 000000000..7cdc09a48 --- /dev/null +++ b/server/src/atlas/mod.rs @@ -0,0 +1,14 @@ +//! Atlas data loaders — reads pre-computed build-time data from systems.db. +//! +//! These loaders are used by the runtime-background tier (D-200, D-206) when +//! populating BodyWorldState (D-203). They are never called on the main tick thread. + +pub mod attractor_matching; +pub mod block_irregularity; +pub mod body_world_state; +pub mod district_mix; +pub mod drainage; +pub mod gen_queue; +pub mod heightmap; +pub mod skeleton_gen; +pub mod tile_condition; diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs new file mode 100644 index 000000000..896a8bebf --- /dev/null +++ b/server/src/atlas/skeleton_gen.rs @@ -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; 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; 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 { + 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()); + } +} diff --git a/server/src/atlas/tile_condition.rs b/server/src/atlas/tile_condition.rs new file mode 100644 index 000000000..e5faa850f --- /dev/null +++ b/server/src/atlas/tile_condition.rs @@ -0,0 +1,191 @@ +//! Tile condition thresholds and derivation (D-217). +//! +//! A tile's visual condition is derived from the district's `prosperity_score` +//! (0.0–1.0) using four threshold bands. The block's `EraCause` applies a +//! minimum condition floor that prevents high-prosperity scores from masking +//! historical decay. +//! +//! **Threshold bands (D-217):** +//! | Band | Condition | prosperity_score | +//! |------|-----------|-----------------| +//! | 1 | Intact | > 0.63 | +//! | 2 | Worn | 0.43 – 0.63 | +//! | 3 | Cracked | 0.23 – 0.43 | +//! | 4 | Broken | < 0.23 | +//! +//! **Era-based floor (D-217):** +//! - `EconomicDisruption` (Decay-era): minimum Cracked. +//! - `EmergencyExtension`: minimum Worn. +//! - All other eras: no floor — condition follows prosperity_score freely. +//! +//! **Threshold crossing invalidation:** A tile's condition only changes when +//! `prosperity_score` crosses a band boundary. Checked once per game-minute. +//! +//! Threshold values are authored constants (D-217): 0.63, 0.43, 0.23. + +use crate::simulation::generator::EraCause; + +// --------------------------------------------------------------------------- +// TileCondition +// --------------------------------------------------------------------------- + +/// Visual condition band for a tile, derived from prosperity_score (D-217). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum TileCondition { + /// prosperity_score > 0.63. Clean, undamaged, well-maintained. + Intact, + /// prosperity_score 0.43–0.63. Scuff marks, minor discoloration, partial repairs. + Worn, + /// prosperity_score 0.23–0.43. Visible damage, incomplete repair, graffiti. + Cracked, + /// prosperity_score < 0.23. Structural damage, debris, derelict appearance. + Broken, +} + +// --------------------------------------------------------------------------- +// Threshold constants (D-217 authored — do not compute at runtime) +// --------------------------------------------------------------------------- + +pub const THRESHOLD_INTACT: f32 = 0.63; +pub const THRESHOLD_WORN: f32 = 0.43; +pub const THRESHOLD_CRACKED: f32 = 0.23; + +// --------------------------------------------------------------------------- +// Derivation +// --------------------------------------------------------------------------- + +/// Derive `TileCondition` from `prosperity_score` alone (no era floor). +pub fn condition_from_score(prosperity_score: f32) -> TileCondition { + if prosperity_score > THRESHOLD_INTACT { + TileCondition::Intact + } else if prosperity_score > THRESHOLD_WORN { + TileCondition::Worn + } else if prosperity_score > THRESHOLD_CRACKED { + TileCondition::Cracked + } else { + TileCondition::Broken + } +} + +/// Era-based minimum condition floor (D-217). +/// +/// Returns the minimum `TileCondition` for a block with the given `EraCause`. +/// `None` means no floor — condition follows prosperity_score freely. +pub fn era_condition_floor(era_cause: Option<&EraCause>) -> Option { + match era_cause { + Some(EraCause::EconomicDisruption) => Some(TileCondition::Cracked), + Some(EraCause::EmergencyExtension) => Some(TileCondition::Worn), + _ => None, + } +} + +/// Derive `TileCondition` with era-based floor applied. +/// +/// If the era floor is stricter (lower condition) than the score-derived +/// condition, the floor wins. +pub fn tile_condition(prosperity_score: f32, era_cause: Option<&EraCause>) -> TileCondition { + let from_score = condition_from_score(prosperity_score); + match era_condition_floor(era_cause) { + Some(floor) => { + // Lower enum discriminant = better condition (Intact < Worn < Cracked < Broken). + // Floor is a *minimum degradation* — we want the worse of the two. + if floor > from_score { + floor + } else { + from_score + } + } + None => from_score, + } +} + +/// Check whether a threshold crossing occurred between two prosperity scores. +/// +/// Returns `true` if the tile's condition band changed between `old_score` and +/// `new_score`. Used by the game-minute update loop to decide whether to +/// apply a `ChunkMutation.tile_override`. +pub fn threshold_crossed(old_score: f32, new_score: f32) -> bool { + condition_from_score(old_score) != condition_from_score(new_score) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn intact_above_0_63() { + assert_eq!(condition_from_score(0.64), TileCondition::Intact); + assert_eq!(condition_from_score(1.0), TileCondition::Intact); + } + + #[test] + fn worn_between_0_43_and_0_63() { + assert_eq!(condition_from_score(0.63), TileCondition::Worn); + assert_eq!(condition_from_score(0.50), TileCondition::Worn); + assert_eq!(condition_from_score(0.44), TileCondition::Worn); + } + + #[test] + fn cracked_between_0_23_and_0_43() { + assert_eq!(condition_from_score(0.43), TileCondition::Cracked); + assert_eq!(condition_from_score(0.30), TileCondition::Cracked); + assert_eq!(condition_from_score(0.24), TileCondition::Cracked); + } + + #[test] + fn broken_below_0_23() { + assert_eq!(condition_from_score(0.23), TileCondition::Broken); + assert_eq!(condition_from_score(0.10), TileCondition::Broken); + assert_eq!(condition_from_score(0.0), TileCondition::Broken); + } + + #[test] + fn era_floor_decay_enforces_cracked_minimum() { + // Prosperous district in an EconomicDisruption-era block — still Cracked. + let cond = tile_condition(0.90, Some(&EraCause::EconomicDisruption)); + assert_eq!(cond, TileCondition::Cracked, + "EconomicDisruption floor must prevent Intact/Worn"); + } + + #[test] + fn era_floor_emergency_extension_enforces_worn_minimum() { + // High prosperity EmergencyExtension block should never be Intact. + let cond = tile_condition(0.80, Some(&EraCause::EmergencyExtension)); + assert_eq!(cond, TileCondition::Worn); + } + + #[test] + fn era_floor_does_not_improve_condition() { + // EconomicDisruption floor = Cracked; Broken score stays Broken. + let cond = tile_condition(0.10, Some(&EraCause::EconomicDisruption)); + assert_eq!(cond, TileCondition::Broken, + "Era floor must not improve condition below score-derived value"); + } + + #[test] + fn no_era_cause_follows_score() { + let cond = tile_condition(0.90, None); + assert_eq!(cond, TileCondition::Intact); + } + + #[test] + fn threshold_crossed_detects_band_change() { + // 0.7 → 0.5 crosses the 0.63 boundary. + assert!(threshold_crossed(0.70, 0.50)); + // 0.55 → 0.48 stays in Worn band. + assert!(!threshold_crossed(0.55, 0.48)); + // 0.40 → 0.20 crosses 0.23 boundary. + assert!(threshold_crossed(0.40, 0.20)); + } + + #[test] + fn condition_ordering_intact_is_best() { + assert!(TileCondition::Intact < TileCondition::Worn); + assert!(TileCondition::Worn < TileCondition::Cracked); + assert!(TileCondition::Cracked < TileCondition::Broken); + } +}