//! 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 0–3 are RNG-free — Layers 0–1 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 0–1; 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, /// Layer 3 — settlement placement. `Some` once [`CascadeLayer::Settlement`] has run. pub layer3: Option, /// DistrictProfile layer — ~1 km carriers. `Some` once /// [`CascadeLayer::DistrictProfile`] has run (T-1023, D-239 §1). pub layer_district: Option, /// Layer 2 — inter-settlement road/rail graph. `Some` once /// [`CascadeLayer::RoadGraph`] has run (D-211, T-1038). pub road_graph: Option, /// Region climate layer — ~205 km climate-context cells. `Some` once /// [`CascadeLayer::Region`] has run (D-243 §3, T-1113). pub layer_region: Option, /// **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, } /// 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, } /// 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, } /// 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, } 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 = 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 { // 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::>() }; 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(¶ms), &[], &[], 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::>() }; 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 = [ (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::>(), g.edges .iter() .map(|e| (e.from, e.to, e.length_cells, e.maintenance)) .collect::>(), ) }; 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 = (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 = 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(¶ms), &[], &[], 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::>() }; 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()); } }