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:
@@ -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<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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user