From 9bc2ed28f221a52d6e9329e0c7024274784d7aad Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 23 May 2026 12:09:49 +0200 Subject: [PATCH] feat(simulation): extensible generation cascade harness (#952, D-200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server/src/atlas/cascade.rs — run_cascade(body_seed, body_id, path, default_sea_level, up_to) loads Layer 0 (heightmap.png) and runs every layer up to the requested CascadeLayer, returning a CascadeSnapshot. The pure core run_cascade_from_heightmap orchestrates the layers without file I/O (testable); run_cascade is the thin path-loading wrapper. CascadeSnapshot is extensible — each layer's artifact is an Option that becomes Some once it runs (Layer 0 heightmap always present, Layer 1 topography next; #954+ append their fields). The carried SeedChain is unused by the RNG-free Layers 0-1 and feeds the RNG-using layers later (D-224). CascadeLayer is an append-only ordered enum. Tests: layer-gating, same-heightmap determinism, layer ordering. The golden-seed regression fixture (the #952 deliverable) builds on this next. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/atlas/cascade.rs | 170 ++++++++++++++++++++++++++++++++++++ server/src/atlas/mod.rs | 1 + 2 files changed, 171 insertions(+) create mode 100644 server/src/atlas/cascade.rs diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs new file mode 100644 index 000000000..df9758144 --- /dev/null +++ b/server/src/atlas/cascade.rs @@ -0,0 +1,170 @@ +//! 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 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)] +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, +} + +/// 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); + } +} diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 16c4894b9..d73ca3a7e 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -6,6 +6,7 @@ pub mod attractor_matching; pub mod block_irregularity; pub mod body_world_state; +pub mod cascade; pub mod district_mix; pub mod drainage; pub mod features;