Addresses PR #170 review (Hoshe H1/H2). H1: the Some(basin_dirs) path through derive_all_districts was untested (tests only used None). Added derive_all_districts_threads_supplied_basin_directions (synthetic per-district directions propagate to DistrictProfile.basin_direction; unmapped districts fall back to North), and added basin_direction to the cascade determinism loop so the full run_layer1->district chain is guarded. H2: clarified the derive_district on-demand comment — the North default is an accepted limitation (a fallback, not a computed value); the batch path threads the true D8 direction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
677 lines
27 KiB
Rust
677 lines
27 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, DistrictPos, DistrictProfile};
|
||
use crate::atlas::features::TerrainAnalysis;
|
||
use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError};
|
||
use crate::atlas::layer1::{self, Layer1Output};
|
||
use crate::atlas::road_graph::{self, RoadGraph};
|
||
use crate::atlas::scale;
|
||
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,
|
||
}
|
||
|
||
/// 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>,
|
||
/// **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-district (~1 km) terrain
|
||
/// profiles covering the whole body. Stored in `BodyWorldState.districts`.
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct LayerDistrictOutput {
|
||
pub districts: std::collections::BTreeMap<DistrictPos, DistrictProfile>,
|
||
}
|
||
|
||
/// 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) = match self.layer1 {
|
||
Some(l1) => (l1.river_network, l1.drainage_basins, l1.attractors),
|
||
None => (RiverNetwork::default(), 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 road_graph = self.road_graph.unwrap_or_default();
|
||
// terrain_analysis (transient) is intentionally dropped here.
|
||
let _ = self.terrain_analysis;
|
||
BodyWorldState {
|
||
body_id: self.body_id,
|
||
heightmap: self.heightmap.data,
|
||
heightmap_width: self.heightmap.width,
|
||
heightmap_height: self.heightmap.height,
|
||
river_network,
|
||
drainage_basins,
|
||
attractors,
|
||
placements,
|
||
road_graph,
|
||
quarters: std::collections::BTreeMap::new(),
|
||
districts,
|
||
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).
|
||
pub fn run_cascade_from_heightmap(
|
||
body_seed: SeedChain,
|
||
heightmap: BodyHeightmap,
|
||
cities: &[CityRecord],
|
||
dominant_faction: Option<&str>,
|
||
body_params: Option<&BodyParams>,
|
||
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,
|
||
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).
|
||
if up_to >= CascadeLayer::Topography {
|
||
let (mut l1, ta) = 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();
|
||
}
|
||
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 builds the region
|
||
// cache internally.
|
||
//
|
||
// district_basin_dirs from Layer1Output threads the true D8
|
||
// thalweg direction into each DistrictProfile.basin_direction
|
||
// (T-1047). Pass the map through derive_all_districts.
|
||
let basin_dirs = snapshot.layer1.as_ref().map(|l1| &l1.district_basin_dirs);
|
||
let districts = district_profile::derive_all_districts(
|
||
body_seed,
|
||
params,
|
||
ta,
|
||
scale::HEIGHTMAP_CELLS_PER_DISTRICT,
|
||
&snapshot.body_id,
|
||
basin_dirs,
|
||
);
|
||
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,
|
||
&[],
|
||
);
|
||
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;
|
||
}
|
||
|
||
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>,
|
||
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,
|
||
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");
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
#[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 -> district_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(),
|
||
},
|
||
CityRecord {
|
||
city_id: 2,
|
||
name: "Farm Town".into(),
|
||
settlement_class: SettlementClass::OrganicGrowth,
|
||
population: 120_000,
|
||
economic_role: "agricultural".into(),
|
||
},
|
||
];
|
||
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(),
|
||
})
|
||
.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
|
||
);
|
||
}
|
||
}
|