1127 lines
47 KiB
Rust
1127 lines
47 KiB
Rust
//! 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<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(¶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::<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(¶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::<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());
|
||
}
|
||
}
|