feat(simulation): WaterBody voxel family — oceans/lakes/tidal flats render wet (T-1082)

The water zones (OpenOcean/Lake/TidalFlat) fell through to the dry-land AlluvialPlain
fallback, so the sea rendered as dry forested land (the D-245 believability bug).

- voxel.rs: new MorphologyFamily::WaterBody (9th family) + generate_water_body,
  dispatched from zone_to_family. Deep open water with a Shallow shoal band on the
  district-anchored coast line (wider for shallower bodies); TidalFlat all-Shallow.
  Seabed Rock (steep) / Sand (gentle) / Wetland (tidal mud); Vegetation::Barren;
  water surface elevation_m = 0; seasonal Ice via derive_cover (frozen seas). Wetland
  stays AlluvialPlain (it is land — a marsh, not open water). Integer/D-010. + 4 tests.
- believability.rs: fix a sampling bias in the T-1083 enforcer found while verifying
  this — the metrics sampled the first 64 districts in BTreeMap order (a spatial corner,
  e.g. all-ocean), which reported a forested world as "0 vegetated". Now a deterministic
  seeded spread across the body (matches D-245's "randomly-sampled locations").
- D-239 amended (§5 8→9 families; §6 refined — the 3 water zones now dispatch to
  WaterBody). Believability golden regenerated.

Probe (Arbour @ yolo): water-renders-wet 4/64 FAIL → 42/44 PASS; terrain-material
variety 1 → 4 PASS; 3/7 → 5/7 criteria. (Remaining fails — moisture gradient,
vegetation-present — are T-1080.)

clippy --all-targets -D warnings clean; 1579 lib tests + the harness pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 15:30:25 +02:00
co-authored by Claude Opus 4.8
parent ed2c9853be
commit cdc55032df
6 changed files with 266 additions and 30 deletions
+39 -7
View File
@@ -33,8 +33,9 @@
//!
//! [`analyze`] is a pure, deterministic function of `(world_seed, body_id, districts)`:
//! scalar/categorical contrast is computed over **all** districts; the voxel-derived
//! metrics sample the first [`VOXEL_SAMPLE_DISTRICTS`] in `BTreeMap` order (sorted →
//! stable). Suitable for golden-snapshot regression.
//! metrics sample a **seeded spread** of [`VOXEL_SAMPLE_DISTRICTS`] across the body
//! (deterministic from the world seed — D-245's "randomly-sampled locations", and
//! unbiased unlike a contiguous corner). Suitable for golden-snapshot regression.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
@@ -60,8 +61,9 @@ const DEFAULT_SEA_LEVEL: f32 = 0.3;
/// `ocean_fraction_q` at or above this marks a district water-present (D-239 §10 uses
/// `ocean_fraction_q` as the chunk-scale water proxy).
const WATER_PRESENCE_Q: i32 = 10;
/// Districts voxel-sampled for the derived metrics — the first N in `BTreeMap` order
/// (deterministic). Scalar/categorical contrast uses *all* districts (cheap).
/// Districts voxel-sampled for the derived metrics — a deterministic seeded spread
/// across the body (D-245 randomly-sampled-anywhere). Scalar/categorical contrast
/// uses *all* districts (cheap).
const VOXEL_SAMPLE_DISTRICTS: usize = 64;
/// Stride over a district's representative 64 m chunk (8 → an 8×8 = 64-voxel sample).
const VOXEL_SAMPLE_STRIDE: usize = 8;
@@ -171,14 +173,21 @@ pub fn analyze(
terrain_materials: 0, // filled from the voxel sample below
};
// ── Voxel-derived metrics over a deterministic subset of districts ────────
// ── Voxel-derived metrics over a deterministic seeded spread of districts ──
// Sampling must span the whole body, not a contiguous prefix: BTreeMap order is
// a spatial corner (e.g. an all-ocean edge), which would report a forested world
// as "0 vegetated". A seeded spread is both unbiased and matches D-245's
// "randomly-sampled locations across the body" wording.
let mut terrain_set: BTreeSet<String> = BTreeSet::new();
let mut coh = CoherenceMetrics::default();
let mut sampled = 0usize;
for (dp, prof) in districts.iter().take(VOXEL_SAMPLE_DISTRICTS) {
let keys: Vec<DistrictPos> = districts.keys().copied().collect();
for idx in sample_indices(world_seed, keys.len(), VOXEL_SAMPLE_DISTRICTS) {
let dp = keys[idx];
let prof = &districts[&dp];
sampled += 1;
let chunk = district_centre_chunk(*dp);
let chunk = district_centre_chunk(dp);
let ctx = derive_chunk_context(world_seed, body_id, prof, chunk, None);
let mut any_wet = false;
@@ -339,6 +348,29 @@ fn district_centre_chunk(dp: DistrictPos) -> ChunkPos {
)
}
/// Deterministic seeded spread of up to `take` distinct indices in `0..n` — a
/// reproducible "random" sample across the whole district list (D-245's
/// randomly-sampled-anywhere requirement), unbiased unlike a contiguous prefix.
/// Returns all of `0..n` when `n <= take`. splitmix64 mixing keyed on the world seed.
fn sample_indices(world_seed: u64, n: usize, take: usize) -> Vec<usize> {
if n <= take {
return (0..n).collect();
}
let mut picked: BTreeSet<usize> = BTreeSet::new();
let mut i: u64 = 0;
while picked.len() < take {
let mut z = world_seed
.wrapping_add(i)
.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
picked.insert((z % n as u64) as usize);
i += 1;
}
picked.into_iter().collect()
}
// ---------------------------------------------------------------------------
// Loader — resolve committed data + run the real cascade (shared by the probe
// binary and the regression harness, so both measure the same thing)
+186 -13
View File
@@ -40,12 +40,13 @@
//!
//! ## Family dispatch (D-239 §5, T-1028/T-1029)
//!
//! The 8-family tree is dispatched through `MorphologyFamily`. All 8 family
//! generators are implemented: `AlluvialPlain` (the D-239 §5 fallback) landed
//! in T-1028; the other 7 (LavaField, FjordWall, CliffCoast, BraidedDelta,
//! DuneStrand, IncisedGorge, MeanderReach) landed in T-1029. Flat/water zones
//! without a dedicated family (OpenOcean, Lake, TidalFlat, Wetland) map to
//! `AlluvialPlain` per D-239 §5.
//! The family tree is dispatched through `MorphologyFamily`. All generators are
//! implemented: `AlluvialPlain` (the D-239 §5 fallback) landed in T-1028; the seven
//! landform families (LavaField, FjordWall, CliffCoast, BraidedDelta, DuneStrand,
//! IncisedGorge, MeanderReach) in T-1029; and `WaterBody` (OpenOcean / Lake / TidalFlat
//! — Deep/Shallow water + seabed) in T-1082, the 9th family that amends D-239 §5/§6's
//! original "8 families". Remaining flat *land* zones (AlluvialPlain, Wetland — a marsh,
//! not open water) still map to `AlluvialPlain` per D-239 §5.
//!
//! ## D-010 compliance
//!
@@ -269,6 +270,10 @@ enum MorphologyFamily {
MeanderReach,
/// Alluvial plain — the D-239 §5 fallback family (T-1028).
AlluvialPlain,
/// Water body — ocean / lake / tidal flat (T-1082). Renders Deep/Shallow water +
/// seabed substrate where the zones used to fall through to dry AlluvialPlain land.
/// The 9th family (amends D-239 §5/§6's "8 families").
WaterBody,
}
/// Map a `MorphologyZone` to the 8-family `MorphologyFamily` dispatch key.
@@ -293,13 +298,14 @@ fn zone_to_family(zone: &MorphologyZone) -> MorphologyFamily {
MorphologyFamily::IncisedGorge
}
MorphologyZone::MeanderReach | MorphologyZone::RiverBank => MorphologyFamily::MeanderReach,
// AlluvialPlain fallback covers the flat/water zones without a dedicated
// family: AlluvialPlain, OpenOcean, Lake, TidalFlat, Wetland (D-239 §5).
MorphologyZone::AlluvialPlain
| MorphologyZone::OpenOcean
| MorphologyZone::Lake
| MorphologyZone::TidalFlat
| MorphologyZone::Wetland => MorphologyFamily::AlluvialPlain,
// Water bodies get the dedicated WaterBody family (T-1082): they render
// Deep/Shallow water + seabed, not the dry AlluvialPlain land they used to.
MorphologyZone::OpenOcean | MorphologyZone::Lake | MorphologyZone::TidalFlat => {
MorphologyFamily::WaterBody
}
// AlluvialPlain fallback covers the remaining flat *land* zones: AlluvialPlain
// and Wetland (a marsh — saturated land, not open water; D-239 §8 Wetland law).
MorphologyZone::AlluvialPlain | MorphologyZone::Wetland => MorphologyFamily::AlluvialPlain,
}
}
@@ -416,6 +422,9 @@ pub fn derive_voxel_column(
MorphologyFamily::MeanderReach => {
generate_meander_reach(district_eff, chunk, voxel_pos, sub_chunk_seed)
}
MorphologyFamily::WaterBody => {
generate_water_body(district_eff, chunk, voxel_pos, sub_chunk_seed)
}
};
// ── 4. Seasonal cover overlay (D-239 §3, T-1030) ──────────────────────
@@ -528,6 +537,78 @@ fn generate_alluvial_plain(
}
}
// ---------------------------------------------------------------------------
// WaterBody generator (T-1082, D-239 §5 amended — the 9th family)
// ---------------------------------------------------------------------------
/// WaterBody voxel generator — ocean / lake / tidal flat (T-1082).
///
/// Renders the predominantly-submerged zones the AlluvialPlain fallback used to paint
/// as dry forested land (the D-245 / T-1082 bug: `water=Dry` + `Forest` on the sea):
/// - `Water::Deep` open water with a `Water::Shallow` shoal band straddling the
/// district-anchored coast line (`chunk.coast_anchor_m`, T-1041) — one continuous
/// shallows per district, wider for shallower (higher-`elev_q`) bodies. TidalFlat
/// is all `Shallow` (an intertidal flat).
/// - Seabed `TerrainMaterial`: `Wetland` (tidal mud), else `Rock` on steep districts
/// / `Sand` on gentle ones (D-239 §8 — rock faces where steep, sandy floor where not).
/// - `Vegetation::Barren` — nothing grows in open water.
/// - Water surface at `elevation_m = 0` (the sea-level convention the coastal families
/// already use, D-239 §8 "mouths at sea level"; the raw heightmap `sea_level` float
/// does not reach this tier). Depth is carried by the `Water` axis, not `elevation_m`.
///
/// Seasonal `cover` (Ice on cold bodies — frozen seas/lakes) is applied centrally by
/// `derive_cover` in [`derive_voxel_column`]. Integer arithmetic only (D-010); the only
/// f64 is the upstream domain warp.
fn generate_water_body(
district: &DistrictProfile,
chunk: &ChunkContext,
voxel_pos: VoxelPos,
sub_chunk_seed: u64,
) -> VoxelColumn {
let is_tidal = matches!(district.morphology_zone, MorphologyZone::TidalFlat);
// Seabed substrate: tidal mud, else rocky on steep districts / sandy on gentle.
let terrain = if is_tidal {
TerrainMaterial::Wetland
} else if district.slope_q >= 18 {
TerrainMaterial::Rock
} else {
TerrainMaterial::Sand
};
// Depth: TidalFlat is all Shallow; ocean/lake get a Shallow shoal band on the
// district-anchored coast line (along-axis ∥ basin, T-1041), Deep beyond.
let water = if is_tidal {
Water::Shallow
} else {
let along = match chunk.basin_direction {
crate::atlas::chunk_context::BasinDirection::North
| crate::atlas::chunk_context::BasinDirection::South => voxel_pos.1,
crate::atlas::chunk_context::BasinDirection::East
| crate::atlas::chunk_context::BasinDirection::West => voxel_pos.0,
};
let coast_d = (along - chunk.coast_anchor_m).abs();
// Shoal half-width: wider for shallower (higher elev_q) bodies, with a little
// sub-chunk jaggedness so the shallows edge is not a straight line.
let band_noise = (sub_chunk_seed & 0x7) as i32 - 3; // [3, +4]
let shallow_band = (8 + district.elev_q / 4 + band_noise).max(2);
if coast_d <= shallow_band {
Water::Shallow
} else {
Water::Deep
}
};
VoxelColumn {
terrain,
floor: FloorMaterial::None,
vegetation: Vegetation::Barren,
water,
elevation_m: 0, // water surface = sea level
cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column
}
}
// ---------------------------------------------------------------------------
// LavaField generator (T-1029, D-239 §5 / §8 Lava law)
// ---------------------------------------------------------------------------
@@ -2062,6 +2143,98 @@ mod tests {
derive_chunk_context(42, "GJ1c", district, (10, 20), None)
}
fn water_district(zone: MorphologyZone, slope_q: i32) -> DistrictProfile {
DistrictProfile {
morphology_zone: zone,
tectonic_class: TectonicClass::Stable,
glaciation_grade: GlaciationGrade::None,
precipitation_class: PrecipitationClass::Temperate,
slope_q,
elev_q: 5,
ocean_fraction_q: 90,
river_threshold: 200,
temperature_c: Some(10.0),
moisture_q: 80,
vegetation_class: VegetationClass::Forest,
basin_direction: BasinDirection::South,
}
}
// ── WaterBody family (T-1082) ────────────────────────────────────────────
#[test]
fn water_body_open_ocean_is_wet_and_barren() {
// T-1082: OpenOcean used to render as dry forested AlluvialPlain land. It must
// now render wet (Deep/Shallow), Barren, on a sandy/rocky seabed at sea level.
let district = water_district(MorphologyZone::OpenOcean, 3);
let chunk = derive_chunk_context(42, "ocean", &district, (5, 5), None);
let mut saw_deep = false;
for sx in (0..64).step_by(4) {
for sy in (0..64).step_by(4) {
let col =
derive_voxel_column(42, "ocean", &district, &chunk, 5 * 64 + sx, 5 * 64 + sy);
assert_ne!(col.water, Water::Dry, "open ocean must not render dry");
assert_eq!(
col.vegetation,
Vegetation::Barren,
"no vegetation in open water"
);
assert!(matches!(
col.terrain,
TerrainMaterial::Sand | TerrainMaterial::Rock
));
assert_eq!(col.elevation_m, 0, "water surface at sea level");
if col.water == Water::Deep {
saw_deep = true;
}
}
}
assert!(
saw_deep,
"open ocean should have deep water away from the shoal"
);
}
#[test]
fn water_body_tidal_flat_is_shallow_wetland() {
let district = water_district(MorphologyZone::TidalFlat, 2);
let chunk = derive_chunk_context(7, "tf", &district, (3, 3), None);
for sx in (0..64).step_by(8) {
for sy in (0..64).step_by(8) {
let col = derive_voxel_column(7, "tf", &district, &chunk, 3 * 64 + sx, 3 * 64 + sy);
assert_eq!(
col.water,
Water::Shallow,
"tidal flat is intertidal (Shallow)"
);
assert_eq!(col.terrain, TerrainMaterial::Wetland, "tidal flat = mud");
assert_eq!(col.vegetation, Vegetation::Barren);
}
}
}
#[test]
fn water_body_steep_district_has_rock_seabed() {
let district = water_district(MorphologyZone::OpenOcean, 40);
let chunk = derive_chunk_context(1, "rock", &district, (2, 2), None);
let col = derive_voxel_column(1, "rock", &district, &chunk, 2 * 64 + 10, 2 * 64 + 10);
assert_eq!(
col.terrain,
TerrainMaterial::Rock,
"steep water district → rocky seabed"
);
assert_ne!(col.water, Water::Dry);
}
#[test]
fn water_body_is_deterministic() {
let district = water_district(MorphologyZone::Lake, 10);
let chunk = derive_chunk_context(99, "lake", &district, (4, 4), None);
let a = derive_voxel_column(99, "lake", &district, &chunk, 4 * 64 + 20, 4 * 64 + 30);
let b = derive_voxel_column(99, "lake", &district, &chunk, 4 * 64 + 20, 4 * 64 + 30);
assert_eq!(a, b);
}
// -----------------------------------------------------------------------
// TerrainMaterial discriminant pins
// -----------------------------------------------------------------------