feat(simulation): add server/src/atlas/ — full Phase 1 generation pipeline
Ten-module atlas package implementing the D-194–D-218 district generation stack: heightmap loader, BodyWorldState LRU cache, D8 drainage routing, background generation queue, five-phase attractor-matching, three-component district mix, block irregularity, tile condition thresholds, and the Phase 1 skeleton generator that wires them into DistrictSkeleton. Closes #916 #917 #918 #919 #920 #922 #923 #924 #899. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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,000–999,999 cities — optimal global assignment.
|
||||
//! 4. Synthetic overflow: any remaining city gets a synthetic `PlainCenter` attractor.
|
||||
//! 5. Name fulfillment check: warn if any atlas city was not placed.
|
||||
//!
|
||||
//! **Mismatch flagging (D-211):**
|
||||
//! - score < 0.35 → WARNING
|
||||
//! - score < 0.15 → ERROR (flagged for manual review; generation continues)
|
||||
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::simulation::generator::{
|
||||
AttractorType, CompatibilityMatrix, GeographicAttractor, SettlementClass,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One city record from atlas_city_names, projected for matching.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CityRecord {
|
||||
pub city_id: u64,
|
||||
pub name: String,
|
||||
pub settlement_class: SettlementClass,
|
||||
pub population: i64,
|
||||
/// One of: manufacturing, financial, agricultural, extraction,
|
||||
/// service_mixed, institutional, transit_hub, research, military, residential.
|
||||
pub economic_role: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result of matching one city to one attractor (real or synthetic).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CityPlacement {
|
||||
pub city_id: u64,
|
||||
pub position: (u16, u16),
|
||||
pub attractor_type: AttractorType,
|
||||
pub score: f32,
|
||||
pub synthetic: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Score matrix helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Row index in CompatibilityMatrix for an economic_role string.
|
||||
/// Order from D-195: manufacturing(0), financial(1), agricultural(2), extraction(3),
|
||||
/// service_mixed(4), institutional(5), transit_hub(6), research(7), military(8), residential(9).
|
||||
fn role_row(economic_role: &str) -> usize {
|
||||
match economic_role {
|
||||
"manufacturing" => 0,
|
||||
"financial" => 1,
|
||||
"agricultural" => 2,
|
||||
"extraction" => 3,
|
||||
"service_mixed" => 4,
|
||||
"institutional" => 5,
|
||||
"transit_hub" => 6,
|
||||
"research" => 7,
|
||||
"military" => 8,
|
||||
"residential" | _ => 9,
|
||||
}
|
||||
}
|
||||
|
||||
/// Column index in CompatibilityMatrix for an AttractorType.
|
||||
/// Order from D-195: RiverMouth(0), CoastalAccess(1), RiverCrossing(2), ValleyFloor(3),
|
||||
/// PassEntrance(4), LakeShore(5), PlainCenter(6).
|
||||
fn attractor_col(at: &AttractorType) -> usize {
|
||||
match at {
|
||||
AttractorType::RiverMouth => 0,
|
||||
AttractorType::CoastalAccess => 1,
|
||||
AttractorType::RiverCrossing => 2,
|
||||
AttractorType::ValleyFloor => 3,
|
||||
AttractorType::PassEntrance => 4,
|
||||
AttractorType::LakeShore => 5,
|
||||
AttractorType::PlainCenter => 6,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the raw match score between a city and an attractor.
|
||||
/// Score = matrix_weight × attractor.strength × (1.0 / terrain_modification_cost).
|
||||
fn cell_score(
|
||||
city: &CityRecord,
|
||||
attractor: &GeographicAttractor,
|
||||
matrix: &CompatibilityMatrix,
|
||||
terrain_cost: f32,
|
||||
) -> f32 {
|
||||
let row = role_row(&city.economic_role);
|
||||
let col = attractor_col(&attractor.attractor_type);
|
||||
let weight = matrix.weights[row][col];
|
||||
let cost_factor = if terrain_cost > 0.0 { 1.0 / terrain_cost } else { 1.0 };
|
||||
weight * attractor.strength * cost_factor
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3: Hungarian algorithm (minimization)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// O(n³) Hungarian algorithm for assignment problem.
|
||||
///
|
||||
/// Input: `cost[i][j]` — cost of assigning task j to worker i.
|
||||
/// Lower cost = better fit. Converts the maximization problem to minimization
|
||||
/// by using `max_score - score` as cost.
|
||||
///
|
||||
/// Returns `assignment[i] = j` for each row i.
|
||||
fn hungarian(cost: &[Vec<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,000–999,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 0–359.
|
||||
/// Pass 0 as default when the terrain doesn't dictate a specific bearing.
|
||||
pub fn founding_orientation(
|
||||
attractor_type: &AttractorType,
|
||||
territorial_status: &TerritorialStatus,
|
||||
river_bearing: u16,
|
||||
coastal_facing: u16,
|
||||
) -> FoundingOrientation {
|
||||
match attractor_type {
|
||||
AttractorType::RiverMouth | AttractorType::CoastalAccess => {
|
||||
FoundingOrientation::Coastal { facing_degrees: coastal_facing }
|
||||
}
|
||||
AttractorType::RiverCrossing => {
|
||||
FoundingOrientation::RiverAligned { bearing_degrees: river_bearing }
|
||||
}
|
||||
AttractorType::ValleyFloor => FoundingOrientation::TerrainFollowing,
|
||||
AttractorType::PlainCenter => {
|
||||
if matches!(territorial_status, TerritorialStatus::CommissionControlled) {
|
||||
FoundingOrientation::Cardinal
|
||||
} else {
|
||||
FoundingOrientation::Free { bearing_degrees: 0 }
|
||||
}
|
||||
}
|
||||
AttractorType::PassEntrance | AttractorType::LakeShore => {
|
||||
FoundingOrientation::TerrainFollowing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor};
|
||||
|
||||
fn uniform_matrix() -> CompatibilityMatrix {
|
||||
CompatibilityMatrix { weights: [[1.0; 7]; 10] }
|
||||
}
|
||||
|
||||
fn make_attractor(row: u16, col: u16, at: AttractorType, strength: f32) -> GeographicAttractor {
|
||||
GeographicAttractor { position: (row, col), attractor_type: at, strength }
|
||||
}
|
||||
|
||||
fn make_city(id: u64, class: SettlementClass, pop: i64) -> CityRecord {
|
||||
CityRecord {
|
||||
city_id: id,
|
||||
name: format!("City{id}"),
|
||||
settlement_class: class,
|
||||
population: pop,
|
||||
economic_role: "manufacturing".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_city_single_attractor() {
|
||||
let cities = vec![make_city(1, SettlementClass::NameLocked, 500_000)];
|
||||
let attractors = vec![make_attractor(10, 20, AttractorType::RiverMouth, 0.8)];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
assert_eq!(placements.len(), 1);
|
||||
assert_eq!(placements[0].city_id, 1);
|
||||
assert_eq!(placements[0].position, (10, 20));
|
||||
assert!(!placements[0].synthetic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_a_gets_priority() {
|
||||
// NameLocked city should get the best attractor (high strength).
|
||||
let cities = vec![
|
||||
make_city(1, SettlementClass::NameLocked, 100_000),
|
||||
make_city(2, SettlementClass::PopulationBudget, 200_000),
|
||||
];
|
||||
let attractors = vec![
|
||||
make_attractor(5, 5, AttractorType::RiverMouth, 0.9), // best
|
||||
make_attractor(10, 10, AttractorType::ValleyFloor, 0.4), // second
|
||||
];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
let p1 = placements.iter().find(|p| p.city_id == 1).unwrap();
|
||||
assert_eq!(p1.position, (5, 5), "NameLocked should get best attractor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_produces_synthetic() {
|
||||
// 2 cities, 1 attractor → second city gets synthetic.
|
||||
let cities = vec![
|
||||
make_city(1, SettlementClass::NameLocked, 2_000_000),
|
||||
make_city(2, SettlementClass::PopulationBudget, 60_000),
|
||||
];
|
||||
let attractors = vec![make_attractor(0, 0, AttractorType::RiverMouth, 1.0)];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
assert_eq!(placements.len(), 2);
|
||||
let p2 = placements.iter().find(|p| p.city_id == 2).unwrap();
|
||||
assert!(p2.synthetic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_cities_placed() {
|
||||
let cities: Vec<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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! BlockIrregularity derivation from founding_age and PoliticalArchetype (D-216).
|
||||
//!
|
||||
//! `block_irregularity` (0.0–1.0) controls how much a block deviates from
|
||||
//! the district's canonical grid. Minimum 0.05 — no block is perfectly regular.
|
||||
//!
|
||||
//! **Formula (D-216):**
|
||||
//! ```text
|
||||
//! base_irregularity = (founding_age_years / 1000.0).min(1.0)
|
||||
//! archetype_step = Commission|Military → -0.3, Corporate|Academic → -0.1,
|
||||
//! Industrial → 0.0, Pioneer → +0.3
|
||||
//! block_irregularity = (base + step).clamp(0.05, 1.0)
|
||||
//! ```
|
||||
//!
|
||||
//! **Determinism (D-010, D-216):** Integer-scaled intermediates; archetype_step
|
||||
//! stored as basis points (i32, 1 bp = 0.001). Final result is f32 from integer
|
||||
//! arithmetic to match the D-216 formula.
|
||||
|
||||
use crate::simulation::generator::PoliticalArchetype;
|
||||
|
||||
/// Compute the `block_irregularity` value for one block.
|
||||
///
|
||||
/// - `founding_age_years`: years since the settlement was founded (integer).
|
||||
/// - `archetype`: the settlement's political archetype.
|
||||
///
|
||||
/// Returns a value in [0.05, 1.0].
|
||||
pub fn block_irregularity(founding_age_years: u32, archetype: &PoliticalArchetype) -> f32 {
|
||||
// base_irregularity in integer basis-points (0–1000, where 1000 = 1.0).
|
||||
let base_bp: i32 = (founding_age_years as i32).min(1000);
|
||||
|
||||
// archetype_step in basis-points.
|
||||
let step_bp: i32 = archetype_step_bp(archetype);
|
||||
|
||||
// block_irregularity_bp clamped to [50, 1000] (0.05–1.0).
|
||||
let result_bp = (base_bp + step_bp).clamp(50, 1000);
|
||||
|
||||
result_bp as f32 / 1000.0
|
||||
}
|
||||
|
||||
fn archetype_step_bp(archetype: &PoliticalArchetype) -> i32 {
|
||||
match archetype {
|
||||
PoliticalArchetype::Commission | PoliticalArchetype::Military => -300,
|
||||
PoliticalArchetype::Corporate | PoliticalArchetype::Academic => -100,
|
||||
PoliticalArchetype::Industrial => 0,
|
||||
PoliticalArchetype::Pioneer => 300,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the maximum block offset in sim tiles from `block_irregularity`.
|
||||
///
|
||||
/// Used by `DistrictLayoutMode::Organic`: `max_offset = (irregularity × 16.0) as i16`.
|
||||
pub fn max_offset_sim_tiles(irregularity: f32) -> i16 {
|
||||
(irregularity * 16.0) as i16
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn minimum_is_0_05() {
|
||||
// New Commission city (age 0) → base 0, step -300 → clamp to 50bp = 0.05.
|
||||
let v = block_irregularity(0, &PoliticalArchetype::Commission);
|
||||
assert!((v - 0.05).abs() < 1e-6, "expected 0.05, got {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_is_1_0() {
|
||||
// Old Pioneer city (age 1000+) → base 1000, step +300 → clamp to 1000bp = 1.0.
|
||||
let v = block_irregularity(1500, &PoliticalArchetype::Pioneer);
|
||||
assert!((v - 1.0).abs() < 1e-6, "expected 1.0, got {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pioneer_more_irregular_than_commission() {
|
||||
let pioneer = block_irregularity(400, &PoliticalArchetype::Pioneer);
|
||||
let commission = block_irregularity(400, &PoliticalArchetype::Commission);
|
||||
assert!(pioneer > commission,
|
||||
"Pioneer ({pioneer}) should be more irregular than Commission ({commission})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn age_increases_irregularity() {
|
||||
let young = block_irregularity(50, &PoliticalArchetype::Industrial);
|
||||
let old = block_irregularity(800, &PoliticalArchetype::Industrial);
|
||||
assert!(old > young,
|
||||
"Older settlement ({old}) should be more irregular than young ({young})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_offset_scales_with_irregularity() {
|
||||
assert_eq!(max_offset_sim_tiles(0.05), 0); // 0.05 × 16 = 0.8 → 0
|
||||
assert_eq!(max_offset_sim_tiles(1.0), 16);
|
||||
assert_eq!(max_offset_sim_tiles(0.5), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_archetypes_produce_valid_range() {
|
||||
let archetypes = [
|
||||
PoliticalArchetype::Commission,
|
||||
PoliticalArchetype::Corporate,
|
||||
PoliticalArchetype::Pioneer,
|
||||
PoliticalArchetype::Military,
|
||||
PoliticalArchetype::Academic,
|
||||
PoliticalArchetype::Industrial,
|
||||
];
|
||||
for a in &archetypes {
|
||||
let v = block_irregularity(300, a);
|
||||
assert!(v >= 0.05 && v <= 1.0, "archetype {:?} gave {v} out of [0.05, 1.0]", a);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! BodyWorldState — per-body Layer 1–2 cache (D-203).
|
||||
//!
|
||||
//! `BodyWorldStateCache` is a Bevy `Resource` holding pre-computed generation
|
||||
//! data for up to 50 planetary bodies. Populated by the runtime-background
|
||||
//! tier (D-206) via Rayon tasks; read by the main tick thread without blocking.
|
||||
//!
|
||||
//! Eviction policy: LRU — the body with the oldest `last_accessed` tick is
|
||||
//! evicted on overflow, unless it is pinned (current player location or an
|
||||
//! adjacent-system neighbor).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy_ecs::prelude::Resource;
|
||||
|
||||
use crate::simulation::generator::GeographicAttractor;
|
||||
|
||||
/// Simulation tick counter — monotonically increasing u64.
|
||||
pub type SimTick = u64;
|
||||
|
||||
/// Maximum number of bodies the cache holds before evicting the LRU entry.
|
||||
pub const CACHE_CAPACITY: usize = 50;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub types — filled in by D-208 (#918) and D-205 (#907 Rust side)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// River network extracted by the D8 drainage algorithm (D-208).
|
||||
/// Stub — replaced when #918 is implemented.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RiverNetwork {
|
||||
/// Pixel positions (row, col) of all river cells (flow_accumulation > 200).
|
||||
pub river_cells: Vec<(u16, u16)>,
|
||||
/// Positions where two or more rivers merge.
|
||||
pub confluences: Vec<(u16, u16)>,
|
||||
/// Positions where rivers reach sea level or the heightmap edge.
|
||||
pub mouths: Vec<(u16, u16)>,
|
||||
}
|
||||
|
||||
/// One drainage basin / province derived from watershed analysis (D-205).
|
||||
/// Stub — boundary polyline data comes from atlas_province_boundaries.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DrainageBasin {
|
||||
pub basin_id: u32,
|
||||
/// Boundary polyline as pixel-space (row, col) points.
|
||||
pub boundary: Vec<(u16, u16)>,
|
||||
/// Fraction of the body's surface area in this basin.
|
||||
pub area_pct: f32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BodyWorldState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pre-computed Layer 1–2 generation data for one planetary body.
|
||||
///
|
||||
/// Produced by the runtime-background tier and stored in `BodyWorldStateCache`.
|
||||
/// The main tick thread reads this data without performing any DB or CPU work.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BodyWorldState {
|
||||
pub body_id: String,
|
||||
/// Downsampled working elevation grid (float32, row-major).
|
||||
/// Full-resolution data lives in atlas_body_heightmaps; this is reduced
|
||||
/// for the ~8KB working-resolution budget described in D-203.
|
||||
pub heightmap: Vec<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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
//! Three-component district mix algorithm for city district type distribution (D-194).
|
||||
//!
|
||||
//! Given a city's population, economic role, and political archetype, produces
|
||||
//! a district type distribution (count of each DistrictType) used by the
|
||||
//! Phase 1 district skeleton generator.
|
||||
//!
|
||||
//! **Components (D-194):**
|
||||
//! 1. Population tier guarantees — minimum district counts by city size.
|
||||
//! 2. 10×9 economic multiplier table — economic role × DistrictType weights.
|
||||
//! 3. Political archetype modifiers — shift weights for specific district types.
|
||||
//!
|
||||
//! **Determinism (D-010, D-194):** Integer weights throughout. No f32 in the
|
||||
//! district count computation. Seed-driven noise uses seeded RNG.
|
||||
|
||||
use crate::simulation::generator::{DistrictType, PoliticalArchetype};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Population tier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Population tier: `floor(log10(pop / 1_000_000))`, capped at [0, 5].
|
||||
pub fn population_tier(population: i64) -> u8 {
|
||||
if population <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let ratio = population as f64 / 1_000_000.0;
|
||||
if ratio <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
let tier = ratio.log10().floor() as i32;
|
||||
tier.clamp(0, 5) as u8
|
||||
}
|
||||
|
||||
/// Minimum district counts guaranteed by population tier (D-194).
|
||||
///
|
||||
/// Returns `(transit_min, commercial_min, residential_min)`.
|
||||
pub fn tier_guarantees(tier: u8) -> (u32, u32, u32) {
|
||||
match tier {
|
||||
0 => (0, 0, 1),
|
||||
1 => (0, 1, 1),
|
||||
2 => (1, 1, 2),
|
||||
3 => (1, 2, 3),
|
||||
4 => (2, 3, 4),
|
||||
5 => (3, 4, 6),
|
||||
_ => (3, 4, 6),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Economic multiplier table (10×9, integer weights × 10 for precision)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// District type column order (0–8).
|
||||
/// Matches DistrictType enum variants: LogisticsHub, Residential, Commercial,
|
||||
/// Industrial, Administrative, Entertainment, MixedUse, Transit, Specialized.
|
||||
const DIST_COLS: [DistrictType; 9] = [
|
||||
DistrictType::LogisticsHub,
|
||||
DistrictType::Residential,
|
||||
DistrictType::Commercial,
|
||||
DistrictType::Industrial,
|
||||
DistrictType::Administrative,
|
||||
DistrictType::Entertainment,
|
||||
DistrictType::MixedUse,
|
||||
DistrictType::Transit,
|
||||
DistrictType::Specialized,
|
||||
];
|
||||
|
||||
/// Map a DistrictType to its column index.
|
||||
fn dist_col(dt: &DistrictType) -> usize {
|
||||
match dt {
|
||||
DistrictType::LogisticsHub => 0,
|
||||
DistrictType::Residential => 1,
|
||||
DistrictType::Commercial => 2,
|
||||
DistrictType::Industrial => 3,
|
||||
DistrictType::Administrative => 4,
|
||||
DistrictType::Entertainment => 5,
|
||||
DistrictType::MixedUse => 6,
|
||||
DistrictType::Transit => 7,
|
||||
DistrictType::Specialized => 8,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an economic role to its row index (0–9).
|
||||
fn role_row(economic_role: &str) -> usize {
|
||||
match economic_role {
|
||||
"manufacturing" => 0,
|
||||
"financial" => 1,
|
||||
"agricultural" => 2,
|
||||
"extraction" => 3,
|
||||
"service_mixed" => 4,
|
||||
"institutional" => 5,
|
||||
"transit_hub" => 6,
|
||||
"research" => 7,
|
||||
"military" => 8,
|
||||
"residential" | _ => 9,
|
||||
}
|
||||
}
|
||||
|
||||
/// 10×9 economic multiplier table. Values are integer weights × 10.
|
||||
/// Rows: manufacturing(0), financial(1), agricultural(2), extraction(3),
|
||||
/// service_mixed(4), institutional(5), transit_hub(6), research(7),
|
||||
/// military(8), residential(9).
|
||||
/// Columns: LogisticsHub(0), Residential(1), Commercial(2), Industrial(3),
|
||||
/// Administrative(4), Entertainment(5), MixedUse(6), Transit(7),
|
||||
/// Specialized(8).
|
||||
#[rustfmt::skip]
|
||||
const ECON_TABLE: [[u32; 9]; 10] = [
|
||||
// LH Re Co In Ad En Mu Tr Sp
|
||||
[25, 10, 15, 30, 10, 5, 10, 20, 10], // manufacturing
|
||||
[10, 15, 30, 10, 20, 15, 20, 15, 10], // financial
|
||||
[20, 20, 10, 15, 10, 5, 20, 10, 5], // agricultural
|
||||
[30, 10, 10, 30, 10, 5, 5, 15, 10], // extraction
|
||||
[15, 20, 25, 10, 10, 20, 25, 20, 10], // service_mixed
|
||||
[10, 15, 10, 10, 30, 10, 10, 10, 20], // institutional
|
||||
[25, 10, 15, 10, 10, 10, 10, 30, 10], // transit_hub
|
||||
[10, 15, 10, 15, 20, 10, 10, 10, 30], // research
|
||||
[10, 20, 5, 15, 20, 5, 5, 10, 15], // military
|
||||
[10, 30, 15, 5, 10, 15, 25, 10, 5], // residential
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Political archetype modifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Additive integer modifiers to column weights based on `PoliticalArchetype`.
|
||||
/// Returns `[mod; 9]` for columns in `DIST_COLS` order.
|
||||
fn archetype_modifiers(archetype: &PoliticalArchetype) -> [i32; 9] {
|
||||
match archetype {
|
||||
PoliticalArchetype::Commission => {
|
||||
// Boosts Administrative + Institutional-style Specialized.
|
||||
[0, 0, 0, 0, 10, 0, 0, 0, 5]
|
||||
}
|
||||
PoliticalArchetype::Corporate => {
|
||||
// Boosts Commercial + Specialized (restricted campus zones).
|
||||
[0, -5, 15, 0, 0, 5, 0, 0, 10]
|
||||
}
|
||||
PoliticalArchetype::Pioneer => {
|
||||
// Boosts MixedUse + organic Residential.
|
||||
[0, 10, 5, 0, -5, 5, 15, 0, 0]
|
||||
}
|
||||
PoliticalArchetype::Military => {
|
||||
// Boosts Administrative + reduces Entertainment.
|
||||
[0, 5, -5, 5, 15, -10, 0, 0, 10]
|
||||
}
|
||||
PoliticalArchetype::Academic => {
|
||||
// Boosts Specialized (research labs) + Administrative.
|
||||
[0, 5, 0, 0, 10, 5, 5, 0, 20]
|
||||
}
|
||||
PoliticalArchetype::Industrial => {
|
||||
// Boosts Industrial + LogisticsHub.
|
||||
[10, -5, 5, 20, 0, -5, 0, 5, 5]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// District mix computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The district type distribution for a generated city.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DistrictMix {
|
||||
/// Ordered list of district types for the city, with repetition (district_count items total).
|
||||
pub districts: Vec<DistrictType>,
|
||||
/// Total district count.
|
||||
pub total: u32,
|
||||
}
|
||||
|
||||
/// Compute the district mix for one city (D-194).
|
||||
///
|
||||
/// `total_districts` is the number of districts to allocate. A good default is
|
||||
/// `max(4, population_tier * 2)`.
|
||||
///
|
||||
/// `seed` is the city-level RNG seed (D-010 determinism).
|
||||
pub fn compute_district_mix(
|
||||
population: i64,
|
||||
economic_role: &str,
|
||||
archetype: &PoliticalArchetype,
|
||||
total_districts: u32,
|
||||
seed: u64,
|
||||
) -> DistrictMix {
|
||||
let tier = population_tier(population);
|
||||
let (transit_min, commercial_min, residential_min) = tier_guarantees(tier);
|
||||
let row = role_row(economic_role);
|
||||
let arch_mods = archetype_modifiers(archetype);
|
||||
|
||||
// Build effective weights (integer, clamped to ≥ 1).
|
||||
let mut weights: [u32; 9] = [0; 9];
|
||||
for col in 0..9 {
|
||||
let base = ECON_TABLE[row][col] as i32;
|
||||
let modified = base + arch_mods[col];
|
||||
weights[col] = modified.max(1) as u32;
|
||||
}
|
||||
|
||||
// Allocate districts proportionally from weights using a seeded LCG.
|
||||
// We avoid f32 by using integer weighted random selection.
|
||||
let weight_sum: u32 = weights.iter().sum();
|
||||
let mut counts: [u32; 9] = [0; 9];
|
||||
let mut lcg = LcgRng::new(seed);
|
||||
|
||||
for _ in 0..total_districts {
|
||||
let mut pick = lcg.next_u32() % weight_sum;
|
||||
for col in 0..9 {
|
||||
if pick < weights[col] {
|
||||
counts[col] += 1;
|
||||
break;
|
||||
}
|
||||
pick -= weights[col];
|
||||
}
|
||||
}
|
||||
|
||||
// Apply tier guarantees (add if under minimum).
|
||||
let transit_col = dist_col(&DistrictType::Transit);
|
||||
let commercial_col = dist_col(&DistrictType::Commercial);
|
||||
let residential_col = dist_col(&DistrictType::Residential);
|
||||
|
||||
if counts[transit_col] < transit_min {
|
||||
counts[transit_col] = transit_min;
|
||||
}
|
||||
if counts[commercial_col] < commercial_min {
|
||||
counts[commercial_col] = commercial_min;
|
||||
}
|
||||
if counts[residential_col] < residential_min {
|
||||
counts[residential_col] = residential_min;
|
||||
}
|
||||
|
||||
// Build the flat ordered list.
|
||||
let mut districts: Vec<DistrictType> = Vec::new();
|
||||
for (col, &count) in counts.iter().enumerate() {
|
||||
for _ in 0..count {
|
||||
districts.push(DIST_COLS[col].clone());
|
||||
}
|
||||
}
|
||||
|
||||
let total = districts.len() as u32;
|
||||
DistrictMix { districts, total }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal seeded LCG (no f32, D-010 compliant)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct LcgRng {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl LcgRng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self { state: seed.wrapping_add(1) }
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
// LCG parameters from Knuth
|
||||
self.state = self.state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.state
|
||||
}
|
||||
|
||||
fn next_u32(&mut self) -> u32 {
|
||||
(self.next_u64() >> 33) as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn population_tier_values() {
|
||||
assert_eq!(population_tier(0), 0);
|
||||
assert_eq!(population_tier(50_000), 0); // 0.05M → log10 < 0 → tier 0
|
||||
assert_eq!(population_tier(1_000_000), 0); // 1M → log10(1) = 0 → tier 0
|
||||
assert_eq!(population_tier(10_000_000), 1); // 10M → log10(10) = 1 → tier 1
|
||||
assert_eq!(population_tier(100_000_000), 2); // 100M → tier 2
|
||||
assert_eq!(population_tier(1_000_000_000_000), 5); // capped at 5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_sums_at_least_to_requested() {
|
||||
let mix = compute_district_mix(
|
||||
5_000_000,
|
||||
"manufacturing",
|
||||
&PoliticalArchetype::Industrial,
|
||||
8,
|
||||
42,
|
||||
);
|
||||
// total may exceed requested due to guarantees
|
||||
assert!(mix.total >= 8, "district count should be >= requested");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_guarantees_applied() {
|
||||
// Tier 2 city: pop/1M = 100–999, log10(100) = 2.
|
||||
// 100M population → pop_tier = floor(log10(100)) = 2 → (1 Transit, 1 Commercial, 2 Residential).
|
||||
let mix = compute_district_mix(
|
||||
100_000_000,
|
||||
"service_mixed",
|
||||
&PoliticalArchetype::Pioneer,
|
||||
6,
|
||||
7,
|
||||
);
|
||||
let transit = mix.districts.iter().filter(|d| matches!(d, DistrictType::Transit)).count();
|
||||
let commercial = mix.districts.iter().filter(|d| matches!(d, DistrictType::Commercial)).count();
|
||||
let residential = mix.districts.iter().filter(|d| matches!(d, DistrictType::Residential)).count();
|
||||
assert!(transit >= 1, "transit guarantee not met: {transit}");
|
||||
assert!(commercial >= 1, "commercial guarantee not met: {commercial}");
|
||||
assert!(residential >= 2, "residential guarantee not met: {residential}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determinism_same_seed() {
|
||||
let mix1 = compute_district_mix(5_000_000, "financial", &PoliticalArchetype::Commission, 6, 99);
|
||||
let mix2 = compute_district_mix(5_000_000, "financial", &PoliticalArchetype::Commission, 6, 99);
|
||||
assert_eq!(mix1, mix2, "same inputs must produce identical output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_archetypes_produce_different_mixes() {
|
||||
let mix_corp = compute_district_mix(5_000_000, "financial", &PoliticalArchetype::Corporate, 8, 42);
|
||||
let mix_pioneer = compute_district_mix(5_000_000, "financial", &PoliticalArchetype::Pioneer, 8, 42);
|
||||
// Should differ in at least one district type count.
|
||||
assert_ne!(mix_corp.districts, mix_pioneer.districts,
|
||||
"Corporate and Pioneer archetypes should produce different district mixes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn military_archetype_has_administrative() {
|
||||
let mix = compute_district_mix(
|
||||
2_000_000,
|
||||
"military",
|
||||
&PoliticalArchetype::Military,
|
||||
8,
|
||||
10,
|
||||
);
|
||||
let admin = mix.districts.iter().filter(|d| matches!(d, DistrictType::Administrative)).count();
|
||||
assert!(admin >= 1, "military archetype should have Administrative districts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_district_types_can_appear() {
|
||||
// With enough districts and a balanced role, every type should appear at least once.
|
||||
let mix = compute_district_mix(
|
||||
50_000_000,
|
||||
"service_mixed",
|
||||
&PoliticalArchetype::Pioneer,
|
||||
50,
|
||||
0,
|
||||
);
|
||||
for dt in &DIST_COLS {
|
||||
let present = mix.districts.iter().any(|d| std::mem::discriminant(d) == std::mem::discriminant(dt));
|
||||
assert!(present, "DistrictType {:?} never appeared in 50-district mix", dt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (0–7), 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 { .. }));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,561 @@
|
||||
//! Phase 1 district skeleton generator (D-194, D-196, D-211, D-213, D-214).
|
||||
//!
|
||||
//! Entry point: [`generate_skeleton`]. Consumes a [`CityGenerationContext`]
|
||||
//! together with the city's raw population and economic role, and produces a
|
||||
//! fully classified [`DistrictSkeleton`] with:
|
||||
//!
|
||||
//! - [`SettingType`] derived from the surrounding biome context.
|
||||
//! - [`ComplexityTier`] derived from population tier × [`WorldTier`].
|
||||
//! - [`DistrictLayoutMode`] derived from [`PoliticalArchetype`].
|
||||
//! - 4×4 block grid with [`ZoningType`] assignments from the district-mix
|
||||
//! algorithm (D-194).
|
||||
//! - [`MultiBlockReservation`]s for parks (pop tier ≥ 2) and transit
|
||||
//! terminals (transit_hub role or pop tier ≥ 3).
|
||||
//!
|
||||
//! **Phase 1 scope only** — no chunk-level tiles, no NPC placement, no tile
|
||||
//! condition data. All stub fields (corridors, social_sites, etc.) are empty.
|
||||
//!
|
||||
//! **Determinism (D-010):** Seeded LCG via the district seed; no floating-point
|
||||
//! in block assignment.
|
||||
|
||||
use crate::atlas::block_irregularity::block_irregularity;
|
||||
use crate::atlas::district_mix::{compute_district_mix, population_tier};
|
||||
use crate::simulation::generator::{
|
||||
BlockPlacement, BlockSkeleton, ComplexityTier, DistrictId, DistrictLayoutMode,
|
||||
DistrictSkeleton, DistrictType, MultiBlockReservation, PoliticalArchetype,
|
||||
ReservationFunction, ReservationId, SettingType, WorldTier, ZoningType,
|
||||
CityGenerationContext,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a Phase 1 [`DistrictSkeleton`] from a city's generation context.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `context`: Build-time city context (archetype, world tier, orientation…).
|
||||
/// - `population`: Raw population count from atlas_city_names.
|
||||
/// - `economic_role`: Economic role string (one of the 10 canonical values).
|
||||
/// - `district_id`: Content-addressable identifier for this district.
|
||||
/// - `founding_age_years`: Years since founding — controls block irregularity.
|
||||
/// - `seed`: Deterministic seed for this district (derived from master seed via SeedChain).
|
||||
pub fn generate_skeleton(
|
||||
context: &CityGenerationContext,
|
||||
population: i64,
|
||||
economic_role: &str,
|
||||
district_id: DistrictId,
|
||||
founding_age_years: u32,
|
||||
seed: u64,
|
||||
) -> DistrictSkeleton {
|
||||
// ── 1. SettingType ────────────────────────────────────────────────────
|
||||
// Pass through the surrounding_biome from context — it already encodes
|
||||
// the planet/station/wilderness classification established at atlas time.
|
||||
let setting = derive_setting(&context.surrounding_biome, economic_role);
|
||||
|
||||
// ── 2. ComplexityTier ─────────────────────────────────────────────────
|
||||
let tier = population_tier(population);
|
||||
let complexity = derive_complexity(&context.world_tier, tier, population);
|
||||
|
||||
// ── 3. DistrictLayoutMode ─────────────────────────────────────────────
|
||||
let irregularity = block_irregularity(founding_age_years, &context.political_archetype);
|
||||
let layout_mode = derive_layout_mode(&context.political_archetype, irregularity, seed);
|
||||
|
||||
// ── 4. District mix → block grid ─────────────────────────────────────
|
||||
// A single district occupies a 4×4 block grid = 16 blocks.
|
||||
let total_blocks: u32 = 16;
|
||||
let mix = compute_district_mix(
|
||||
population,
|
||||
economic_role,
|
||||
&context.political_archetype,
|
||||
total_blocks,
|
||||
seed,
|
||||
);
|
||||
|
||||
// ── 5. Multi-block reservations ───────────────────────────────────────
|
||||
let reservations = derive_reservations(tier, economic_role, seed);
|
||||
|
||||
// Build the reservation lookup: block position → reservation id.
|
||||
let mut block_reservation: [[Option<ReservationId>; 4]; 4] =
|
||||
[[None, None, None, None]; 4];
|
||||
for (idx, res) in reservations.iter().enumerate() {
|
||||
let rid = idx as u64 + 1; // 1-based stable id within this district
|
||||
for &(row, col) in &res.blocks {
|
||||
let r = row as usize;
|
||||
let c = col as usize;
|
||||
if r < 4 && c < 4 {
|
||||
block_reservation[r][c] = Some(rid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build 4×4 block grid ──────────────────────────────────────────────
|
||||
// Flat district-mix list is already in deterministic order; assign
|
||||
// row-major (row 0 col 0 → row 0 col 3 → row 1 col 0 …).
|
||||
let primary_district_type = mix.districts.first().cloned()
|
||||
.unwrap_or(DistrictType::MixedUse);
|
||||
let blocks = build_block_grid(&mix.districts, &block_reservation, &primary_district_type);
|
||||
|
||||
// ── Compute z_levels ──────────────────────────────────────────────────
|
||||
// Phase 1: single-storey above ground for all non-reserved blocks.
|
||||
// Reserved blocks carry their own z_levels count.
|
||||
let z_levels: u8 = 1;
|
||||
|
||||
DistrictSkeleton {
|
||||
district_id,
|
||||
seed,
|
||||
district_type: district_type_from_mix(&primary_district_type),
|
||||
context: String::new(), // stub — DistrictContext = String
|
||||
world_tier: context.world_tier.clone(),
|
||||
complexity,
|
||||
setting,
|
||||
layout_mode,
|
||||
blocks,
|
||||
reservations,
|
||||
corridors: Vec::new(),
|
||||
z_levels,
|
||||
social_sites: Vec::new(),
|
||||
access_points: Vec::new(),
|
||||
society_profile: String::new(),
|
||||
zone_palette: Vec::new(),
|
||||
boundaries: String::new(),
|
||||
guarantee_audit: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SettingType derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive SettingType from the city's surrounding biome context.
|
||||
///
|
||||
/// The surrounding_biome on CityGenerationContext already encodes the
|
||||
/// planet/station classification. For city districts we map it to
|
||||
/// Urban (the default for settled cities) or pass Station/Maritime/etc.
|
||||
/// through directly.
|
||||
fn derive_setting(surrounding_biome: &SettingType, economic_role: &str) -> SettingType {
|
||||
match surrounding_biome {
|
||||
// Station bodies → always Station setting regardless of role.
|
||||
SettingType::Station => SettingType::Station,
|
||||
// Orbital platforms.
|
||||
SettingType::Orbital => SettingType::Orbital,
|
||||
// Maritime worlds — coastal city districts are Maritime.
|
||||
SettingType::Maritime => SettingType::Maritime,
|
||||
// Agricultural worlds → Agricultural districts.
|
||||
SettingType::Agricultural => SettingType::Agricultural,
|
||||
// For all other planet classes, city districts are Urban.
|
||||
// Exception: extraction role on wilderness worlds → Specialized.
|
||||
SettingType::Wilderness { biome } => {
|
||||
if economic_role == "extraction" {
|
||||
SettingType::Specialized {
|
||||
function: format!("extraction-{biome}"),
|
||||
}
|
||||
} else {
|
||||
SettingType::Urban
|
||||
}
|
||||
}
|
||||
// Transit nodes get Transitional setting.
|
||||
SettingType::Transitional => SettingType::Transitional,
|
||||
// Water bodies → Water districts don't host cities; treat as Specialized.
|
||||
SettingType::Water { .. } => SettingType::Specialized {
|
||||
function: "waterfront".into(),
|
||||
},
|
||||
// Generic Specialized pass-through.
|
||||
SettingType::Specialized { function } => SettingType::Specialized {
|
||||
function: function.clone(),
|
||||
},
|
||||
// Default for Urban and any unknown variant: Urban.
|
||||
SettingType::Urban => SettingType::Urban,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ComplexityTier derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive ComplexityTier from WorldTier + population tier (D-194, D-218).
|
||||
///
|
||||
/// | WorldTier | pop_tier ≥ 1 | pop_tier = 0 |
|
||||
/// |-----------------|---------------|----------------------|
|
||||
/// | Epicenter | Full | Moderate |
|
||||
/// | Regional | Full | Moderate |
|
||||
/// | Backwater | Moderate | Minimal |
|
||||
/// | Passage | Moderate | Minimal |
|
||||
/// | Waypoint | Minimal | Minimal (→ Empty <5K)|
|
||||
fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> ComplexityTier {
|
||||
// Ghost stub threshold: pop < 5000 on Waypoint → Empty.
|
||||
if population < 5_000 && matches!(world_tier, WorldTier::Waypoint) {
|
||||
return ComplexityTier::Empty;
|
||||
}
|
||||
|
||||
match world_tier {
|
||||
WorldTier::Epicenter | WorldTier::Regional => {
|
||||
if pop_tier >= 1 { ComplexityTier::Full } else { ComplexityTier::Moderate }
|
||||
}
|
||||
WorldTier::Backwater | WorldTier::Passage => {
|
||||
if pop_tier >= 1 { ComplexityTier::Moderate } else { ComplexityTier::Minimal }
|
||||
}
|
||||
WorldTier::Waypoint => ComplexityTier::Minimal,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DistrictLayoutMode derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive DistrictLayoutMode from PoliticalArchetype + block irregularity (D-213, D-214).
|
||||
///
|
||||
/// Commission / Military / Corporate / Academic → Grid (planned geometry).
|
||||
/// Pioneer / Industrial → Organic (organic growth with per-block offsets).
|
||||
fn derive_layout_mode(
|
||||
archetype: &PoliticalArchetype,
|
||||
irregularity: f32,
|
||||
seed: u64,
|
||||
) -> DistrictLayoutMode {
|
||||
match archetype {
|
||||
PoliticalArchetype::Commission
|
||||
| PoliticalArchetype::Military
|
||||
| PoliticalArchetype::Corporate
|
||||
| PoliticalArchetype::Academic => DistrictLayoutMode::Grid,
|
||||
|
||||
PoliticalArchetype::Pioneer | PoliticalArchetype::Industrial => {
|
||||
// Organic: generate per-block offsets and rotations seeded from district seed.
|
||||
let placements = organic_placements(irregularity, seed);
|
||||
DistrictLayoutMode::Organic { placements }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate 4×4 organic block placements seeded deterministically (D-010).
|
||||
///
|
||||
/// Uses a seeded LCG; offset range controlled by `irregularity` (0.05–1.0)
|
||||
/// scaled to the ±16 sim tile maximum from `block_irregularity::max_offset_sim_tiles`.
|
||||
fn organic_placements(irregularity: f32, seed: u64) -> [[BlockPlacement; 4]; 4] {
|
||||
let max_offset = (irregularity * 16.0) as i16;
|
||||
let mut lcg = SkeletonLcg::new(seed);
|
||||
|
||||
// Build the 2D array using a flat closure to keep things readable.
|
||||
let mut flat: [BlockPlacement; 16] = core::array::from_fn(|_| BlockPlacement {
|
||||
offset: (0, 0),
|
||||
rotation_steps: 0,
|
||||
street_width_bps: 10_000,
|
||||
});
|
||||
|
||||
for item in flat.iter_mut() {
|
||||
let raw_x = (lcg.next_u32() % (2 * max_offset as u32 + 1)) as i16 - max_offset;
|
||||
let raw_y = (lcg.next_u32() % (2 * max_offset as u32 + 1)) as i16 - max_offset;
|
||||
let rot = (lcg.next_u32() % 4) as u8; // 0–3 (15° increments, max 45°)
|
||||
// Street width 7500–20000 bps proportional to irregularity.
|
||||
let width_range = 12_500u32; // 20000 - 7500
|
||||
let width = 7_500u32 + (lcg.next_u32() % (width_range + 1));
|
||||
*item = BlockPlacement {
|
||||
offset: (raw_x, raw_y),
|
||||
rotation_steps: rot,
|
||||
street_width_bps: width as u16,
|
||||
};
|
||||
}
|
||||
|
||||
// Safety: BlockPlacement is Copy-able; reshape flat array to [[_; 4]; 4].
|
||||
core::array::from_fn(|row| {
|
||||
core::array::from_fn(|col| flat[row * 4 + col].clone())
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Block grid construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Map a DistrictType to its primary ZoningType (D-194).
|
||||
fn zoning_for_district(dt: &DistrictType) -> ZoningType {
|
||||
match dt {
|
||||
DistrictType::LogisticsHub => ZoningType::Industrial,
|
||||
DistrictType::Residential => ZoningType::Residential,
|
||||
DistrictType::Commercial => ZoningType::Commercial,
|
||||
DistrictType::Industrial => ZoningType::Industrial,
|
||||
DistrictType::Administrative => ZoningType::Administrative,
|
||||
DistrictType::Entertainment => ZoningType::Commercial,
|
||||
DistrictType::MixedUse => ZoningType::Mixed,
|
||||
DistrictType::Transit => ZoningType::Transit,
|
||||
DistrictType::Specialized => ZoningType::Restricted,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the primary district type to the DistrictType field on DistrictSkeleton.
|
||||
fn district_type_from_mix(primary: &DistrictType) -> DistrictType {
|
||||
primary.clone()
|
||||
}
|
||||
|
||||
/// Build the 4×4 BlockSkeleton grid from the district mix list and reservation map.
|
||||
///
|
||||
/// Blocks are assigned row-major (index = row * 4 + col).
|
||||
/// Reserved blocks retain their zoning from the district mix but link to the reservation.
|
||||
fn build_block_grid(
|
||||
districts: &[DistrictType],
|
||||
block_reservation: &[[Option<ReservationId>; 4]; 4],
|
||||
primary: &DistrictType,
|
||||
) -> [[BlockSkeleton; 4]; 4] {
|
||||
// Pad or truncate district list to exactly 16.
|
||||
let district_iter: Vec<&DistrictType> = (0..16)
|
||||
.map(|i| districts.get(i).unwrap_or(primary))
|
||||
.collect();
|
||||
|
||||
core::array::from_fn(|row| {
|
||||
core::array::from_fn(|col| {
|
||||
let idx = row * 4 + col;
|
||||
let dt = district_iter[idx];
|
||||
let zoning = zoning_for_district(dt);
|
||||
let reservation = block_reservation[row][col];
|
||||
|
||||
let density = density_for_zoning(&zoning);
|
||||
BlockSkeleton {
|
||||
position: (row as u8, col as u8),
|
||||
zoning,
|
||||
reservation,
|
||||
chunk_layout: String::new(), // stub
|
||||
hosted_sites: Vec::new(),
|
||||
era: String::new(), // stub
|
||||
era_modifications: Vec::new(),
|
||||
era_cause: None,
|
||||
density_pct: density,
|
||||
landmark: None,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Default build density percentage for a zoning type.
|
||||
fn density_for_zoning(zoning: &ZoningType) -> u8 {
|
||||
match zoning {
|
||||
ZoningType::Residential => 60,
|
||||
ZoningType::Commercial => 80,
|
||||
ZoningType::Industrial => 70,
|
||||
ZoningType::Administrative => 75,
|
||||
ZoningType::Transit => 50,
|
||||
ZoningType::Recreational => 30,
|
||||
ZoningType::Restricted => 85,
|
||||
ZoningType::Mixed => 65,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-block reservations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive Phase 1 multi-block reservations for a city (D-211, D-194).
|
||||
///
|
||||
/// Reservation rules:
|
||||
/// - Pop tier ≥ 2 → one 2×2 park reservation at the center-right (blocks (1,2),(1,3),(2,2),(2,3)).
|
||||
/// - Transit_hub role OR pop tier ≥ 3 → one 1×2 transit terminal at row 0 cols 0–1.
|
||||
///
|
||||
/// Phase 1 produces skeleton-only reservations — floor_zones and vertical_corridors
|
||||
/// are deferred to Phase 2.
|
||||
fn derive_reservations(pop_tier: u8, economic_role: &str, _seed: u64) -> Vec<MultiBlockReservation> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
// Park: large cities need open space.
|
||||
if pop_tier >= 2 {
|
||||
out.push(MultiBlockReservation {
|
||||
blocks: vec![(1, 2), (1, 3), (2, 2), (2, 3)],
|
||||
template_tag: "park-central".into(),
|
||||
function: ReservationFunction::Park,
|
||||
z_levels: 1,
|
||||
base_z: 0,
|
||||
floor_zones: Vec::new(),
|
||||
z_band_count: 1,
|
||||
z_band_zones: Vec::new(),
|
||||
vertical_corridors: Vec::new(),
|
||||
hosted_sites: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Transit terminal: transit-hub economies and major cities.
|
||||
if economic_role == "transit_hub" || pop_tier >= 3 {
|
||||
out.push(MultiBlockReservation {
|
||||
blocks: vec![(0, 0), (0, 1)],
|
||||
template_tag: "transit-terminal".into(),
|
||||
function: ReservationFunction::Terminal,
|
||||
z_levels: 2,
|
||||
base_z: -1, // one level of underground rail
|
||||
floor_zones: Vec::new(),
|
||||
z_band_count: 2,
|
||||
z_band_zones: Vec::new(),
|
||||
vertical_corridors: Vec::new(),
|
||||
hosted_sites: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal seeded LCG (D-010 determinism)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct SkeletonLcg {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl SkeletonLcg {
|
||||
fn new(seed: u64) -> Self {
|
||||
// Mix seed to avoid degenerate state at 0.
|
||||
Self { state: seed.wrapping_add(0x9e37_79b9_7f4a_7c15) }
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
self.state = self.state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.state
|
||||
}
|
||||
|
||||
fn next_u32(&mut self) -> u32 {
|
||||
(self.next_u64() >> 33) as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::simulation::generator::{
|
||||
CityGenerationContext, FoundingOrientation, PoliticalArchetype, SettingType, WorldTier,
|
||||
};
|
||||
|
||||
fn make_context(archetype: PoliticalArchetype, world_tier: WorldTier) -> CityGenerationContext {
|
||||
CityGenerationContext {
|
||||
city_id: 1,
|
||||
political_archetype: archetype,
|
||||
prosperity_baseline: 0.7,
|
||||
surrounding_biome: SettingType::Urban,
|
||||
road_entry_directions: vec![0, 4],
|
||||
footprint_radius_km: 10.0,
|
||||
founding_orientation: FoundingOrientation::Cardinal,
|
||||
world_tier,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_station_passthrough() {
|
||||
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
ctx.surrounding_biome = SettingType::Station;
|
||||
let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 200, 42);
|
||||
assert!(matches!(sk.setting, SettingType::Station));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_urban_for_city_on_planet() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, 42);
|
||||
assert!(matches!(sk.setting, SettingType::Urban));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complexity_epicenter_high_pop_is_full() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
// 10M pop → pop_tier = 1 → Full on Epicenter
|
||||
let sk = generate_skeleton(&ctx, 10_000_000, "financial", 1, 200, 42);
|
||||
assert_eq!(sk.complexity, ComplexityTier::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complexity_waypoint_tiny_pop_is_empty() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Waypoint);
|
||||
let sk = generate_skeleton(&ctx, 1_000, "residential", 1, 50, 42);
|
||||
assert_eq!(sk.complexity, ComplexityTier::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complexity_backwater_low_pop_is_minimal() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
|
||||
// 50_000 pop → pop_tier = 0 → Minimal on Backwater
|
||||
let sk = generate_skeleton(&ctx, 50_000, "residential", 1, 50, 42);
|
||||
assert_eq!(sk.complexity, ComplexityTier::Minimal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_commission_is_grid() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 100, 99);
|
||||
assert!(matches!(sk.layout_mode, DistrictLayoutMode::Grid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_pioneer_is_organic() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "residential", 1, 400, 99);
|
||||
assert!(matches!(sk.layout_mode, DistrictLayoutMode::Organic { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_grid_is_fully_populated() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, 42);
|
||||
// All 16 blocks must have valid positions.
|
||||
for row in 0..4 {
|
||||
for col in 0..4 {
|
||||
let b = &sk.blocks[row][col];
|
||||
assert_eq!(b.position, (row as u8, col as u8));
|
||||
assert!(b.density_pct <= 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_reservations_for_small_city() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
|
||||
// pop_tier 0, not transit_hub → no reservations.
|
||||
let sk = generate_skeleton(&ctx, 80_000, "residential", 1, 50, 42);
|
||||
assert!(sk.reservations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn park_reservation_for_large_city() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
// 100M pop → pop_tier 2 → park reservation.
|
||||
let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, 42);
|
||||
let has_park = sk.reservations.iter()
|
||||
.any(|r| matches!(r.function, ReservationFunction::Park));
|
||||
assert!(has_park);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transit_terminal_for_transit_hub_role() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
// pop_tier 0 but transit_hub → terminal reservation.
|
||||
let sk = generate_skeleton(&ctx, 80_000, "transit_hub", 1, 200, 42);
|
||||
let has_terminal = sk.reservations.iter()
|
||||
.any(|r| matches!(r.function, ReservationFunction::Terminal));
|
||||
assert!(has_terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_blocks_linked_in_grid() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
// 100M pop → park at (1,2),(1,3),(2,2),(2,3) with reservation id 1.
|
||||
let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, 42);
|
||||
// All park blocks must reference the park reservation (id=1).
|
||||
for &(row, col) in &[(1u8, 2u8), (1, 3), (2, 2), (2, 3)] {
|
||||
let b = &sk.blocks[row as usize][col as usize];
|
||||
assert!(b.reservation.is_some(), "block ({row},{col}) should be reserved");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determinism_same_seed_same_output() {
|
||||
let ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional);
|
||||
let sk1 = generate_skeleton(&ctx, 2_000_000, "manufacturing", 77, 250, 12345);
|
||||
let sk2 = generate_skeleton(&ctx, 2_000_000, "manufacturing", 77, 250, 12345);
|
||||
// Compare block grid zoning and positions.
|
||||
for row in 0..4 {
|
||||
for col in 0..4 {
|
||||
assert_eq!(sk1.blocks[row][col].zoning, sk2.blocks[row][col].zoning);
|
||||
assert_eq!(sk1.blocks[row][col].position, sk2.blocks[row][col].position);
|
||||
assert_eq!(sk1.blocks[row][col].density_pct, sk2.blocks[row][col].density_pct);
|
||||
}
|
||||
}
|
||||
assert_eq!(sk1.reservations.len(), sk2.reservations.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Tile condition thresholds and derivation (D-217).
|
||||
//!
|
||||
//! A tile's visual condition is derived from the district's `prosperity_score`
|
||||
//! (0.0–1.0) using four threshold bands. The block's `EraCause` applies a
|
||||
//! minimum condition floor that prevents high-prosperity scores from masking
|
||||
//! historical decay.
|
||||
//!
|
||||
//! **Threshold bands (D-217):**
|
||||
//! | Band | Condition | prosperity_score |
|
||||
//! |------|-----------|-----------------|
|
||||
//! | 1 | Intact | > 0.63 |
|
||||
//! | 2 | Worn | 0.43 – 0.63 |
|
||||
//! | 3 | Cracked | 0.23 – 0.43 |
|
||||
//! | 4 | Broken | < 0.23 |
|
||||
//!
|
||||
//! **Era-based floor (D-217):**
|
||||
//! - `EconomicDisruption` (Decay-era): minimum Cracked.
|
||||
//! - `EmergencyExtension`: minimum Worn.
|
||||
//! - All other eras: no floor — condition follows prosperity_score freely.
|
||||
//!
|
||||
//! **Threshold crossing invalidation:** A tile's condition only changes when
|
||||
//! `prosperity_score` crosses a band boundary. Checked once per game-minute.
|
||||
//!
|
||||
//! Threshold values are authored constants (D-217): 0.63, 0.43, 0.23.
|
||||
|
||||
use crate::simulation::generator::EraCause;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TileCondition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Visual condition band for a tile, derived from prosperity_score (D-217).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum TileCondition {
|
||||
/// prosperity_score > 0.63. Clean, undamaged, well-maintained.
|
||||
Intact,
|
||||
/// prosperity_score 0.43–0.63. Scuff marks, minor discoloration, partial repairs.
|
||||
Worn,
|
||||
/// prosperity_score 0.23–0.43. Visible damage, incomplete repair, graffiti.
|
||||
Cracked,
|
||||
/// prosperity_score < 0.23. Structural damage, debris, derelict appearance.
|
||||
Broken,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Threshold constants (D-217 authored — do not compute at runtime)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const THRESHOLD_INTACT: f32 = 0.63;
|
||||
pub const THRESHOLD_WORN: f32 = 0.43;
|
||||
pub const THRESHOLD_CRACKED: f32 = 0.23;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive `TileCondition` from `prosperity_score` alone (no era floor).
|
||||
pub fn condition_from_score(prosperity_score: f32) -> TileCondition {
|
||||
if prosperity_score > THRESHOLD_INTACT {
|
||||
TileCondition::Intact
|
||||
} else if prosperity_score > THRESHOLD_WORN {
|
||||
TileCondition::Worn
|
||||
} else if prosperity_score > THRESHOLD_CRACKED {
|
||||
TileCondition::Cracked
|
||||
} else {
|
||||
TileCondition::Broken
|
||||
}
|
||||
}
|
||||
|
||||
/// Era-based minimum condition floor (D-217).
|
||||
///
|
||||
/// Returns the minimum `TileCondition` for a block with the given `EraCause`.
|
||||
/// `None` means no floor — condition follows prosperity_score freely.
|
||||
pub fn era_condition_floor(era_cause: Option<&EraCause>) -> Option<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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user