//! 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–1 are RNG-free (pure functions of the //! heightmap); the carried `SeedChain` feeds the RNG-using layers (Layer 3+, //! D-224). use std::path::Path; use serde::{Deserialize, Serialize}; use crate::atlas::body_world_state::{BodyWorldState, RiverNetwork}; use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError}; use crate::atlas::layer1::{self, Layer1Output}; use crate::seed::SeedChain; /// 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, } /// 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, } 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()), }; 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, districts: std::collections::BTreeMap::new(), last_accessed: 0, } } } /// 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)`. pub fn run_cascade_from_heightmap( body_seed: SeedChain, heightmap: BodyHeightmap, up_to: CascadeLayer, ) -> CascadeSnapshot { let mut snapshot = CascadeSnapshot { body_id: heightmap.body_id.clone(), seed: body_seed, heightmap, layer1: None, }; // Layer 1 — topography (RNG-free; pure function of the heightmap). if up_to >= CascadeLayer::Topography { snapshot.layer1 = Some(layer1::run_layer1(&snapshot.heightmap)); } 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, 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, 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(), 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(), 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::>() }; let a = extract(run_cascade_from_heightmap( body_seed(), test_heightmap(), CascadeLayer::Topography, )); let b = extract(run_cascade_from_heightmap( body_seed(), test_heightmap(), CascadeLayer::Topography, )); assert_eq!( a, b, "same heightmap must yield identical Layer-1 attractors" ); } #[test] fn layers_are_ordered() { assert!(CascadeLayer::Heightmap < CascadeLayer::Topography); } #[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, CascadeLayer::Heightmap, ); assert!(res.is_err(), "missing heightmap must Err, not panic"); } }