feat(simulation): extensible generation cascade harness (#952, D-200)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 12:09:49 +02:00
co-authored by Claude Opus 4.7
parent 41343987f0
commit 9bc2ed28f2
2 changed files with 171 additions and 0 deletions
+170
View File
@@ -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 01 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 01; 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>,
}
/// 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<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, 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::<Vec<_>>()
};
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);
}
}
+1
View File
@@ -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;