- Hoshe: body_without_system_row test inserted a NULL system_id, impossible under production schema (system_id NOT NULL REFERENCES star_systems). Rewrite as body_with_orphan_system_id_* — non-NULL system_id with no matching star_systems row (the real case the LEFT JOIN guards) + NOT NULL in the test schema + corrected comment. - Tyre: cascade.rs PERF/TODO comment said the region path 'defers to T-1032'; T-1032 IS this PR, so production dispatch is now live. Update to track T-1028 only and note the cost is live in production. No production logic change. cargo test body_params_reader 7 pass, clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
515 lines
20 KiB
Rust
515 lines
20 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::features::TerrainAnalysis;
|
||
use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError};
|
||
use crate::atlas::layer1::{self, Layer1Output};
|
||
use crate::atlas::region_profile::{self, BodyParams, RegionPos, RegionProfile};
|
||
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 — RegionProfile (~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).
|
||
RegionProfile,
|
||
}
|
||
|
||
/// 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>,
|
||
/// RegionProfile layer — ~1 km carriers. `Some` once
|
||
/// [`CascadeLayer::RegionProfile`] has run (T-1023, D-239 §1).
|
||
pub layer_region: Option<LayerRegionOutput>,
|
||
}
|
||
|
||
/// RegionProfile layer output (T-1023, D-239 §1): per-region (~1 km) terrain
|
||
/// profiles covering the whole body. Stored in `BodyWorldState.regions`.
|
||
#[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.
|
||
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 regions = self.layer_region.map(|lr| lr.regions).unwrap_or_default();
|
||
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,
|
||
quarters: std::collections::BTreeMap::new(),
|
||
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 RegionProfile
|
||
/// layer (T-1023); `None` → region layer skips (empty `regions` 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_region: 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).
|
||
if up_to >= CascadeLayer::Topography {
|
||
let mut l1 = 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);
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// RegionProfile layer (T-1023, D-239 §1) — pure derivation from body params +
|
||
// terrain analysis. Needs a TerrainAnalysis, which needs a drainage pass.
|
||
// Layer 1 already ran drainage inside run_layer1, but neither the drainage
|
||
// result nor the TerrainAnalysis is stored on Layer1Output, so we re-run both
|
||
// here. Pure → determinism preserved, but the drainage re-run is NOT free at
|
||
// the ~6 000-regions/body working scale (D-203).
|
||
// PERF/TODO(T-1028): cache TerrainAnalysis on Layer1Output to drop this
|
||
// redundant drainage pass, and validate the combined cost against the D-239 §10
|
||
// ~45 ms/body budget in the T-1031 verification harness. This is now a LIVE
|
||
// production cost: T-1032 wired the real body_params read, so every analyzed
|
||
// body runs this path. If body_params is None, the region layer is skipped
|
||
// (e.g. unit tests without DB).
|
||
if up_to >= CascadeLayer::RegionProfile {
|
||
if let Some(params) = body_params {
|
||
use crate::atlas::drainage;
|
||
let dr = drainage::analyze(
|
||
&snapshot.heightmap.data,
|
||
snapshot.heightmap.width,
|
||
snapshot.heightmap.height,
|
||
snapshot.heightmap.sea_level,
|
||
);
|
||
let ta = TerrainAnalysis::analyze(&snapshot.heightmap, &dr);
|
||
// ~8 cells per region on a 128×64 working grid → ~80×32 = ~2 560 regions;
|
||
// at full working resolution the budget is ~6 000/body (D-203).
|
||
const CELLS_PER_REGION: usize = 8;
|
||
let regions =
|
||
region_profile::derive_all_regions(body_seed, params, &ta, CELLS_PER_REGION);
|
||
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>,
|
||
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::RegionProfile);
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
/// RegionProfile layer runs, produces regions, and is deterministic (T-1023).
|
||
#[test]
|
||
fn region_profile_layer_runs_and_is_deterministic() {
|
||
use crate::atlas::region_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::RegionProfile,
|
||
)
|
||
};
|
||
let snap1 = run();
|
||
let snap2 = run();
|
||
let lr1 = snap1.layer_region.expect("layer_region should be Some");
|
||
let lr2 = snap2.layer_region.expect("layer_region should be Some");
|
||
assert!(!lr1.regions.is_empty(), "regions map must not be empty");
|
||
assert_eq!(
|
||
lr1.regions.len(),
|
||
lr2.regions.len(),
|
||
"region count deterministic"
|
||
);
|
||
// BTreeMap iteration order is deterministic — compare all entries.
|
||
for (pos, p1) in &lr1.regions {
|
||
let p2 = lr2.regions.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);
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
);
|
||
}
|
||
}
|