Files
settled-reach/server/src/atlas/cascade.rs
T
2026-07-25 16:53:41 +02:00

1127 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Generation cascade harness (#952, D-200).
//!
//! [`run_cascade`] runs the deterministic generation cascade for one body, from
//! Layer 0 (load the baked `heightmap.png`, D-202) up to a requested layer, and
//! returns a [`CascadeSnapshot`]. The harness is extensible: each new layer is
//! added to [`CascadeLayer`] and populated on the snapshot as it lands (#954+).
//! The golden-seed regression test (#952) diffs a snapshot against a stored
//! fixture.
//!
//! [`run_cascade_from_heightmap`] is the pure, in-memory core (no file I/O); the
//! path-loading [`run_cascade`] is a thin wrapper around it.
//!
//! **Determinism (D-010 #4):** for a fixed heightmap + [`SeedChain`], the
//! snapshot is reproducible. Layers 03 are RNG-free — Layers 01 are pure
//! functions of the heightmap, and Layer 3 is a pure function of
//! (attractors, cities). The carried `SeedChain` is reserved for the future
//! RNG-using layers (Layer 4+, D-224).
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::atlas::attractor_matching::{
match_cities, territorial_status_from_faction, CityPlacement, CityRecord,
};
use crate::atlas::body_world_state::{BodyWorldState, RiverNetwork};
use crate::atlas::district_profile::{self, BodyParams, DistrictProfile};
use crate::atlas::features::TerrainAnalysis;
use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError};
use crate::atlas::layer1::{self, Layer1Output};
use crate::atlas::region_profile::{self, RegionProfile};
use crate::atlas::road_graph::{self, RoadGraph};
use crate::atlas::scale::{self, RegionPos, SurveyCellPos};
use crate::seed::SeedChain;
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor, TerritorialStatus};
/// Cascade layers in execution order (D-200). [`run_cascade`] runs every layer
/// up to and including the requested one. Append new layers as they are built;
/// the `Ord` derive relies on declaration order, so only ever append.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum CascadeLayer {
/// Layer 0 — load the pre-baked 16-bit `heightmap.png` (D-202).
Heightmap,
/// Layer 1 — empty-world topography: drainage, feature tags, sub-biome (#953).
Topography,
/// Layer 3 — settlement placement: attractor-matched city positions (#955, D-211).
/// Deterministic and RNG-free: a pure function of (attractors, cities) via
/// `match_cities`. The carried `SeedChain` is unused here; later stochastic
/// layers (Layer 4+) will consume it.
Settlement,
/// Layer — DistrictProfile (~1 km carriers, D-239 §1, T-1023). Pure function of
/// `(seed, body_params, terrain_analysis)`. Appended after Settlement so
/// declaration order (= Ord) is preserved — never reorder (D-010).
DistrictProfile,
/// Layer 2 — inter-settlement road/rail graph (D-211, T-1038). Pure function
/// of `(Layer-3 placements, Layer-1 terrain)`; RNG-free. Semantically "Layer
/// 2", but it depends only on Settlement + Topography, so it is **appended
/// last** to honour the append-only `Ord` rule (it neither needs nor blocks
/// the DistrictProfile layer; requesting it runs DistrictProfile first, harmlessly).
RoadGraph,
/// Region climate layer (~205 km cells, D-243 §3, T-1113). Pure function of
/// `(seed, body_params, heightmap dims)` — the region baselines the district
/// layer already derives internally (and discards) are RETAINED here as
/// their own layer output for the Atlas. Semantically the climate context
/// *above* districts, but **appended last** per the append-only `Ord` rule
/// (the RoadGraph precedent): it depends on no other layer, so requesting
/// it runs the earlier layers first, harmlessly. The cheap double-derive
/// (district blend cache + this layer) is deliberate — one layer, one
/// concern, no cache plumbing between layers.
Region,
}
/// Output of the cascade for one body, up to the requested layer (#952).
///
/// Extensible: each layer's artifact is an `Option` that becomes `Some` once
/// that layer has run. Layer 0 (`heightmap`) is always present.
#[derive(Debug, Clone)]
pub struct CascadeSnapshot {
pub body_id: String,
/// This body's root in the deterministic seed tree (D-224). Unused by the
/// RNG-free Layers 01; carried for the RNG-using layers (Layer 3+).
pub seed: SeedChain,
/// Layer 0 — the loaded heightmap.
pub heightmap: BodyHeightmap,
/// Layer 1 — topography. `Some` once [`CascadeLayer::Topography`] has run.
pub layer1: Option<Layer1Output>,
/// Layer 3 — settlement placement. `Some` once [`CascadeLayer::Settlement`] has run.
pub layer3: Option<Layer3Output>,
/// DistrictProfile layer — ~1 km carriers. `Some` once
/// [`CascadeLayer::DistrictProfile`] has run (T-1023, D-239 §1).
pub layer_district: Option<LayerDistrictOutput>,
/// Layer 2 — inter-settlement road/rail graph. `Some` once
/// [`CascadeLayer::RoadGraph`] has run (D-211, T-1038).
pub road_graph: Option<RoadGraph>,
/// Region climate layer — ~205 km climate-context cells. `Some` once
/// [`CascadeLayer::Region`] has run (D-243 §3, T-1113).
pub layer_region: Option<LayerRegionOutput>,
/// **Transient** — the `TerrainAnalysis` produced by the Layer-1 drainage
/// pass (T-1044). Populated when Layer 1 runs; consumed (and freed) once
/// both `DistrictProfile` and `RoadGraph` have consumed it.
///
/// NOT persisted on `BodyWorldState` or the LRU cache (D-203 / T-1048 size
/// concern — `TerrainAnalysis` is ~2 MB of full-grid Vecs). Callers that
/// need it after the cascade must re-derive from `run_layer1`.
pub terrain_analysis: Option<TerrainAnalysis>,
}
/// DistrictProfile layer output (T-1023, D-239 §1): per-survey-cell (D-256(b))
/// terrain profiles covering the whole body. Stored in `BodyWorldState.districts`.
#[derive(Debug, Clone, Default)]
pub struct LayerDistrictOutput {
pub districts: std::collections::BTreeMap<SurveyCellPos, DistrictProfile>,
}
/// Region climate layer output (T-1113, D-243 §3): per-region (~205 km) climate
/// context covering the body's district grid. Stored in `BodyWorldState.regions`.
///
/// The set is the **covering grid only** — the regions whose districts tile the
/// body, with no ±1 neighbour padding. (The district layer's internal region
/// cache pads a neighbour ring because its edge-fuzz blend samples across
/// boundaries; that padding is a blend implementation detail, not part of the
/// body's own region grid, and a dense Atlas wire encoding wants exact dims.)
///
/// **D-256(f):** the build still keys off the SurveyCellPos-shaped pseudo-grid
/// dims rather than the true D-243 district grid — a fenced, deliberate defer
/// to T-1181's rung-0 Global canvas, not a fix this ticket makes. Verified
/// safe: the sole reader is the `region_grid` Atlas overlay, which reads no
/// `DistrictProfile` climate to disagree with.
#[derive(Debug, Clone, Default)]
pub struct LayerRegionOutput {
pub regions: std::collections::BTreeMap<RegionPos, RegionProfile>,
}
/// Layer 3 output (#955, D-211): attractor-matched settlement placements for the
/// body. Re-derivable from (Layer-1 attractors + settlement records + seed).
#[derive(Debug, Clone, Default)]
pub struct Layer3Output {
pub placements: Vec<CityPlacement>,
}
impl CascadeSnapshot {
/// Convert into a [`BodyWorldState`] for the D-203 cache (#968). Moves the
/// heightmap raster and the Layer-1 outputs in; `last_accessed` starts at 0
/// (the cache stamps it on read). A snapshot that stopped at Layer 0 yields
/// empty river/basin/attractor data.
///
/// `terrain_analysis` (transient, ~2 MB) is **dropped here** — it is not
/// persisted on `BodyWorldState` per the D-203/T-1048 size budget.
pub fn into_body_world_state(self) -> BodyWorldState {
let (river_network, drainage_basins, attractors, feature_names) = match self.layer1 {
Some(l1) => (
l1.river_network,
l1.drainage_basins,
l1.attractors,
l1.feature_names,
),
None => (RiverNetwork::default(), Vec::new(), Vec::new(), Vec::new()),
};
let placements = self.layer3.map(|l3| l3.placements).unwrap_or_default();
let districts = self
.layer_district
.map(|lr| lr.districts)
.unwrap_or_default();
let regions = self.layer_region.map(|lr| lr.regions).unwrap_or_default();
let road_graph = self.road_graph.unwrap_or_default();
// terrain_analysis (transient) is intentionally dropped here.
let _ = self.terrain_analysis;
let sea_level = self.heightmap.sea_level;
BodyWorldState {
body_id: self.body_id,
heightmap: self.heightmap.data,
heightmap_width: self.heightmap.width,
heightmap_height: self.heightmap.height,
sea_level,
river_network,
drainage_basins,
attractors,
feature_names,
placements,
road_graph,
quarters: std::collections::BTreeMap::new(),
districts,
regions,
last_accessed: 0,
}
}
}
/// Layer 3 — settlement placement (#955, D-211). Pure: matches the body's
/// settlements to its Layer-1 attractors via the authored D-195 compatibility
/// matrix (the five-phase `match_cities` pipeline). Deterministic — a pure
/// function of (attractors, cities); no RNG.
///
/// `terrain_costs` is `None` for now (uniform 1.0); wiring sub-biome
/// `terrain_modification_cost` (D-234) is a follow-on refinement.
///
/// `territorial_status` (D-212, from the body's `dominant_faction`) and `seed`
/// drive the per-settlement spatial-character enrichment (#956, D-213/214/215).
fn run_layer3(
attractors: &[GeographicAttractor],
cities: &[CityRecord],
territorial_status: &TerritorialStatus,
seed: SeedChain,
grid_w: u32,
grid_h: u32,
) -> Layer3Output {
let matrix = CompatibilityMatrix::d195();
let placements = match_cities(
cities,
attractors,
&matrix,
None,
grid_w,
grid_h,
territorial_status,
seed,
);
Layer3Output { placements }
}
/// Run the cascade from a heightmap already in memory, up to `up_to`.
///
/// Pure (no I/O); this is the testable core. `body_seed` is this body's
/// [`SeedChain`] position — the caller derives it from the world seed via
/// `SeedChain::root(world_seed).derive(SeedDomain::Body, id)`. `cities` are the
/// body's settlements (from `atlas_city_names`, supplied by the caller — the
/// cascade stays DB-free); empty until Layer 3 (`Settlement`) is requested.
/// `dominant_faction` is the body's authored system faction (D-237); it drives
/// the `TerritorialStatus` on each province and the per-settlement spatial
/// character (#956). `None` → `FrontierUnclaimed`.
/// `body_params` supplies the physical parameters needed for the DistrictProfile
/// layer (T-1023); `None` → district layer skips (empty `districts` map).
/// `river_names`/`mountain_names` are the body's reserved-name pools (T-1169,
/// D-223, from `atlas_feature_names`), supplied by the caller — mirrors
/// `cities`' own pre-resolved, DB-free-cascade pattern. Empty slices are the
/// correct input for a body with no reserved names, or a caller (tests,
/// `aliveness_probe`) that hasn't pre-resolved them; `attach_feature_names`
/// degrades gracefully (every attractor position simply gets no name).
pub fn run_cascade_from_heightmap(
body_seed: SeedChain,
heightmap: BodyHeightmap,
cities: &[CityRecord],
dominant_faction: Option<&str>,
body_params: Option<&BodyParams>,
river_names: &[String],
mountain_names: &[String],
up_to: CascadeLayer,
) -> CascadeSnapshot {
let mut snapshot = CascadeSnapshot {
body_id: heightmap.body_id.clone(),
seed: body_seed,
heightmap,
layer1: None,
layer3: None,
layer_district: None,
road_graph: None,
layer_region: None,
terrain_analysis: None,
};
// TerritorialStatus is derived once per body from the system's dominant
// faction (D-212, #956). Uniform across the body's provinces for now.
let territorial_status = territorial_status_from_faction(dominant_faction);
// Layer 1 — topography (RNG-free; pure function of the heightmap).
// run_layer1 now returns (Layer1Output, TerrainAnalysis); the TerrainAnalysis
// is carried transiently on the snapshot so DistrictProfile + RoadGraph can
// reuse it without the former ~45 ms redundant drainage re-run (T-1044).
//
// T-1184: settled-equilibrium hydrology solves inside run_layer1 as part
// of this same pass (D-227 amendment (4), the AnalyzeBody cascade populate
// point). When real BodyParams are available, derive the body's actual
// moisture ceiling (hydrosphere/atmosphere) for the endorheic-vs-overflow
// split rather than falling back to run_layer1's body-agnostic default —
// this cascade entry point always has body_params in scope when the
// caller supplied one, so there is no reason to leave it on the fallback.
if up_to >= CascadeLayer::Topography {
let (mut l1, ta) = match body_params {
Some(params) => layer1::run_layer1_with_moisture(
&snapshot.heightmap,
district_profile::derive_moisture_ceiling_q(params),
),
None => layer1::run_layer1(&snapshot.heightmap),
};
// Stamp the province TerritorialStatus (D-212) onto each basin.
for basin in &mut l1.drainage_basins {
basin.territorial_status = territorial_status.clone();
}
// T-1169: attach reserved names (D-223) to the strongest river-mouth
// and alpine-peak attractors. Cheap (two sorts + zips over the
// already-computed attractor list, no new terrain work) and
// deterministic given the caller-supplied pools — mirrors the
// TerritorialStatus stamp above in running once, right after Layer 1
// produces the attractors this reads.
let (river_assignments, mountain_assignments) =
layer1::attach_feature_names(&l1, river_names, mountain_names);
l1.feature_names = river_assignments
.into_iter()
.map(|(position, name)| layer1::FeatureNameAssignment {
position,
name,
feature_type: layer1::FeatureNameType::River,
})
.chain(mountain_assignments.into_iter().map(|(position, name)| {
layer1::FeatureNameAssignment {
position,
name,
feature_type: layer1::FeatureNameType::Mountain,
}
}))
.collect();
snapshot.layer1 = Some(l1);
snapshot.terrain_analysis = Some(ta);
}
// Layer 3 — settlement placement (D-211). Requires Layer 1 attractors, which
// are present because Settlement > Topography in the layer order.
if up_to >= CascadeLayer::Settlement {
let attractors: &[GeographicAttractor] = match snapshot.layer1.as_ref() {
Some(l1) => &l1.attractors,
None => &[],
};
// cache seam: run_layer3 is a pure, deterministic function of
// (attractors, cities, territorial_status, seed) — wrap a persistent
// cache here when we add one (build-time bake or local cache; see #1021).
let l3 = run_layer3(
attractors,
cities,
&territorial_status,
body_seed,
snapshot.heightmap.width,
snapshot.heightmap.height,
);
snapshot.layer3 = Some(l3);
}
// DistrictProfile (T-1023, D-239 §1) and RoadGraph (Layer 2, T-1038) both need
// a TerrainAnalysis. The Layer-1 pass already produced one and stored it
// transiently on `snapshot.terrain_analysis` — reuse it here instead of
// re-running the full ~45 ms drainage pass (T-1044 eliminates the former
// PERF/TODO re-run). The analysis is valid as long as the heightmap has not
// changed, which is guaranteed by cascade invariant (pure, deterministic).
//
// The terrain_analysis is consumed after DistrictProfile + RoadGraph are
// built; it is dropped (not stored on BodyWorldState) per D-203/T-1048.
if up_to >= CascadeLayer::DistrictProfile
&& (body_params.is_some() || up_to >= CascadeLayer::RoadGraph)
{
// Borrow the transient TerrainAnalysis produced by Layer 1. If Layer 1
// did not run (e.g. up_to < Topography — impossible given the guard
// above, since DistrictProfile > Topography in CascadeLayer Ord) this
// is None and both consumers below will short-circuit gracefully.
if let Some(ta) = snapshot.terrain_analysis.as_ref() {
// DistrictProfile layer — pure derivation from body params + terrain.
if let Some(params) = body_params {
// Canonical cells-per-district for the working grid (T-1039):
// shared via scale::HEIGHTMAP_CELLS_PER_DISTRICT so plugin.rs
// converts CityPlacement pixel coords with the same constant.
// body_id is required for the D-243 §4 climate edge-fuzz warp
// domain separation — derive_all_districts pre-builds the
// region-baseline cache internally on TRUE region keys (each
// survey cell's centre world metres → containing district →
// region ±1 ring; D-256(c) + PR #199 review).
//
// Layer1Output.survey_basin_dirs threads the true D8 thalweg
// direction into each DistrictProfile.basin_direction (T-1047)
// — the VALUES are true D8 aggregates, the KEYS are survey
// cells (D-256(b); `derive_all_districts` looks it up by
// identity, matching this map's own key space). Pass the map
// through derive_all_districts.
let basin_dirs = snapshot.layer1.as_ref().map(|l1| &l1.survey_basin_dirs);
// T-1168 Ruling 4c: same `snapshot.layer1` source as
// `basin_dirs`/`river_cells` (the `road_graph` precedent
// below) — the river network for the batch-path riparian
// signal.
let river_network = snapshot.layer1.as_ref().map(|l1| &l1.river_network);
let districts = district_profile::derive_all_districts(
body_seed,
params,
ta,
scale::HEIGHTMAP_CELLS_PER_DISTRICT,
&snapshot.body_id,
basin_dirs,
river_network,
);
snapshot.layer_district = Some(LayerDistrictOutput { districts });
}
// Layer 2 — inter-settlement road/rail graph (D-211, T-1038). Pure
// function of (Layer-3 placements, Layer-1 terrain). The named-route
// pool is empty for now (atlas_roads/atlas_railroads carry no rows
// post-D-223), so the named-route identity join is a designed-for
// no-op.
if up_to >= CascadeLayer::RoadGraph {
let placements = snapshot
.layer3
.as_ref()
.map(|l3| l3.placements.as_slice())
.unwrap_or(&[]);
let river_cells = snapshot
.layer1
.as_ref()
.map(|l1| l1.river_network.river_cells.as_slice())
.unwrap_or(&[]);
let graph = road_graph::build_road_graph(
placements,
ta,
river_cells,
snapshot.heightmap.width,
snapshot.heightmap.height,
&territorial_status,
&[],
);
// RailHeadFacing pass (T-1076 §4, D-213 amended): settlements
// that are high-connectivity junctions (degree ≥ 3) get their
// founding_orientation overridden toward the dominant incident
// edge. Mutates the Layer-3 placements post-hoc — orientation
// is a Layer-3 output, but rail-head facing is only knowable
// once Layer 2 exists. Deterministic: a pure function of the
// (already deterministic) graph.
if let Some(l3) = snapshot.layer3.as_mut() {
road_graph::assign_railhead_orientations(&mut l3.placements, &graph);
}
snapshot.road_graph = Some(graph);
}
}
// Drop the transient TerrainAnalysis — both consumers are done.
// Not stored on BodyWorldState (D-203/T-1048 size budget: ~2 MB per body).
snapshot.terrain_analysis = None;
}
// Region climate layer (D-243 §3, T-1113) — the ~205 km climate-context
// cells the district blend already derives internally, retained as their
// own layer output. Pure function of (seed, body_params, heightmap dims):
// no TerrainAnalysis needed, so it runs outside the transient-borrow block
// above. Gates on body_params like the DistrictProfile layer (no params →
// no climate inputs → the layer skips, `regions` stays empty).
//
// D-256(f) FENCED, NOT FIXED BY THIS TICKET: this block still treats the
// survey-raster dims (`district_cols`/`district_rows` below, really
// SurveyCellPos counts) as if they were true district counts and maps
// them straight through `scale::district_to_region` — the SAME pseudo-grid
// collapse D-256(c) fixed for `DistrictProfile`'s own region baseline.
// Deliberately deferred to T-1181's rung-0 Global canvas (D-256 ruling's
// tripwire, verified: the sole production reader of `LayerRegionOutput`/
// `regions` is the `region_grid` body-view overlay — no consumer reads it
// against `DistrictProfile` climate, so this collapse never disagrees
// with anything this ticket's scope touches). The overlay stays visibly
// stale until T-1181 replaces it — accepted, noted, not silently ignored.
if up_to >= CascadeLayer::Region {
if let Some(params) = body_params {
// The covering region grid: the same district dims the district
// layer computes (heightmap dims ÷ cells-per-district), mapped up
// to region cells — WITHOUT the ±1 neighbour padding the district
// blend cache adds (see LayerRegionOutput's doc).
let gcpr = scale::HEIGHTMAP_CELLS_PER_DISTRICT.max(1);
let district_cols = (snapshot.heightmap.width as usize).div_ceil(gcpr) as i32;
let district_rows = (snapshot.heightmap.height as usize).div_ceil(gcpr) as i32;
let max_region = scale::district_to_region((
district_cols.saturating_sub(1),
district_rows.saturating_sub(1),
));
let mut region_positions: Vec<RegionPos> = Vec::new();
for ry in 0..=max_region.1 {
for rx in 0..=max_region.0 {
region_positions.push((rx, ry));
}
}
let climate = district_profile::ClimateConstants::default();
let regions = region_profile::derive_regions_for_body(
body_seed,
params,
&climate,
region_positions,
);
snapshot.layer_region = Some(LayerRegionOutput { regions });
}
}
snapshot
}
/// Run the cascade for one body, loading its baked `heightmap.png` from `path`.
///
/// `default_sea_level` is the fallback used when the PNG lacks a `sea_level`
/// tEXt chunk. Delegates to [`run_cascade_from_heightmap`] for the layer work.
pub fn run_cascade(
body_seed: SeedChain,
body_id: &str,
heightmap_path: &Path,
default_sea_level: f32,
cities: &[CityRecord],
dominant_faction: Option<&str>,
body_params: Option<&BodyParams>,
river_names: &[String],
mountain_names: &[String],
up_to: CascadeLayer,
) -> Result<CascadeSnapshot, HeightmapLoadError> {
// Layer 0 — the cascade's input; always loaded.
let heightmap = heightmap::load_heightmap_png(heightmap_path, body_id, default_sea_level)?;
Ok(run_cascade_from_heightmap(
body_seed,
heightmap,
cities,
dominant_faction,
body_params,
river_names,
mountain_names,
up_to,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::seed::SeedDomain;
/// A small synthetic heightmap with a diagonal slope so drainage and feature
/// extraction have real structure to work on.
fn test_heightmap() -> BodyHeightmap {
let (width, height) = (64u32, 32u32);
let n = (width * height) as usize;
let data = (0..n)
.map(|i| {
let r = (i / width as usize) as f32 / height as f32;
let c = (i % width as usize) as f32 / width as f32;
(r * 0.6 + c * 0.4).min(1.0)
})
.collect();
BodyHeightmap {
body_id: "test_body".into(),
width,
height,
data,
sea_level: 0.3,
}
}
fn body_seed() -> SeedChain {
SeedChain::root(42).derive(SeedDomain::Body, 1)
}
#[test]
fn heightmap_layer_skips_layer1() {
let snap = run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Heightmap,
);
assert_eq!(snap.body_id, "test_body");
assert!(
snap.layer1.is_none(),
"Layer 1 must not run when up_to = Heightmap"
);
}
#[test]
fn topography_layer_runs_layer1() {
let snap = run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Topography,
);
let l1 = snap.layer1.expect("Layer 1 should have run");
assert_eq!(l1.body_id, "test_body");
}
/// T-1169: `run_cascade_from_heightmap` attaches reserved names to the
/// strongest river-mouth/alpine attractors when the caller supplies name
/// pools — proves the cascade wiring (`attach_feature_names` call site
/// inside the Topography block), not just the function in isolation
/// (`layer1::tests` already covers `attach_feature_names` itself).
#[test]
fn topography_layer_attaches_feature_names_when_pools_supplied() {
let river_names = vec!["Kaltfluss".to_string(), "Silberbach".to_string()];
let mountain_names = vec!["Wiesenbach".to_string()];
let snap = run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None, // body_params
&river_names,
&mountain_names,
CascadeLayer::Topography,
);
let l1 = snap.layer1.expect("Layer 1 should have run");
// The test heightmap's `0.6r + 0.4c` ramp crosses sea_level=0.3,
// producing real river-mouth/coastal attractors — assert against
// WHATEVER attach_feature_names actually paired, not a hardcoded
// count (the exact attractor set is an implementation detail of
// feature extraction, not this test's concern).
let river_attractor_count = l1
.attractors
.iter()
.filter(|a| a.attractor_type == crate::simulation::generator::AttractorType::RiverMouth)
.count();
let expected_river_assignments = river_attractor_count.min(river_names.len());
let actual_river_assignments = l1
.feature_names
.iter()
.filter(|f| f.feature_type == crate::atlas::layer1::FeatureNameType::River)
.count();
assert_eq!(
actual_river_assignments, expected_river_assignments,
"every river mouth (up to pool size) must get a name"
);
if expected_river_assignments > 0 {
let names: std::collections::BTreeSet<&str> = l1
.feature_names
.iter()
.filter(|f| f.feature_type == crate::atlas::layer1::FeatureNameType::River)
.map(|f| f.name.as_str())
.collect();
assert!(
names.iter().all(|n| river_names.contains(&n.to_string())),
"assigned names must come from the supplied pool"
);
}
// No pools supplied -> no assignments (the pre-wiring default).
let snap_no_pools = run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None,
&[],
&[],
CascadeLayer::Topography,
);
assert!(
snap_no_pools
.layer1
.expect("Layer 1 should have run")
.feature_names
.is_empty(),
"empty pools must yield zero assignments"
);
}
#[test]
fn cascade_is_deterministic() {
let extract = |s: CascadeSnapshot| {
let l1 = s.layer1.expect("layer1");
l1.attractors
.iter()
.map(|a| (a.position, a.attractor_type, a.sub_biome))
.collect::<Vec<_>>()
};
let a = extract(run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Topography,
));
let b = extract(run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Topography,
));
assert_eq!(
a, b,
"same heightmap must yield identical Layer-1 attractors"
);
}
#[test]
fn layers_are_ordered() {
// The `up_to >= CascadeLayer::Settlement` guards in the cascade rely on
// this declaration order — pin it explicitly so reordering the enum (or
// inserting a layer out of sequence) fails here instead of silently
// breaking which layers run.
assert!(CascadeLayer::Heightmap < CascadeLayer::Topography);
assert!(CascadeLayer::Topography < CascadeLayer::Settlement);
assert!(CascadeLayer::Settlement < CascadeLayer::DistrictProfile);
assert!(CascadeLayer::DistrictProfile < CascadeLayer::RoadGraph);
assert!(CascadeLayer::RoadGraph < CascadeLayer::Region);
}
#[test]
fn run_cascade_missing_file_is_err() {
// The file-loading path returns an error (not a panic) for a bad path.
let res = run_cascade(
body_seed(),
"missing",
std::path::Path::new("/nonexistent/sr-test/heightmap.png"),
0.3,
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Heightmap,
);
assert!(res.is_err(), "missing heightmap must Err, not panic");
}
/// DistrictProfile layer runs, produces districts, and is deterministic (T-1023).
#[test]
fn district_profile_layer_runs_and_is_deterministic() {
use crate::atlas::district_profile::BodyParams;
let params = BodyParams {
hydrosphere: Some("ocean".into()),
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
..Default::default()
};
let run = || {
run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
Some(&params),
&[],
&[],
CascadeLayer::DistrictProfile,
)
};
let snap1 = run();
let snap2 = run();
let lr1 = snap1.layer_district.expect("layer_district should be Some");
let lr2 = snap2.layer_district.expect("layer_district should be Some");
assert!(!lr1.districts.is_empty(), "districts map must not be empty");
assert_eq!(
lr1.districts.len(),
lr2.districts.len(),
"district count deterministic"
);
// BTreeMap iteration order is deterministic — compare all entries.
for (pos, p1) in &lr1.districts {
let p2 = lr2.districts.get(pos).expect("matching pos in second run");
assert_eq!(p1.river_threshold, p2.river_threshold);
assert_eq!(p1.tectonic_class, p2.tectonic_class);
assert_eq!(p1.glaciation_grade, p2.glaciation_grade);
// basin_direction threads run_layer1 -> survey_basin_dirs -> here;
// guard the full chain's determinism (T-1047).
assert_eq!(p1.basin_direction, p2.basin_direction);
}
}
/// Layer 3 — settlement placement runs, places the body's settlements onto
/// attractors, and is deterministic (#955, D-211).
#[test]
fn settlement_layer_places_cities_deterministically() {
use crate::atlas::attractor_matching::CityRecord;
use crate::simulation::generator::SettlementClass;
let cities = vec![
CityRecord {
city_id: 1,
name: "Capital".into(),
settlement_class: SettlementClass::NameLocked,
population: 2_000_000,
economic_role: "financial".into(),
is_capital: true,
is_standalone_hq: false,
},
CityRecord {
city_id: 2,
name: "Farm Town".into(),
settlement_class: SettlementClass::OrganicGrowth,
population: 120_000,
economic_role: "agricultural".into(),
is_capital: false,
is_standalone_hq: false,
},
];
let run = || {
run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&cities,
Some("concord_assembly"),
None, // body_params
&[],
&[],
CascadeLayer::Settlement,
)
};
let snap = run();
let placement_count = {
let l3 = snap.layer3.as_ref().expect("Layer 3 should have run");
assert!(
!l3.placements.is_empty(),
"settlements must be placed when Layer 1 produced attractors"
);
l3.placements.len()
};
// Determinism: same inputs → identical placements (positions + city_ids).
let key = |s: &CascadeSnapshot| {
s.layer3
.as_ref()
.unwrap()
.placements
.iter()
.map(|p| (p.city_id, p.position, p.attractor_type, p.synthetic))
.collect::<Vec<_>>()
};
assert_eq!(key(&snap), key(&run()), "placement must be deterministic");
// #956 enrichment propagates: a concord_assembly body is
// CommissionControlled, so every placement derives the Commission
// archetype + RadialCore arrangement (D-212/214/215).
{
use crate::simulation::generator::{
ArrangementPattern, PoliticalArchetype, TerritorialStatus,
};
let l3 = snap.layer3.as_ref().unwrap();
for p in &l3.placements {
assert_eq!(p.political_archetype, PoliticalArchetype::Commission);
assert_eq!(p.arrangement_pattern, ArrangementPattern::RadialCore);
}
// TerritorialStatus is stamped on every province (D-212).
let l1 = snap.layer1.as_ref().unwrap();
assert!(
l1.drainage_basins
.iter()
.all(|b| b.territorial_status == TerritorialStatus::CommissionControlled),
"every basin inherits the body's TerritorialStatus"
);
}
// The placements propagate into the hot-cache BodyWorldState.
assert_eq!(
snap.into_body_world_state().placements.len(),
placement_count
);
}
/// Layer 2 — road graph runs through the full cascade, connects the placed
/// cities, is deterministic, and propagates into BodyWorldState (T-1038).
#[test]
fn road_graph_layer_connects_cities_deterministically() {
use crate::atlas::attractor_matching::CityRecord;
use crate::atlas::road_graph::RoadNodeKind;
use crate::simulation::generator::SettlementClass;
// Several inland cities (the slope heightmap is land away from the low
// corner) so the MST has real edges to route.
let cities: Vec<CityRecord> = [
(1u64, "A", 800_000i64),
(2, "B", 400_000),
(3, "C", 200_000),
(4, "D", 150_000),
]
.iter()
.map(|(id, name, pop)| CityRecord {
city_id: *id,
name: (*name).into(),
settlement_class: SettlementClass::PopulationBudget,
population: *pop,
economic_role: "manufacturing".into(),
is_capital: false,
is_standalone_hq: false,
})
.collect();
let run = || {
run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&cities,
Some("independent"),
None, // body_params — road graph needs none
&[],
&[],
CascadeLayer::RoadGraph,
)
};
let snap = run();
let graph = snap.road_graph.as_ref().expect("RoadGraph layer ran");
let settlements = graph
.nodes
.iter()
.filter(|n| n.kind == RoadNodeKind::Settlement)
.count();
assert_eq!(settlements, cities.len(), "one road node per placed city");
assert!(
!graph.edges.is_empty(),
"placed cities on shared land must be connected"
);
// Every edge endpoint is a settlement node and the path snaps to it.
for e in &graph.edges {
assert!(e.from < e.to);
assert_eq!(graph.nodes[e.from].position, *e.path.first().unwrap());
assert_eq!(graph.nodes[e.to].position, *e.path.last().unwrap());
}
// Determinism: identical inputs → identical graph.
let key = |s: &CascadeSnapshot| {
let g = s.road_graph.as_ref().unwrap();
(
g.nodes
.iter()
.map(|n| (n.city_id, n.position, n.degree))
.collect::<Vec<_>>(),
g.edges
.iter()
.map(|e| (e.from, e.to, e.length_cells, e.maintenance))
.collect::<Vec<_>>(),
)
};
assert_eq!(key(&snap), key(&run()), "road graph must be deterministic");
// Propagates into the hot-cache BodyWorldState.
let edge_count = graph.edges.len();
assert_eq!(
snap.into_body_world_state().road_graph.edges.len(),
edge_count
);
}
/// PR #178 H3 — the RailHeadFacing wiring end-to-end (T-1076 §4): after the
/// RoadGraph layer runs, the cascade must have mutated
/// `layer3.placements[..].founding_orientation` to `RailHeadFacing` for
/// exactly the settlements that are high-connectivity junctions
/// (degree ≥ 3), and for no others. A regression that drops the
/// `assign_railhead_orientations` call (or runs it before the graph
/// exists) fails here mechanically.
#[test]
fn cascade_assigns_railhead_orientation_at_junctions() {
use crate::atlas::attractor_matching::CityRecord;
use crate::atlas::road_graph::{RoadNodeKind, JUNCTION_DEGREE};
use crate::simulation::generator::{FoundingOrientation, SettlementClass};
// A plus-shaped landmass (arms meeting at the centre, ocean elsewhere):
// settlements string along the arms, so the trunk MST must branch where
// the arms meet — diagnosed to yield exactly 2 degree-3 settlement
// junctions with 8 cities. Deterministic: the junction requirement
// below is a stable fixture property, not flakiness. (The default
// slope fixture never branches — its coastal attractors form a chain,
// and snapped minors raise Junction-node degrees, not settlement
// degrees.)
let (width, height) = (64u32, 32u32);
let mut data = vec![0.05f32; (width * height) as usize]; // ocean
for r in 0..height {
for c in 0..width {
let in_v_arm = (24..40).contains(&c); // vertical arm
let in_h_arm = (12..20).contains(&r); // horizontal arm
if in_v_arm || in_h_arm {
data[(r * width + c) as usize] = 0.6;
}
}
}
let cross_hm = crate::atlas::heightmap::BodyHeightmap {
body_id: "test_body".into(),
width,
height,
data,
sea_level: 0.3,
};
let cities: Vec<CityRecord> = (1..=8u64)
.map(|id| CityRecord {
city_id: id,
name: format!("City{id}"),
settlement_class: SettlementClass::PopulationBudget,
population: 1_000_000 - (id as i64) * 1_000, // hubs = lowest 6 ids
economic_role: "manufacturing".into(),
is_capital: false,
is_standalone_hq: false,
})
.collect();
let snap = run_cascade_from_heightmap(
body_seed(),
cross_hm,
&cities,
Some("independent"),
None,
&[],
&[],
CascadeLayer::RoadGraph,
);
let graph = snap.road_graph.as_ref().expect("RoadGraph layer ran");
let junction_city_ids: Vec<u64> = graph
.high_connectivity_junctions()
.iter()
.filter_map(|&i| graph.nodes[i].city_id)
.collect();
assert!(
!junction_city_ids.is_empty(),
"fixture must produce at least one degree ≥ {JUNCTION_DEGREE} settlement \
junction — if this fires the fixture changed, not the wiring"
);
// The wiring assertion, both directions: junction settlements carry
// RailHeadFacing; every other settlement does not.
let l3 = snap.layer3.as_ref().expect("Layer 3 ran");
for p in &l3.placements {
let is_junction = junction_city_ids.contains(&p.city_id);
let is_rail = matches!(
p.founding_orientation,
FoundingOrientation::RailHeadFacing { .. }
);
assert_eq!(
is_junction, is_rail,
"city {} junction={} but rail_facing={} — cascade wiring broken",
p.city_id, is_junction, is_rail
);
}
// And the mutated placements are what BodyWorldState carries forward.
let junction_count = junction_city_ids.len();
let state = snap.into_body_world_state();
let rail_count = state
.placements
.iter()
.filter(|p| {
matches!(
p.founding_orientation,
FoundingOrientation::RailHeadFacing { .. }
)
})
.count();
assert_eq!(rail_count, junction_count);
// Silence unused-import warning when the filter above changes.
let _ = RoadNodeKind::Settlement;
}
/// Region climate layer (T-1113, D-243 §3): runs as the cascade terminal,
/// populates `BodyWorldState.regions` with the covering region grid, gates
/// on body_params like the DistrictProfile layer, and is deterministic.
#[test]
fn region_layer_populates_regions_deterministically() {
use crate::atlas::district_profile::BodyParams;
let params = BodyParams {
hydrosphere: Some("ocean".into()),
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
..Default::default()
};
let run = || {
run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
Some(&params),
&[],
&[],
CascadeLayer::Region,
)
};
let snap = run();
let lr = snap.layer_region.as_ref().expect("Region layer ran");
// 64×32 working grid → 8×4 districts → a single covering region at
// (0,0) (100 districts per region side — the working grid is far
// inside one region cell today; the D-243 elastic seam grows this).
assert_eq!(lr.regions.len(), 1, "one covering region on the test grid");
let profile = lr.regions.get(&(0, 0)).expect("region (0,0) present");
assert!(
profile.clock.mean_temp_c.is_some(),
"breathable temperate body derives a temperature baseline"
);
assert!((0..=100).contains(&profile.moisture_q));
// Determinism: identical inputs → bit-identical region output.
let key = |s: &CascadeSnapshot| {
s.layer_region
.as_ref()
.unwrap()
.regions
.iter()
.map(|(pos, p)| {
(
*pos,
p.clock.season as u8,
p.clock.weather as u8,
p.clock.mean_temp_c.map(f32::to_bits),
p.moisture_q,
)
})
.collect::<Vec<_>>()
};
assert_eq!(
key(&snap),
key(&run()),
"region layer must be deterministic"
);
// The regions propagate into the hot-cache BodyWorldState.
let state = snap.into_body_world_state();
assert_eq!(state.regions.len(), 1);
assert!(state.regions.contains_key(&(0, 0)));
// No body params → the layer skips and regions stays empty (mirrors
// the DistrictProfile gate).
let no_params = run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None,
&[],
&[],
CascadeLayer::Region,
);
assert!(no_params.layer_region.is_none());
assert!(no_params.into_body_world_state().regions.is_empty());
}
}