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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 18:11:13 +02:00
co-authored by Claude Sonnet 4.6
parent f9fdfb7712
commit b9fd7a3fc8
10 changed files with 3326 additions and 0 deletions
+618
View File
@@ -0,0 +1,618 @@
//! Attractor-matching five-phase pipeline for settlement placement (D-211).
//!
//! Given a body's `Vec<GeographicAttractor>` 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,000999,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<f32>]) -> Vec<usize> {
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<f32>> = 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<CityPlacement> {
let default_cost = vec![1.0f32; attractors.len()];
let costs = terrain_costs.unwrap_or(&default_cost);
let mut placements: Vec<CityPlacement> = Vec::with_capacity(cities.len());
let mut used_attractors: Vec<bool> = vec![false; attractors.len()];
// -------------------------------------------------------------------------
// Phase 1: Score matrix
// -------------------------------------------------------------------------
let scores: Vec<Vec<f32>> = 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<usize> = 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,000999,999)
// -------------------------------------------------------------------------
let tier_bc_indices: Vec<usize> = 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<usize> = (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<Vec<f32>> = 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<u64> =
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<u64> =
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 0359.
/// 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<CityRecord> = (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));
}
}
+111
View File
@@ -0,0 +1,111 @@
//! BlockIrregularity derivation from founding_age and PoliticalArchetype (D-216).
//!
//! `block_irregularity` (0.01.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 (01000, 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.051.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);
}
}
}
+249
View File
@@ -0,0 +1,249 @@
//! BodyWorldState — per-body Layer 12 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 12 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<f32>,
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<DrainageBasin>,
/// Geographic attractors (D-195, D-209). Empty until attractor task completes.
pub attractors: Vec<GeographicAttractor>,
/// 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<String, BodyWorldState>,
/// Body IDs that must not be evicted regardless of `last_accessed`.
pinned: std::collections::HashSet<String>,
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());
}
}
+359
View File
@@ -0,0 +1,359 @@
//! Three-component district mix algorithm for city district type distribution (D-194).
//!
//! Given a city's population, economic role, and political archetype, produces
//! a district type distribution (count of each DistrictType) used by the
//! Phase 1 district skeleton generator.
//!
//! **Components (D-194):**
//! 1. Population tier guarantees — minimum district counts by city size.
//! 2. 10×9 economic multiplier table — economic role × DistrictType weights.
//! 3. Political archetype modifiers — shift weights for specific district types.
//!
//! **Determinism (D-010, D-194):** Integer weights throughout. No f32 in the
//! district count computation. Seed-driven noise uses seeded RNG.
use crate::simulation::generator::{DistrictType, PoliticalArchetype};
// ---------------------------------------------------------------------------
// Population tier
// ---------------------------------------------------------------------------
/// Population tier: `floor(log10(pop / 1_000_000))`, capped at [0, 5].
pub fn population_tier(population: i64) -> u8 {
if population <= 0 {
return 0;
}
let ratio = population as f64 / 1_000_000.0;
if ratio <= 0.0 {
return 0;
}
let tier = ratio.log10().floor() as i32;
tier.clamp(0, 5) as u8
}
/// Minimum district counts guaranteed by population tier (D-194).
///
/// Returns `(transit_min, commercial_min, residential_min)`.
pub fn tier_guarantees(tier: u8) -> (u32, u32, u32) {
match tier {
0 => (0, 0, 1),
1 => (0, 1, 1),
2 => (1, 1, 2),
3 => (1, 2, 3),
4 => (2, 3, 4),
5 => (3, 4, 6),
_ => (3, 4, 6),
}
}
// ---------------------------------------------------------------------------
// Economic multiplier table (10×9, integer weights × 10 for precision)
// ---------------------------------------------------------------------------
/// District type column order (08).
/// 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 (09).
fn role_row(economic_role: &str) -> usize {
match economic_role {
"manufacturing" => 0,
"financial" => 1,
"agricultural" => 2,
"extraction" => 3,
"service_mixed" => 4,
"institutional" => 5,
"transit_hub" => 6,
"research" => 7,
"military" => 8,
"residential" | _ => 9,
}
}
/// 10×9 economic multiplier table. Values are integer weights × 10.
/// Rows: manufacturing(0), financial(1), agricultural(2), extraction(3),
/// service_mixed(4), institutional(5), transit_hub(6), research(7),
/// military(8), residential(9).
/// Columns: LogisticsHub(0), Residential(1), Commercial(2), Industrial(3),
/// Administrative(4), Entertainment(5), MixedUse(6), Transit(7),
/// Specialized(8).
#[rustfmt::skip]
const ECON_TABLE: [[u32; 9]; 10] = [
// LH Re Co In Ad En Mu Tr Sp
[25, 10, 15, 30, 10, 5, 10, 20, 10], // manufacturing
[10, 15, 30, 10, 20, 15, 20, 15, 10], // financial
[20, 20, 10, 15, 10, 5, 20, 10, 5], // agricultural
[30, 10, 10, 30, 10, 5, 5, 15, 10], // extraction
[15, 20, 25, 10, 10, 20, 25, 20, 10], // service_mixed
[10, 15, 10, 10, 30, 10, 10, 10, 20], // institutional
[25, 10, 15, 10, 10, 10, 10, 30, 10], // transit_hub
[10, 15, 10, 15, 20, 10, 10, 10, 30], // research
[10, 20, 5, 15, 20, 5, 5, 10, 15], // military
[10, 30, 15, 5, 10, 15, 25, 10, 5], // residential
];
// ---------------------------------------------------------------------------
// Political archetype modifiers
// ---------------------------------------------------------------------------
/// Additive integer modifiers to column weights based on `PoliticalArchetype`.
/// Returns `[mod; 9]` for columns in `DIST_COLS` order.
fn archetype_modifiers(archetype: &PoliticalArchetype) -> [i32; 9] {
match archetype {
PoliticalArchetype::Commission => {
// Boosts Administrative + Institutional-style Specialized.
[0, 0, 0, 0, 10, 0, 0, 0, 5]
}
PoliticalArchetype::Corporate => {
// Boosts Commercial + Specialized (restricted campus zones).
[0, -5, 15, 0, 0, 5, 0, 0, 10]
}
PoliticalArchetype::Pioneer => {
// Boosts MixedUse + organic Residential.
[0, 10, 5, 0, -5, 5, 15, 0, 0]
}
PoliticalArchetype::Military => {
// Boosts Administrative + reduces Entertainment.
[0, 5, -5, 5, 15, -10, 0, 0, 10]
}
PoliticalArchetype::Academic => {
// Boosts Specialized (research labs) + Administrative.
[0, 5, 0, 0, 10, 5, 5, 0, 20]
}
PoliticalArchetype::Industrial => {
// Boosts Industrial + LogisticsHub.
[10, -5, 5, 20, 0, -5, 0, 5, 5]
}
}
}
// ---------------------------------------------------------------------------
// District mix computation
// ---------------------------------------------------------------------------
/// The district type distribution for a generated city.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DistrictMix {
/// Ordered list of district types for the city, with repetition (district_count items total).
pub districts: Vec<DistrictType>,
/// Total district count.
pub total: u32,
}
/// Compute the district mix for one city (D-194).
///
/// `total_districts` is the number of districts to allocate. A good default is
/// `max(4, population_tier * 2)`.
///
/// `seed` is the city-level RNG seed (D-010 determinism).
pub fn compute_district_mix(
population: i64,
economic_role: &str,
archetype: &PoliticalArchetype,
total_districts: u32,
seed: u64,
) -> DistrictMix {
let tier = population_tier(population);
let (transit_min, commercial_min, residential_min) = tier_guarantees(tier);
let row = role_row(economic_role);
let arch_mods = archetype_modifiers(archetype);
// Build effective weights (integer, clamped to ≥ 1).
let mut weights: [u32; 9] = [0; 9];
for col in 0..9 {
let base = ECON_TABLE[row][col] as i32;
let modified = base + arch_mods[col];
weights[col] = modified.max(1) as u32;
}
// Allocate districts proportionally from weights using a seeded LCG.
// We avoid f32 by using integer weighted random selection.
let weight_sum: u32 = weights.iter().sum();
let mut counts: [u32; 9] = [0; 9];
let mut lcg = LcgRng::new(seed);
for _ in 0..total_districts {
let mut pick = lcg.next_u32() % weight_sum;
for col in 0..9 {
if pick < weights[col] {
counts[col] += 1;
break;
}
pick -= weights[col];
}
}
// Apply tier guarantees (add if under minimum).
let transit_col = dist_col(&DistrictType::Transit);
let commercial_col = dist_col(&DistrictType::Commercial);
let residential_col = dist_col(&DistrictType::Residential);
if counts[transit_col] < transit_min {
counts[transit_col] = transit_min;
}
if counts[commercial_col] < commercial_min {
counts[commercial_col] = commercial_min;
}
if counts[residential_col] < residential_min {
counts[residential_col] = residential_min;
}
// Build the flat ordered list.
let mut districts: Vec<DistrictType> = Vec::new();
for (col, &count) in counts.iter().enumerate() {
for _ in 0..count {
districts.push(DIST_COLS[col].clone());
}
}
let total = districts.len() as u32;
DistrictMix { districts, total }
}
// ---------------------------------------------------------------------------
// Minimal seeded LCG (no f32, D-010 compliant)
// ---------------------------------------------------------------------------
struct LcgRng {
state: u64,
}
impl LcgRng {
fn new(seed: u64) -> Self {
Self { state: seed.wrapping_add(1) }
}
fn next_u64(&mut self) -> u64 {
// LCG parameters from Knuth
self.state = self.state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
self.state
}
fn next_u32(&mut self) -> u32 {
(self.next_u64() >> 33) as u32
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn population_tier_values() {
assert_eq!(population_tier(0), 0);
assert_eq!(population_tier(50_000), 0); // 0.05M → log10 < 0 → tier 0
assert_eq!(population_tier(1_000_000), 0); // 1M → log10(1) = 0 → tier 0
assert_eq!(population_tier(10_000_000), 1); // 10M → log10(10) = 1 → tier 1
assert_eq!(population_tier(100_000_000), 2); // 100M → tier 2
assert_eq!(population_tier(1_000_000_000_000), 5); // capped at 5
}
#[test]
fn mix_sums_at_least_to_requested() {
let mix = compute_district_mix(
5_000_000,
"manufacturing",
&PoliticalArchetype::Industrial,
8,
42,
);
// total may exceed requested due to guarantees
assert!(mix.total >= 8, "district count should be >= requested");
}
#[test]
fn tier_guarantees_applied() {
// Tier 2 city: pop/1M = 100999, 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);
}
}
}
+661
View File
@@ -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<DrainageBasin>,
}
// ---------------------------------------------------------------------------
// 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<i64> = 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<i64> {
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 (07), or -1 for no outflow.
fn flow_direction(filled: &[i64], w: usize, h: usize) -> Vec<i8> {
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<i32> {
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<bool> = (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<i32> {
let n = w * h;
let mut labels = vec![-1i32; n];
// Pour points: local accumulation maxima above river threshold.
let mut pour_pts: Vec<usize> = 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<usize> = 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<i32>,
w: usize,
h: usize,
min_count: usize,
max_count: usize,
) -> Vec<i32> {
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<i32, usize> = 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<i32> = {
let mut set: std::collections::HashSet<i32> = std::collections::HashSet::new();
for &l in &labels {
set.insert(l);
}
let mut v: Vec<i32> = set.into_iter().collect();
v.sort();
v
};
let remap: std::collections::HashMap<i32, i32> = 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<i32, usize>,
w: usize,
h: usize,
) -> Option<i32> {
let n = w * h;
let mut neighbor_sizes: std::collections::HashMap<i32, usize> =
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<DrainageBasin> {
let n = w * h;
let mut basin_map: std::collections::HashMap<i32, Vec<usize>> =
std::collections::HashMap::new();
for (i, &l) in labels.iter().enumerate() {
basin_map.entry(l).or_default().push(i);
}
let mut basins: Vec<DrainageBasin> = Vec::with_capacity(basin_map.len());
let mut ids: Vec<i32> = 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::<f32>()
/ boundary.len() as f32;
let cc = boundary.iter().map(|&(_, c)| c as f32).sum::<f32>()
/ 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<f32> {
vec![val; (w * h) as usize]
}
fn slope_grid(w: u32, h: u32) -> Vec<f32> {
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"
);
}
}
+361
View File
@@ -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<Mutex<Vec<QueuedWork>>>,
/// Completions channel — background tasks send here; main thread reads.
completion_tx: Sender<GenCompletion>,
completion_rx: Receiver<GenCompletion>,
/// Rayon thread pool dedicated to generation work.
pool: rayon::ThreadPool,
/// Set of body_ids currently in-flight to avoid duplicate submissions.
in_flight: Arc<Mutex<std::collections::HashSet<String>>>,
}
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<GenCompletion> {
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);
}
}
+201
View File
@@ -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<f32>` 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<f32>,
/// 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<f32> {
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<u8>` 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<BodyHeightmap, HeightmapLoadError> {
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<u8> = 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<f32> = (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 { .. }));
}
}
+14
View File
@@ -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;
+561
View File
@@ -0,0 +1,561 @@
//! Phase 1 district skeleton generator (D-194, D-196, D-211, D-213, D-214).
//!
//! Entry point: [`generate_skeleton`]. Consumes a [`CityGenerationContext`]
//! together with the city's raw population and economic role, and produces a
//! fully classified [`DistrictSkeleton`] with:
//!
//! - [`SettingType`] derived from the surrounding biome context.
//! - [`ComplexityTier`] derived from population tier × [`WorldTier`].
//! - [`DistrictLayoutMode`] derived from [`PoliticalArchetype`].
//! - 4×4 block grid with [`ZoningType`] assignments from the district-mix
//! algorithm (D-194).
//! - [`MultiBlockReservation`]s for parks (pop tier ≥ 2) and transit
//! terminals (transit_hub role or pop tier ≥ 3).
//!
//! **Phase 1 scope only** — no chunk-level tiles, no NPC placement, no tile
//! condition data. All stub fields (corridors, social_sites, etc.) are empty.
//!
//! **Determinism (D-010):** Seeded LCG via the district seed; no floating-point
//! in block assignment.
use crate::atlas::block_irregularity::block_irregularity;
use crate::atlas::district_mix::{compute_district_mix, population_tier};
use crate::simulation::generator::{
BlockPlacement, BlockSkeleton, ComplexityTier, DistrictId, DistrictLayoutMode,
DistrictSkeleton, DistrictType, MultiBlockReservation, PoliticalArchetype,
ReservationFunction, ReservationId, SettingType, WorldTier, ZoningType,
CityGenerationContext,
};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Generate a Phase 1 [`DistrictSkeleton`] from a city's generation context.
///
/// # Parameters
/// - `context`: Build-time city context (archetype, world tier, orientation…).
/// - `population`: Raw population count from atlas_city_names.
/// - `economic_role`: Economic role string (one of the 10 canonical values).
/// - `district_id`: Content-addressable identifier for this district.
/// - `founding_age_years`: Years since founding — controls block irregularity.
/// - `seed`: Deterministic seed for this district (derived from master seed via SeedChain).
pub fn generate_skeleton(
context: &CityGenerationContext,
population: i64,
economic_role: &str,
district_id: DistrictId,
founding_age_years: u32,
seed: u64,
) -> DistrictSkeleton {
// ── 1. SettingType ────────────────────────────────────────────────────
// Pass through the surrounding_biome from context — it already encodes
// the planet/station/wilderness classification established at atlas time.
let setting = derive_setting(&context.surrounding_biome, economic_role);
// ── 2. ComplexityTier ─────────────────────────────────────────────────
let tier = population_tier(population);
let complexity = derive_complexity(&context.world_tier, tier, population);
// ── 3. DistrictLayoutMode ─────────────────────────────────────────────
let irregularity = block_irregularity(founding_age_years, &context.political_archetype);
let layout_mode = derive_layout_mode(&context.political_archetype, irregularity, seed);
// ── 4. District mix → block grid ─────────────────────────────────────
// A single district occupies a 4×4 block grid = 16 blocks.
let total_blocks: u32 = 16;
let mix = compute_district_mix(
population,
economic_role,
&context.political_archetype,
total_blocks,
seed,
);
// ── 5. Multi-block reservations ───────────────────────────────────────
let reservations = derive_reservations(tier, economic_role, seed);
// Build the reservation lookup: block position → reservation id.
let mut block_reservation: [[Option<ReservationId>; 4]; 4] =
[[None, None, None, None]; 4];
for (idx, res) in reservations.iter().enumerate() {
let rid = idx as u64 + 1; // 1-based stable id within this district
for &(row, col) in &res.blocks {
let r = row as usize;
let c = col as usize;
if r < 4 && c < 4 {
block_reservation[r][c] = Some(rid);
}
}
}
// ── Build 4×4 block grid ──────────────────────────────────────────────
// Flat district-mix list is already in deterministic order; assign
// row-major (row 0 col 0 → row 0 col 3 → row 1 col 0 …).
let primary_district_type = mix.districts.first().cloned()
.unwrap_or(DistrictType::MixedUse);
let blocks = build_block_grid(&mix.districts, &block_reservation, &primary_district_type);
// ── Compute z_levels ──────────────────────────────────────────────────
// Phase 1: single-storey above ground for all non-reserved blocks.
// Reserved blocks carry their own z_levels count.
let z_levels: u8 = 1;
DistrictSkeleton {
district_id,
seed,
district_type: district_type_from_mix(&primary_district_type),
context: String::new(), // stub — DistrictContext = String
world_tier: context.world_tier.clone(),
complexity,
setting,
layout_mode,
blocks,
reservations,
corridors: Vec::new(),
z_levels,
social_sites: Vec::new(),
access_points: Vec::new(),
society_profile: String::new(),
zone_palette: Vec::new(),
boundaries: String::new(),
guarantee_audit: None,
}
}
// ---------------------------------------------------------------------------
// SettingType derivation
// ---------------------------------------------------------------------------
/// Derive SettingType from the city's surrounding biome context.
///
/// The surrounding_biome on CityGenerationContext already encodes the
/// planet/station classification. For city districts we map it to
/// Urban (the default for settled cities) or pass Station/Maritime/etc.
/// through directly.
fn derive_setting(surrounding_biome: &SettingType, economic_role: &str) -> SettingType {
match surrounding_biome {
// Station bodies → always Station setting regardless of role.
SettingType::Station => SettingType::Station,
// Orbital platforms.
SettingType::Orbital => SettingType::Orbital,
// Maritime worlds — coastal city districts are Maritime.
SettingType::Maritime => SettingType::Maritime,
// Agricultural worlds → Agricultural districts.
SettingType::Agricultural => SettingType::Agricultural,
// For all other planet classes, city districts are Urban.
// Exception: extraction role on wilderness worlds → Specialized.
SettingType::Wilderness { biome } => {
if economic_role == "extraction" {
SettingType::Specialized {
function: format!("extraction-{biome}"),
}
} else {
SettingType::Urban
}
}
// Transit nodes get Transitional setting.
SettingType::Transitional => SettingType::Transitional,
// Water bodies → Water districts don't host cities; treat as Specialized.
SettingType::Water { .. } => SettingType::Specialized {
function: "waterfront".into(),
},
// Generic Specialized pass-through.
SettingType::Specialized { function } => SettingType::Specialized {
function: function.clone(),
},
// Default for Urban and any unknown variant: Urban.
SettingType::Urban => SettingType::Urban,
}
}
// ---------------------------------------------------------------------------
// ComplexityTier derivation
// ---------------------------------------------------------------------------
/// Derive ComplexityTier from WorldTier + population tier (D-194, D-218).
///
/// | WorldTier | pop_tier ≥ 1 | pop_tier = 0 |
/// |-----------------|---------------|----------------------|
/// | Epicenter | Full | Moderate |
/// | Regional | Full | Moderate |
/// | Backwater | Moderate | Minimal |
/// | Passage | Moderate | Minimal |
/// | Waypoint | Minimal | Minimal (→ Empty <5K)|
fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> ComplexityTier {
// Ghost stub threshold: pop < 5000 on Waypoint → Empty.
if population < 5_000 && matches!(world_tier, WorldTier::Waypoint) {
return ComplexityTier::Empty;
}
match world_tier {
WorldTier::Epicenter | WorldTier::Regional => {
if pop_tier >= 1 { ComplexityTier::Full } else { ComplexityTier::Moderate }
}
WorldTier::Backwater | WorldTier::Passage => {
if pop_tier >= 1 { ComplexityTier::Moderate } else { ComplexityTier::Minimal }
}
WorldTier::Waypoint => ComplexityTier::Minimal,
}
}
// ---------------------------------------------------------------------------
// DistrictLayoutMode derivation
// ---------------------------------------------------------------------------
/// Derive DistrictLayoutMode from PoliticalArchetype + block irregularity (D-213, D-214).
///
/// Commission / Military / Corporate / Academic → Grid (planned geometry).
/// Pioneer / Industrial → Organic (organic growth with per-block offsets).
fn derive_layout_mode(
archetype: &PoliticalArchetype,
irregularity: f32,
seed: u64,
) -> DistrictLayoutMode {
match archetype {
PoliticalArchetype::Commission
| PoliticalArchetype::Military
| PoliticalArchetype::Corporate
| PoliticalArchetype::Academic => DistrictLayoutMode::Grid,
PoliticalArchetype::Pioneer | PoliticalArchetype::Industrial => {
// Organic: generate per-block offsets and rotations seeded from district seed.
let placements = organic_placements(irregularity, seed);
DistrictLayoutMode::Organic { placements }
}
}
}
/// Generate 4×4 organic block placements seeded deterministically (D-010).
///
/// Uses a seeded LCG; offset range controlled by `irregularity` (0.051.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; // 03 (15° increments, max 45°)
// Street width 750020000 bps proportional to irregularity.
let width_range = 12_500u32; // 20000 - 7500
let width = 7_500u32 + (lcg.next_u32() % (width_range + 1));
*item = BlockPlacement {
offset: (raw_x, raw_y),
rotation_steps: rot,
street_width_bps: width as u16,
};
}
// Safety: BlockPlacement is Copy-able; reshape flat array to [[_; 4]; 4].
core::array::from_fn(|row| {
core::array::from_fn(|col| flat[row * 4 + col].clone())
})
}
// ---------------------------------------------------------------------------
// Block grid construction
// ---------------------------------------------------------------------------
/// Map a DistrictType to its primary ZoningType (D-194).
fn zoning_for_district(dt: &DistrictType) -> ZoningType {
match dt {
DistrictType::LogisticsHub => ZoningType::Industrial,
DistrictType::Residential => ZoningType::Residential,
DistrictType::Commercial => ZoningType::Commercial,
DistrictType::Industrial => ZoningType::Industrial,
DistrictType::Administrative => ZoningType::Administrative,
DistrictType::Entertainment => ZoningType::Commercial,
DistrictType::MixedUse => ZoningType::Mixed,
DistrictType::Transit => ZoningType::Transit,
DistrictType::Specialized => ZoningType::Restricted,
}
}
/// Map the primary district type to the DistrictType field on DistrictSkeleton.
fn district_type_from_mix(primary: &DistrictType) -> DistrictType {
primary.clone()
}
/// Build the 4×4 BlockSkeleton grid from the district mix list and reservation map.
///
/// Blocks are assigned row-major (index = row * 4 + col).
/// Reserved blocks retain their zoning from the district mix but link to the reservation.
fn build_block_grid(
districts: &[DistrictType],
block_reservation: &[[Option<ReservationId>; 4]; 4],
primary: &DistrictType,
) -> [[BlockSkeleton; 4]; 4] {
// Pad or truncate district list to exactly 16.
let district_iter: Vec<&DistrictType> = (0..16)
.map(|i| districts.get(i).unwrap_or(primary))
.collect();
core::array::from_fn(|row| {
core::array::from_fn(|col| {
let idx = row * 4 + col;
let dt = district_iter[idx];
let zoning = zoning_for_district(dt);
let reservation = block_reservation[row][col];
let density = density_for_zoning(&zoning);
BlockSkeleton {
position: (row as u8, col as u8),
zoning,
reservation,
chunk_layout: String::new(), // stub
hosted_sites: Vec::new(),
era: String::new(), // stub
era_modifications: Vec::new(),
era_cause: None,
density_pct: density,
landmark: None,
}
})
})
}
/// Default build density percentage for a zoning type.
fn density_for_zoning(zoning: &ZoningType) -> u8 {
match zoning {
ZoningType::Residential => 60,
ZoningType::Commercial => 80,
ZoningType::Industrial => 70,
ZoningType::Administrative => 75,
ZoningType::Transit => 50,
ZoningType::Recreational => 30,
ZoningType::Restricted => 85,
ZoningType::Mixed => 65,
}
}
// ---------------------------------------------------------------------------
// Multi-block reservations
// ---------------------------------------------------------------------------
/// Derive Phase 1 multi-block reservations for a city (D-211, D-194).
///
/// Reservation rules:
/// - Pop tier ≥ 2 → one 2×2 park reservation at the center-right (blocks (1,2),(1,3),(2,2),(2,3)).
/// - Transit_hub role OR pop tier ≥ 3 → one 1×2 transit terminal at row 0 cols 01.
///
/// Phase 1 produces skeleton-only reservations — floor_zones and vertical_corridors
/// are deferred to Phase 2.
fn derive_reservations(pop_tier: u8, economic_role: &str, _seed: u64) -> Vec<MultiBlockReservation> {
let mut out = Vec::new();
// Park: large cities need open space.
if pop_tier >= 2 {
out.push(MultiBlockReservation {
blocks: vec![(1, 2), (1, 3), (2, 2), (2, 3)],
template_tag: "park-central".into(),
function: ReservationFunction::Park,
z_levels: 1,
base_z: 0,
floor_zones: Vec::new(),
z_band_count: 1,
z_band_zones: Vec::new(),
vertical_corridors: Vec::new(),
hosted_sites: Vec::new(),
});
}
// Transit terminal: transit-hub economies and major cities.
if economic_role == "transit_hub" || pop_tier >= 3 {
out.push(MultiBlockReservation {
blocks: vec![(0, 0), (0, 1)],
template_tag: "transit-terminal".into(),
function: ReservationFunction::Terminal,
z_levels: 2,
base_z: -1, // one level of underground rail
floor_zones: Vec::new(),
z_band_count: 2,
z_band_zones: Vec::new(),
vertical_corridors: Vec::new(),
hosted_sites: Vec::new(),
});
}
out
}
// ---------------------------------------------------------------------------
// Minimal seeded LCG (D-010 determinism)
// ---------------------------------------------------------------------------
struct SkeletonLcg {
state: u64,
}
impl SkeletonLcg {
fn new(seed: u64) -> Self {
// Mix seed to avoid degenerate state at 0.
Self { state: seed.wrapping_add(0x9e37_79b9_7f4a_7c15) }
}
fn next_u64(&mut self) -> u64 {
self.state = self.state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
self.state
}
fn next_u32(&mut self) -> u32 {
(self.next_u64() >> 33) as u32
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::simulation::generator::{
CityGenerationContext, FoundingOrientation, PoliticalArchetype, SettingType, WorldTier,
};
fn make_context(archetype: PoliticalArchetype, world_tier: WorldTier) -> CityGenerationContext {
CityGenerationContext {
city_id: 1,
political_archetype: archetype,
prosperity_baseline: 0.7,
surrounding_biome: SettingType::Urban,
road_entry_directions: vec![0, 4],
footprint_radius_km: 10.0,
founding_orientation: FoundingOrientation::Cardinal,
world_tier,
}
}
#[test]
fn setting_station_passthrough() {
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
ctx.surrounding_biome = SettingType::Station;
let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 200, 42);
assert!(matches!(sk.setting, SettingType::Station));
}
#[test]
fn setting_urban_for_city_on_planet() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, 42);
assert!(matches!(sk.setting, SettingType::Urban));
}
#[test]
fn complexity_epicenter_high_pop_is_full() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
// 10M pop → pop_tier = 1 → Full on Epicenter
let sk = generate_skeleton(&ctx, 10_000_000, "financial", 1, 200, 42);
assert_eq!(sk.complexity, ComplexityTier::Full);
}
#[test]
fn complexity_waypoint_tiny_pop_is_empty() {
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Waypoint);
let sk = generate_skeleton(&ctx, 1_000, "residential", 1, 50, 42);
assert_eq!(sk.complexity, ComplexityTier::Empty);
}
#[test]
fn complexity_backwater_low_pop_is_minimal() {
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
// 50_000 pop → pop_tier = 0 → Minimal on Backwater
let sk = generate_skeleton(&ctx, 50_000, "residential", 1, 50, 42);
assert_eq!(sk.complexity, ComplexityTier::Minimal);
}
#[test]
fn layout_commission_is_grid() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 100, 99);
assert!(matches!(sk.layout_mode, DistrictLayoutMode::Grid));
}
#[test]
fn layout_pioneer_is_organic() {
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Regional);
let sk = generate_skeleton(&ctx, 500_000, "residential", 1, 400, 99);
assert!(matches!(sk.layout_mode, DistrictLayoutMode::Organic { .. }));
}
#[test]
fn block_grid_is_fully_populated() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, 42);
// All 16 blocks must have valid positions.
for row in 0..4 {
for col in 0..4 {
let b = &sk.blocks[row][col];
assert_eq!(b.position, (row as u8, col as u8));
assert!(b.density_pct <= 100);
}
}
}
#[test]
fn no_reservations_for_small_city() {
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
// pop_tier 0, not transit_hub → no reservations.
let sk = generate_skeleton(&ctx, 80_000, "residential", 1, 50, 42);
assert!(sk.reservations.is_empty());
}
#[test]
fn park_reservation_for_large_city() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
// 100M pop → pop_tier 2 → park reservation.
let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, 42);
let has_park = sk.reservations.iter()
.any(|r| matches!(r.function, ReservationFunction::Park));
assert!(has_park);
}
#[test]
fn transit_terminal_for_transit_hub_role() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
// pop_tier 0 but transit_hub → terminal reservation.
let sk = generate_skeleton(&ctx, 80_000, "transit_hub", 1, 200, 42);
let has_terminal = sk.reservations.iter()
.any(|r| matches!(r.function, ReservationFunction::Terminal));
assert!(has_terminal);
}
#[test]
fn reserved_blocks_linked_in_grid() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
// 100M pop → park at (1,2),(1,3),(2,2),(2,3) with reservation id 1.
let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, 42);
// All park blocks must reference the park reservation (id=1).
for &(row, col) in &[(1u8, 2u8), (1, 3), (2, 2), (2, 3)] {
let b = &sk.blocks[row as usize][col as usize];
assert!(b.reservation.is_some(), "block ({row},{col}) should be reserved");
}
}
#[test]
fn determinism_same_seed_same_output() {
let ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional);
let sk1 = generate_skeleton(&ctx, 2_000_000, "manufacturing", 77, 250, 12345);
let sk2 = generate_skeleton(&ctx, 2_000_000, "manufacturing", 77, 250, 12345);
// Compare block grid zoning and positions.
for row in 0..4 {
for col in 0..4 {
assert_eq!(sk1.blocks[row][col].zoning, sk2.blocks[row][col].zoning);
assert_eq!(sk1.blocks[row][col].position, sk2.blocks[row][col].position);
assert_eq!(sk1.blocks[row][col].density_pct, sk2.blocks[row][col].density_pct);
}
}
assert_eq!(sk1.reservations.len(), sk2.reservations.len());
}
}
+191
View File
@@ -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.01.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.430.63. Scuff marks, minor discoloration, partial repairs.
Worn,
/// prosperity_score 0.230.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<TileCondition> {
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);
}
}