Files
settled-reach/server/src/atlas/cascade.rs
T
jpmschweitzerandClaude Opus 4.8 46b8688fa3 feat(simulation): foundation shared types + struct plumbing (#1006)
Define the type-definition layer the Phase-4 fill-seam tickets depend on
(D-229/D-230/D-231/D-232/D-233), compiling with stubs/defaults; behavior
logic lands in #982-985/#998.

- New types in generator.rs: BuildingPropertyTag, FloorExtent +
  FloorHeightProfile (floor_at_voxel_z/voxel_range_for_floor, resolves
  Q-104), BuildingEntryClass, ConstructionEra, ZoneTypeId, MorphologyZone,
  BulkClass(5), ProductionUbiquity, DoorSpec, InteriorDescriptor,
  DistrictWorldState.
- Rename spatial AccessTier -> ZoneAccessTier to free the name for the new
  per-building BuildingEntryClass.
- CityGenerationContext: +morphology_zone, +trait_selection,
  +dominant_bulk_class, +dominant_production_ubiquity.
- BodyWorldState: +districts (DistrictWorldState w/ block_tags).
- GenCompletion::SkeletonGenerated carries body_id + DistrictWorldState;
  plugin handler inserts into BodyWorldState.districts.
- Add smallvec as a direct dep (DoorSpec list stays Vec for now, TODO).

cargo check --all-targets / clippy clean; 1259 lib tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:07:33 +02:00

211 lines
7.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 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 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>,
}
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<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);
}
#[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");
}
}