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>
211 lines
7.4 KiB
Rust
211 lines
7.4 KiB
Rust
//! 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<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");
|
||
}
|
||
}
|