The push gate's integration tests (not in cargo test --lib) caught two regressions from the relief change: - derivation_harness::cross_district_elevation_blend_reduces_seam_step — the relief's large per-position term (span 300 → ±200 m, far above the compressed elev_q/2 base) swamped the T-1042 seam measurement and clamped heavily at sea level. Fixes: (1) shift the voxel-relief octave band to 0.13–1 km (all sub-district, dropping the 2 km octave that competed with elev_q's district role); (2) reduce VOXEL_RELIEF_SPAN_M 300 → 100 so relief stays mostly below the base (fewer sea-level clamp artifacts, proportional hills); (3) rewrite the seam test's avg_elev to average over an 8 km multi-wavelength y-transect so the zero-mean relief cancels, isolating the base seam. - derivation_harness::golden_seed_determinism_regression — legitimate golden refresh (determinism still holds; elevation values changed intentionally). Believability relief now ≈22 m mean (was 5 m flat; 59 m at the over-aggressive span 300) — navigable hills without clamp artifacts, 7/8 criteria. Both goldens regenerated; full cargo test (38 binaries) + clippy --all-targets -D warnings green. D-239 amendment + Q-123 item 4 updated to the final span/octave/numbers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4072 lines
179 KiB
Rust
4072 lines
179 KiB
Rust
//! VoxelColumn — 1 m derivation tier of the D-239 refinement chain (T-1028).
|
||
//!
|
||
//! A `VoxelColumn` is the finest derivation tier: derived on-demand from a
|
||
//! `ChunkContext` (64 m) and the covering `DistrictProfile` (~1 km). Never stored,
|
||
//! never persisted — cached in `VoxelCache` (D-227).
|
||
//!
|
||
//! ## D-228 composite tile axes
|
||
//!
|
||
//! Each `VoxelColumn` is a bundle of **orthogonal axes** (D-228):
|
||
//! - `terrain`: `TerrainMaterial` — permanent natural ground (Soil/Sand/Gravel/Rock/Wetland/Lava)
|
||
//! - `floor`: `FloorMaterial` — built surface (None for now; Phase 5)
|
||
//! - `vegetation`: `Vegetation` — ground cover (from `VegetationClass`)
|
||
//! - `water`: `Water` — local depth state (Dry/Shallow/Deep)
|
||
//! - `elevation_m`: scalar metres
|
||
//! - `cover`: `SeasonalCover` — seasonal cover overlay (Snow/Ice, D-228/D-239 §3)
|
||
//!
|
||
//! **Snow/Ice are NOT `TerrainMaterial`** — they are a seasonal cover overlay
|
||
//! per D-228. `derive_cover` (T-1030) computes the static mean-state cover from
|
||
//! the district's integer temperature + water/terrain type + spatially-coherent
|
||
//! cluster scatter (D-239 §3). The transient/clock-bound part (cover forms in the
|
||
//! cold phase, melts in the warm phase) is deferred to Q-105.
|
||
//!
|
||
//! ## Domain warp (D-239 §4)
|
||
//!
|
||
//! The warp is applied **before** the integer voxel address is computed. The
|
||
//! caller passes the logical tile position `(tile_x, tile_y)` in metres; the
|
||
//! derivation function calls `domain_warp(seed, body_id, (tile_x, tile_y))` to
|
||
//! get `(dx, dy)` in f64, then truncates to the integer voxel address with
|
||
//! `as i32` (cast, not comparison — IEEE-754 deterministic, D-239 §4).
|
||
//!
|
||
//! The warp is **the only f64** in the structural derivation path; all
|
||
//! material/morphology decisions downstream operate on the integer voxel address.
|
||
//!
|
||
//! ## Cache (D-227)
|
||
//!
|
||
//! `VoxelCache` is a bounded LRU-style cache backed by a `BTreeMap` and a
|
||
//! monotonic generation counter. Same (seed, body, pos) → same column;
|
||
//! eviction is by generation-counter LRU (oldest entry evicted when capacity
|
||
//! is reached). Never written to disk.
|
||
//!
|
||
//! ## Family dispatch (D-239 §5, T-1028/T-1029)
|
||
//!
|
||
//! 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
|
||
//!
|
||
//! All material/morphology decisions are integer. The only f64 path is the
|
||
//! domain warp displacement (positional math, truncated before any decision).
|
||
|
||
use std::collections::BTreeMap;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::atlas::chunk_context::ChunkContext;
|
||
use crate::atlas::district_profile::{DistrictProfile, VegetationClass};
|
||
use crate::atlas::domain_warp::domain_warp;
|
||
use crate::seed::{SeedChain, SeedDomain};
|
||
use crate::simulation::generator::MorphologyZone;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// D-228 tile axes
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Permanent natural ground material (D-228).
|
||
///
|
||
/// **Snow and Ice are NOT here** — they are a seasonal cover overlay (Q-105).
|
||
/// Integer-discriminant, append-only (D-010).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||
#[repr(u8)]
|
||
pub enum TerrainMaterial {
|
||
/// Loamy soil — rolling/floodplain (D-239 §8 Soil law).
|
||
#[default]
|
||
Soil = 0,
|
||
/// Sand — ≤~32° angle of repose, dunes not cliffs (D-239 §8 Sand law).
|
||
Sand = 1,
|
||
/// Gravel — braided channels/fans, not single-thread meander (D-239 §8).
|
||
Gravel = 2,
|
||
/// Rock — vertical faces on steep steps (D-239 §8 Rock law).
|
||
Rock = 3,
|
||
/// Wetland substrate — saturated organic soil, ≤5° flats (D-239 §8).
|
||
Wetland = 4,
|
||
/// Lava — sheets/shield slopes + tubes, immature drainage (D-239 §8).
|
||
Lava = 5,
|
||
}
|
||
|
||
/// Built surface over natural ground (D-228).
|
||
///
|
||
/// `None` is the only implemented value for Phase 4 — built surfaces are
|
||
/// Phase 5 (player control). The vocabulary is declared here so the type system
|
||
/// enforces the distinction from `TerrainMaterial`.
|
||
///
|
||
/// Integer-discriminant, append-only (D-010).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||
#[repr(u8)]
|
||
pub enum FloorMaterial {
|
||
/// No built surface — natural ground exposed.
|
||
#[default]
|
||
None = 0,
|
||
// Phase 5 variants: Concrete = 1, Pavement = 2, Carpet = 3, Metal = 4, …
|
||
}
|
||
|
||
/// Ground cover (D-228). Carries cover/concealment, movement sound profile,
|
||
/// and economic yield (timber/crops).
|
||
///
|
||
/// Derived from the district's `VegetationClass` (D-239 §8 climate→vegetation law).
|
||
/// Integer-discriminant, append-only (D-010).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||
#[repr(u8)]
|
||
pub enum Vegetation {
|
||
/// No vegetation — above treeline or hyper-arid.
|
||
#[default]
|
||
Barren = 0,
|
||
/// Grass — open ground cover, low concealment.
|
||
Grass = 1,
|
||
/// Scrub — transitional shrubs, moderate concealment.
|
||
Scrub = 2,
|
||
/// Thicket — dense low cover, high concealment.
|
||
Thicket = 3,
|
||
/// Forest — closed canopy, full concealment.
|
||
Forest = 4,
|
||
/// Crop — managed agriculture (Phase 6 economic layer).
|
||
Crop = 5,
|
||
/// Cleared — formerly vegetated, stripped (player/sim action).
|
||
Cleared = 6,
|
||
}
|
||
|
||
impl Vegetation {
|
||
/// Map a district-level `VegetationClass` to the per-voxel `Vegetation` axis.
|
||
///
|
||
/// The district class establishes the dominant cover; per-voxel scatter
|
||
/// (Grass vs Scrub at the margin) is handled by the voxel generator using
|
||
/// the sub-chunk seed. This function gives the canonical deterministic
|
||
/// baseline before scatter is applied.
|
||
pub fn from_vegetation_class(vc: VegetationClass) -> Self {
|
||
match vc {
|
||
VegetationClass::Absent => Vegetation::Barren,
|
||
VegetationClass::Barren => Vegetation::Barren,
|
||
VegetationClass::Scrub => Vegetation::Scrub,
|
||
VegetationClass::Forest => Vegetation::Forest,
|
||
VegetationClass::RiparianScrub => Vegetation::Thicket,
|
||
VegetationClass::RiparianThicket => Vegetation::Thicket,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Local depth state (D-228).
|
||
///
|
||
/// `Water` is dynamic — changes with season/tide (Q-105) — but for Phase 4
|
||
/// derivation we use the static base state (the seasonal overlay is deferred).
|
||
///
|
||
/// Integer-discriminant, append-only (D-010).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||
#[repr(u8)]
|
||
pub enum Water {
|
||
/// No standing water.
|
||
#[default]
|
||
Dry = 0,
|
||
/// Shallow water — passable with effort; grants concealment penalty.
|
||
Shallow = 1,
|
||
/// Deep water — impassable without vessel; full concealment penalty.
|
||
Deep = 2,
|
||
}
|
||
|
||
/// Seasonal cover overlay (D-228 / D-239 §3, T-1030).
|
||
///
|
||
/// Snow and Ice are **not** `TerrainMaterial` — they are a derived overlay that
|
||
/// sits on top of the permanent ground material. This axis captures the
|
||
/// **static mean-state** cover: whether the district's mean-annual temperature
|
||
/// puts this voxel firmly in the frozen zone. The **transient, clock-bound** part
|
||
/// (cover forms in the cold diurnal/seasonal phase, melts in the warm phase →
|
||
/// time-of-day passability shifts) is out of scope here.
|
||
///
|
||
/// ## Q-105 forward contract
|
||
///
|
||
/// D-239 §3 states: "Transient: because temperature is clock-bound (season +
|
||
/// day/night), in the marginal band ice/snow forms in the cold phase and melts in
|
||
/// the warm phase — dawn frost burns off, a stream iced at dawn is crossable by
|
||
/// noon. Passability is therefore time-of-day dynamic." This dynamic behaviour
|
||
/// requires the cheap district seasonal/clock state described in Q-105 — the
|
||
/// `cover` field here captures only the mean-state freeze. When Q-105 is
|
||
/// implemented, consumers must additionally consult the regional clock-phase
|
||
/// before treating `cover` as a passability gate. The static `SeasonalCover`
|
||
/// output of `derive_cover` remains valid as the base state; Q-105 applies a
|
||
/// time-dependent modifier on top of it.
|
||
///
|
||
/// Integer-discriminant, append-only (D-010).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||
#[repr(u8)]
|
||
pub enum SeasonalCover {
|
||
/// No seasonal cover — ground is bare.
|
||
#[default]
|
||
None = 0,
|
||
/// Snow — cold + wet land surface; reduces mobility, provides concealment.
|
||
Snow = 1,
|
||
/// Ice — frozen water surface or permanent ice; alters passability.
|
||
Ice = 2,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// VoxelColumn
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// The finest derivation tier — the D-228 composite tile axes for one 1 m column.
|
||
///
|
||
/// Derived on demand from `(seed, body_id, district, chunk_context, voxel_pos)`.
|
||
/// Never stored, never persisted. Cached in `VoxelCache` (D-227).
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct VoxelColumn {
|
||
/// Permanent natural ground material (D-228).
|
||
pub terrain: TerrainMaterial,
|
||
/// Built surface over the ground (D-228). `None` in Phase 4.
|
||
pub floor: FloorMaterial,
|
||
/// Ground cover (D-228). Derived from district `VegetationClass`.
|
||
pub vegetation: Vegetation,
|
||
/// Local depth state (D-228).
|
||
pub water: Water,
|
||
/// Surface elevation in integer metres (D-010; no f64 in the stored value).
|
||
pub elevation_m: i32,
|
||
/// Seasonal cover overlay (D-228 / D-239 §3, T-1030).
|
||
///
|
||
/// Static mean-state: whether mean-annual temperature puts this voxel in
|
||
/// the frozen zone, gated by surface type + spatially-coherent cluster
|
||
/// scatter. The transient clock-bound layer (Q-105) is not represented here.
|
||
pub cover: SeasonalCover,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Voxel position (integer metres, D-010)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Absolute voxel position — integer metres. The unit coordinate of D-222.
|
||
///
|
||
/// After domain warp is applied (`tile_x + dx as i32`), the result is the
|
||
/// integer voxel address used for all material/morphology decisions (D-239 §4).
|
||
pub type VoxelPos = (i32, i32);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Family dispatch (D-239 §5)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Internal family identifier for the 8-family morphology dispatch (D-239 §5).
|
||
///
|
||
/// Derived from `MorphologyZone` by `zone_to_family`. The dispatch gates are
|
||
/// pre-computed at zone classification time (DistrictProfile); the family is the
|
||
/// structural decision that drives voxel geometry.
|
||
///
|
||
/// All 8 families have dedicated generators: `AlluvialPlain` (T-1028, also the
|
||
/// D-239 §5 fallback for flat/water zones) and the other 7 (T-1029).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum MorphologyFamily {
|
||
/// Lava field / shield slope. Requires TectonicClass::Volcanic (D-239 §5).
|
||
LavaField,
|
||
/// Fjord wall. Requires GlaciationGrade ≥ 2 (D-239 §5).
|
||
FjordWall,
|
||
/// Cliff coast. High slope + coastal (D-239 §5).
|
||
CliffCoast,
|
||
/// Braided delta. Very flat + low elevation + coastal (D-239 §5).
|
||
BraidedDelta,
|
||
/// Dune strand. Low slope + coastal + arid (D-239 §5).
|
||
DuneStrand,
|
||
/// Incised gorge / mountain pass. High slope + inland + high elev (D-239 §5).
|
||
IncisedGorge,
|
||
/// Meander reach. Gentle slope + water presence (D-239 §5).
|
||
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.
|
||
///
|
||
/// Sub-classified zones (TidalFlat, Estuarine, Alpine, Wetland) map to their
|
||
/// parent family for voxel geometry — their distinction is captured in the
|
||
/// district zone label, not in the voxel generator dispatch.
|
||
///
|
||
/// BraidedPlain maps to BraidedDelta (its parent at district scale, per the
|
||
/// D-239 §6 implementation note — BraidedPlain is deferred from DistrictProfile
|
||
/// to ChunkContext sub-classification).
|
||
fn zone_to_family(zone: &MorphologyZone) -> MorphologyFamily {
|
||
match zone {
|
||
MorphologyZone::Volcanic => MorphologyFamily::LavaField,
|
||
MorphologyZone::Fjord => MorphologyFamily::FjordWall,
|
||
MorphologyZone::CliffCoast => MorphologyFamily::CliffCoast,
|
||
MorphologyZone::Delta | MorphologyZone::BraidedPlain | MorphologyZone::Estuarine => {
|
||
MorphologyFamily::BraidedDelta
|
||
}
|
||
MorphologyZone::DuneStrand => MorphologyFamily::DuneStrand,
|
||
MorphologyZone::MountainPass | MorphologyZone::ValleyFloor | MorphologyZone::Alpine => {
|
||
MorphologyFamily::IncisedGorge
|
||
}
|
||
MorphologyZone::MeanderReach | MorphologyZone::RiverBank => MorphologyFamily::MeanderReach,
|
||
// 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,
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Voxel derivation entry point
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Derive a `VoxelColumn` for the tile at integer metre position `(tile_x, tile_y)`.
|
||
///
|
||
/// ## Warp wiring (D-239 §4)
|
||
///
|
||
/// 1. Call `domain_warp(seed, body_id, (tile_x, tile_y))` → `(dx, dy)` in f64.
|
||
/// 2. Apply `voxel_x = (tile_x as f64 + dx) as i32` (and likewise for `voxel_y`).
|
||
/// 3. The integer `(voxel_x, voxel_y)` is used for all downstream decisions.
|
||
/// This is the **only** f64 in the structural path.
|
||
///
|
||
/// ## Parameters
|
||
///
|
||
/// - `world_seed` — master world seed.
|
||
/// - `body_id` — stable string body identifier (used for domain separation).
|
||
/// - `district` — the covering `DistrictProfile` (~1 km).
|
||
/// - `chunk` — the covering `ChunkContext` (64 m).
|
||
/// - `tile_x`, `tile_y` — logical tile position in **integer metres** (D-010).
|
||
///
|
||
/// ## Returns
|
||
///
|
||
/// A fully derived `VoxelColumn` with all D-228 axes populated.
|
||
/// T-1081: the metre span of the voxel-tier mid-scale relief field — the amplitude
|
||
/// ceiling for the ≈0.13–1 km rolling/ridged hills added to the family base
|
||
/// elevation. Actual relief at a voxel is `voxel_relief(...) * SPAN`, which the
|
||
/// envelope (district ruggedness) scales down on gentle terrain, so this is the
|
||
/// steep-terrain ceiling, not a uniform amplitude. Provisional — tuned against the
|
||
/// believability probe (T-1079) like the moisture gradient; per-body *absolute*
|
||
/// elevation span is a later refinement (the T-1080 climate-fields lineage).
|
||
///
|
||
/// Kept modest so relief stays mostly *below* the family base elevation: the base is
|
||
/// the compressed `elev_q/N` scale (max ~50 m) and `elevation_m` clamps at 0 (sea
|
||
/// level), so an oversized span clamps away on low ground — drowning the relief and
|
||
/// biasing it positive. A larger span belongs with the deferred per-body absolute
|
||
/// elevation model that would give the base real headroom.
|
||
const VOXEL_RELIEF_SPAN_M: i32 = 100;
|
||
|
||
pub fn derive_voxel_column(
|
||
world_seed: u64,
|
||
body_id: &str,
|
||
district: &DistrictProfile,
|
||
chunk: &ChunkContext,
|
||
tile_x: i32,
|
||
tile_y: i32,
|
||
) -> VoxelColumn {
|
||
// ── 1. Domain warp (D-239 §4) ─────────────────────────────────────────
|
||
// Apply warp and truncate to integer voxel address. This is the ONLY f64
|
||
// in the structural path; all decisions below use integer voxel coords.
|
||
// The warp is applied BEFORE the blend boundary, so the seam cannot align
|
||
// with a chunk edge (T-1042: "reusing the same warp offset so the blend
|
||
// seam cannot align with the chunk edge").
|
||
let (dx, dy) = domain_warp(world_seed, body_id, (tile_x, tile_y));
|
||
let voxel_x = (tile_x as f64 + dx) as i32;
|
||
let voxel_y = (tile_y as f64 + dy) as i32;
|
||
let voxel_pos: VoxelPos = (voxel_x, voxel_y);
|
||
|
||
// ── 2. Sub-chunk seed (for features with wavelength < 64 m) ───────────
|
||
// Keyed on integer voxel address after warp — deterministic (D-010). Uses the
|
||
// dedicated SeedDomain::Voxel (not ChunkContext) so the per-voxel stream can
|
||
// never collide with the district-scale meander seed (D-224 domain separation).
|
||
let sub_chunk_seed = SeedChain::for_body(world_seed, body_id)
|
||
.derive(SeedDomain::Voxel, voxel_pos_to_id(voxel_pos))
|
||
.seed();
|
||
|
||
// ── 2b. Cross-district terrain blending (T-1042, D-239 §4/§7) ─────────
|
||
// When a secondary district is present AND blend_weight < 255, blend the
|
||
// continuous terrain scalars (`elev_q`, `moisture_q`) between the primary
|
||
// and secondary district. This is done BEFORE family dispatch so the
|
||
// blended values propagate through ALL family generators.
|
||
//
|
||
// Morphology family is NOT changed — family dispatch always uses the
|
||
// primary district's `morphology_zone` (D-239 §7: morphology seams stay
|
||
// sharp). Only `elev_q` and `moisture_q` are blended; all structural
|
||
// fields (glaciation_grade, tectonic_class, precipitation_class, etc.)
|
||
// stay primary.
|
||
//
|
||
// Integer blend formula: `(a * w + b * (255 - w) + 127) / 255` (D-010).
|
||
// This is a zero-cost branch when blend_weight == 255 (no secondary active).
|
||
let effective_district: std::borrow::Cow<DistrictProfile> = if chunk.blend_weight < 255 {
|
||
if let Some(ref sec) = chunk.secondary {
|
||
let w = chunk.blend_weight as i32;
|
||
let w_sec = 255 - w;
|
||
let blended_elev_q = (district.elev_q * w + sec.elev_q * w_sec + 127) / 255;
|
||
let blended_moisture_q = (district.moisture_q * w + sec.moisture_q * w_sec + 127) / 255;
|
||
let mut blended = district.clone();
|
||
blended.elev_q = blended_elev_q;
|
||
blended.moisture_q = blended_moisture_q;
|
||
std::borrow::Cow::Owned(blended)
|
||
} else {
|
||
std::borrow::Cow::Borrowed(district)
|
||
}
|
||
} else {
|
||
std::borrow::Cow::Borrowed(district)
|
||
};
|
||
|
||
let district_eff: &DistrictProfile = &effective_district;
|
||
|
||
// ── 3. Family dispatch (D-239 §5) ─────────────────────────────────────
|
||
// Family selection uses the PRIMARY district's morphology_zone (D-239 §7:
|
||
// seams stay sharp). Blended `district_eff` is passed to the generators
|
||
// so their `elev_q`/`moisture_q` reads get the blended values.
|
||
let family = zone_to_family(&district.morphology_zone);
|
||
|
||
let mut column = match family {
|
||
MorphologyFamily::AlluvialPlain => {
|
||
generate_alluvial_plain(district_eff, chunk, voxel_pos, sub_chunk_seed)
|
||
}
|
||
MorphologyFamily::LavaField => generate_lava_field(district_eff, voxel_pos, sub_chunk_seed),
|
||
MorphologyFamily::FjordWall => {
|
||
generate_fjord_wall(district_eff, chunk, voxel_pos, sub_chunk_seed)
|
||
}
|
||
MorphologyFamily::CliffCoast => {
|
||
generate_cliff_coast(district_eff, chunk, voxel_pos, sub_chunk_seed)
|
||
}
|
||
MorphologyFamily::BraidedDelta => {
|
||
generate_braided_delta(district_eff, chunk, voxel_pos, sub_chunk_seed)
|
||
}
|
||
MorphologyFamily::DuneStrand => {
|
||
generate_dune_strand(district_eff, chunk, voxel_pos, sub_chunk_seed)
|
||
}
|
||
MorphologyFamily::IncisedGorge => {
|
||
generate_incised_gorge(district_eff, chunk, voxel_pos, sub_chunk_seed)
|
||
}
|
||
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)
|
||
}
|
||
};
|
||
|
||
// ── 3b. Voxel-tier mid-scale relief (T-1081, D-227 / D-243 §2) ────────
|
||
// The family generators set a coarse district-derived base elevation plus only
|
||
// ±4 m of per-voxel micro-scatter, leaving the walkable surface near-flat — no
|
||
// hills or landmarks to navigate by (the T-1081 symptom). Add the mid-scale
|
||
// (≈0.25–2 km) rolling/ridged relief the district→voxel seam does not otherwise
|
||
// reach (D-243 §2): detail-scatter enveloped by district ruggedness (slope_q) so
|
||
// an authored plain stays flat and rugged terrain gains real relief. The relief
|
||
// field is body-global (SeedDomain::VoxelRelief, position-keyed); the f64
|
||
// perturbation is truncated to integer metres before assignment (D-010,
|
||
// truncate-before-decision — the same sanctioned f64 path as the domain warp).
|
||
//
|
||
// Skipped for the WaterBody family: open ocean / lake / tidal-flat surfaces stay
|
||
// at sea level (elevation 0). River channels inside the land families ride up
|
||
// with their banks — value noise is smooth at 1 m steps, so a channel voxel and
|
||
// its bank get near-identical relief and the family's relative channel cut holds.
|
||
// Only the FLAT families take the mid-scale relief. The dramatic families
|
||
// (CliffCoast / FjordWall / IncisedGorge) already generate strong internal relief
|
||
// from their own geometry — layering a position-varying field over them would warp
|
||
// those features (e.g. drown a gorge's wall-to-floor drop under a relief swell).
|
||
// WaterBody stays at sea level. The flat families are the ones that read flat and
|
||
// need invented relief; their only feature is a 3–15 m channel, narrow enough that
|
||
// the smooth relief shifts it bodily with its banks.
|
||
let takes_mid_scale_relief = matches!(
|
||
family,
|
||
MorphologyFamily::AlluvialPlain
|
||
| MorphologyFamily::LavaField
|
||
| MorphologyFamily::BraidedDelta
|
||
| MorphologyFamily::DuneStrand
|
||
| MorphologyFamily::MeanderReach
|
||
);
|
||
if takes_mid_scale_relief {
|
||
let relief_seed = SeedChain::for_body(world_seed, body_id)
|
||
.derive(SeedDomain::VoxelRelief, 0)
|
||
.seed();
|
||
// Relief envelope (T-1081): the coarse heightmap (~40–78 km/px) carries almost
|
||
// no slope at this tier (slope_q tops out near 0.1 on a real body), so gating on
|
||
// slope_q alone would kill the invented relief everywhere. Combine slope with
|
||
// elevation — high ground reads rugged, coastal lowlands stay gentle — so
|
||
// detail-scatter raises hills where the body has high terrain and keeps flats
|
||
// soft. (Tectonic-class ridge sharpening is a later refinement.)
|
||
let signal = (district_eff.slope_q * 3 + district_eff.elev_q).clamp(0, 100) as f64 / 100.0;
|
||
let relief = crate::atlas::detail_scatter::voxel_relief(
|
||
relief_seed,
|
||
voxel_x as f64,
|
||
voxel_y as f64,
|
||
signal,
|
||
signal,
|
||
);
|
||
let relief_m = (relief * VOXEL_RELIEF_SPAN_M as f64) as i32; // truncate (D-010)
|
||
column.elevation_m = (column.elevation_m + relief_m).max(0);
|
||
}
|
||
|
||
// ── 4. Seasonal cover overlay (D-239 §3, T-1030) ──────────────────────
|
||
// Applied AFTER family dispatch: the 8 family generators produce the base
|
||
// axes (terrain/water/vegetation/elevation); cover is a separate orthogonal
|
||
// axis derived from the district's mean temperature + water/terrain + coherent
|
||
// cluster scatter. One site, set here — no family generator needs changing.
|
||
// Cover derives from the primary district's temperature (not blended) since
|
||
// temperature blending is the sibling ticket's domain (T-1078, D-243 §4).
|
||
column.cover = derive_cover(world_seed, body_id, district, &column, voxel_pos);
|
||
|
||
column
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// AlluvialPlain generator (T-1028, D-239 §5 fallback)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// AlluvialPlain voxel generator — the D-239 §5 fallback family (T-1028).
|
||
///
|
||
/// Flat floodplain with:
|
||
/// - `Soil` terrain material (D-239 §8 Soil law: rolling/floodplain)
|
||
/// - Vegetation from the district's `vegetation_class` (D-239 §2)
|
||
/// - Flat elevation derived from district `elev_q` + small local scatter
|
||
/// - Meander channel cut from `ChunkContext` (D-239 §10: one basin direction
|
||
/// + global meander params, NOT a per-tile flow_direction grid)
|
||
/// - Water state: Shallow/Deep in channel, Dry elsewhere
|
||
///
|
||
/// ## Meander channel placement
|
||
///
|
||
/// The channel is a sine-wave approximation running along `basin_direction`,
|
||
/// centred on the district-anchored `channel_anchor_m` (T-1040). The
|
||
/// perpendicular distance from the voxel to the wave determines whether the
|
||
/// voxel is in-channel. This avoids per-tile flow grids while producing a
|
||
/// spatially coherent channel continuous across chunk boundaries (D-239 §10).
|
||
///
|
||
/// Channel width is `chunk.channel_width_m` (game-feel range 3–15 m).
|
||
fn generate_alluvial_plain(
|
||
district: &DistrictProfile,
|
||
chunk: &ChunkContext,
|
||
voxel_pos: VoxelPos,
|
||
sub_chunk_seed: u64,
|
||
) -> VoxelColumn {
|
||
// ── Terrain material ───────────────────────────────────────────────────
|
||
// AlluvialPlain → Soil (D-239 §8 Soil law). Wetland sub-zone → Wetland
|
||
// substrate when moisture is very high (§8 Wetland ≤5° flats law).
|
||
let terrain = if matches!(district.morphology_zone, MorphologyZone::Wetland)
|
||
|| (district.slope_q <= 5 && district.moisture_q >= 60)
|
||
{
|
||
TerrainMaterial::Wetland
|
||
} else {
|
||
TerrainMaterial::Soil
|
||
};
|
||
|
||
// ── Vegetation ─────────────────────────────────────────────────────────
|
||
// Base vegetation from district class; sub-chunk scatter (a few Grass/Scrub
|
||
// variations) uses `sub_chunk_seed` — wavelength < 64 m.
|
||
let base_veg = Vegetation::from_vegetation_class(district.vegetation_class);
|
||
let vegetation = scatter_vegetation(base_veg, sub_chunk_seed);
|
||
|
||
// ── Base elevation ──────────────────────────────────────────────────────
|
||
// Convert district `elev_q` (0–100) to metres. We scale 0–100 → 0–50 m here
|
||
// (walking-skeleton fidelity: a rough body-relative elevation).
|
||
// Sub-chunk micro-relief: small integer scatter in [-4, +3] m from the seed
|
||
// (3 low bits, minus 4). The final elevation is clamped to >= 0 below.
|
||
let base_elev_m = district.elev_q / 2;
|
||
let micro_relief = (sub_chunk_seed & 0x7) as i32 - 4; // 3 bits → [-4, +3]
|
||
let elevation_m = (base_elev_m + micro_relief).max(0);
|
||
|
||
// ── Channel (meander, D-239 §10) ───────────────────────────────────────
|
||
// Check if this voxel falls within the meander channel. The channel is a
|
||
// sine-wave path through the chunk, parameterised by meander_phase and
|
||
// meander_wavelength_m. We compute the perpendicular distance from the voxel
|
||
// to the channel centreline; if it's within half the channel width, the voxel
|
||
// is in-channel.
|
||
//
|
||
// All comparison is integer (D-010): we compute a scaled integer distance.
|
||
let (in_channel, is_deep) = if chunk.has_active_channel {
|
||
compute_channel_state(voxel_pos, chunk, sub_chunk_seed)
|
||
} else {
|
||
(false, false)
|
||
};
|
||
|
||
let water = if is_deep {
|
||
Water::Deep
|
||
} else if in_channel {
|
||
Water::Shallow
|
||
} else {
|
||
Water::Dry
|
||
};
|
||
|
||
// ── Channel elevation adjustment ───────────────────────────────────────
|
||
// In-channel voxels are slightly lower than the surrounding floodplain
|
||
// (the channel is cut below the levee/floodplain surface). This satisfies
|
||
// D-239 §9: "channels fall below and levees above the high-water threshold".
|
||
// We use a fixed 2 m cut (integer, D-010).
|
||
let elevation_m = if in_channel {
|
||
(elevation_m - 2).max(0)
|
||
} else {
|
||
elevation_m
|
||
};
|
||
|
||
VoxelColumn {
|
||
terrain,
|
||
floor: FloorMaterial::None,
|
||
vegetation,
|
||
water,
|
||
elevation_m,
|
||
cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// LavaField voxel generator.
|
||
///
|
||
/// Shield-slope topology with lava sheets and tube voids:
|
||
/// - `Lava` terrain everywhere (D-239 §8 Lava law).
|
||
/// - `Barren` vegetation — fresh lava is lifeless.
|
||
/// - Low-relief base elevation (shield — gentle radiating slopes, not peaks).
|
||
/// - Micro-relief: coarse lava block texture from seed bits.
|
||
/// - Tube voids: seed-gated depressions 1–3 m below the surface (immature
|
||
/// drainage; no active river network — D-239 §8 immature drainage law).
|
||
/// - No active water channel regardless of `chunk.has_active_channel` —
|
||
/// immature drainage means no organised river network on fresh lava.
|
||
fn generate_lava_field(
|
||
district: &DistrictProfile,
|
||
_voxel_pos: VoxelPos,
|
||
sub_chunk_seed: u64,
|
||
) -> VoxelColumn {
|
||
// ── Base elevation ─────────────────────────────────────────────────────
|
||
// Shield slope: gentle radiating dome. Use elev_q for the regional height;
|
||
// the shield adds a small parabolic falloff from the centre. At voxel scale
|
||
// we simulate this as a low-amplitude multi-scale noise on a flat base.
|
||
// Integer arithmetic throughout (D-010).
|
||
let base_elev_m = district.elev_q / 2;
|
||
|
||
// Coarse lava block texture: bits [0:2] of seed → ±2 m vertical scatter.
|
||
let lava_block = (sub_chunk_seed & 0x7) as i32 - 3; // [−3, +4]
|
||
|
||
// Lava tube void: 1-in-8 chance (bits [3:5] == 0b000) of a depression.
|
||
// Tubes are below-surface drainage (D-239 §8 immature drainage).
|
||
let tube_depression = if (sub_chunk_seed >> 3) & 0x7 == 0 {
|
||
let depth = ((sub_chunk_seed >> 6) & 0x3) as i32 + 1; // 1–4 m depression
|
||
-depth
|
||
} else {
|
||
0
|
||
};
|
||
|
||
let elevation_m = (base_elev_m + lava_block + tube_depression).max(0);
|
||
|
||
// ── Tube void water state ──────────────────────────────────────────────
|
||
// A lava tube depression may collect a shallow puddle (moisture-gated).
|
||
// Very rare — only in humid zones (moisture_q ≥ 70). No Deep water on
|
||
// shield slopes (immature drainage has no deep organised channels).
|
||
let water = if tube_depression < -2 && district.moisture_q >= 70 {
|
||
Water::Shallow
|
||
} else {
|
||
Water::Dry
|
||
};
|
||
|
||
VoxelColumn {
|
||
terrain: TerrainMaterial::Lava,
|
||
floor: FloorMaterial::None,
|
||
vegetation: Vegetation::Barren,
|
||
water,
|
||
elevation_m,
|
||
cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// FjordWall generator (T-1029, D-239 §5 / §8 glaciation→form law)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// FjordWall voxel generator.
|
||
///
|
||
/// Glacial U-valley topology:
|
||
/// - `Rock` terrain (D-239 §8 Rock→vertical faces law).
|
||
/// - Steep valley walls meeting deep water in the channel (the fjord).
|
||
/// - U-valley cross-section: flat (deep) bottom + near-vertical walls.
|
||
/// - GlaciationGrade ≥ 2 produces cirques (D-239 §5/§8 glaciation→form law).
|
||
/// - Moraines (debris mounds) at the valley sides for Grade ≥ 1.
|
||
/// - Valley floor is narrow (chokepoint — D-239 §9) with `Deep` water.
|
||
/// - Walls rise steeply; rocky, very little vegetation.
|
||
///
|
||
/// The fjord trough runs along `basin_direction` at the district-anchored
|
||
/// centreline `chunk.channel_anchor_m` (T-1041, D-239 §10): ONE valley spans
|
||
/// the district's chunks. Cross-section: wall | moraine | Deep water | moraine | wall.
|
||
fn generate_fjord_wall(
|
||
district: &DistrictProfile,
|
||
chunk: &ChunkContext,
|
||
voxel_pos: VoxelPos,
|
||
sub_chunk_seed: u64,
|
||
) -> VoxelColumn {
|
||
// ── Cross-valley coordinate ───────────────────────────────────────────
|
||
// Fjord runs along basin_direction; cross = perpendicular distance from
|
||
// the centre-line.
|
||
let (along, cross) = match chunk.basin_direction {
|
||
crate::atlas::chunk_context::BasinDirection::North
|
||
| crate::atlas::chunk_context::BasinDirection::South => (voxel_pos.1, voxel_pos.0),
|
||
crate::atlas::chunk_context::BasinDirection::East
|
||
| crate::atlas::chunk_context::BasinDirection::West => (voxel_pos.0, voxel_pos.1),
|
||
};
|
||
|
||
// Cross-channel distance from the district-anchored trough centreline
|
||
// (T-1041): continuous world coordinates — folding into the 64 m chunk
|
||
// frame repeated a complete fjord in every chunk (D-239 §10 violation).
|
||
let cross_from_centre = (cross - chunk.channel_anchor_m).abs();
|
||
|
||
// ── U-valley geometry ─────────────────────────────────────────────────
|
||
// The fjord bottom is 4–8 m wide (chokepoint, D-239 §9 "gorge floors 2–8 m").
|
||
// Deep water within floor_half m of centre; moraine band beyond; wall beyond that.
|
||
//
|
||
// GlaciationGrade modifies the floor width, wall steepness, and cirque presence.
|
||
// Upstream classifier gate guarantees GlaciationGrade ≥ 2 for FjordWall (D-239 §5).
|
||
let glacier_grade = district.glaciation_grade as i32; // guaranteed ≥ 2
|
||
|
||
// Fjord floor half-width: 2–4 m (total 4–8 m, D-239 §9 compliant).
|
||
// Stronger glaciation carves a wider U-trough, but stays within §9 spec.
|
||
let floor_half = glacier_grade.clamp(2, 4); // total 4–8 m
|
||
// Moraine band extends from floor edge to moraine_outer.
|
||
let moraine_outer = floor_half + 6 + glacier_grade; // 10–14 m from centre
|
||
|
||
// Base elevation for the wall platform — high ground context.
|
||
let wall_elev_base = (district.elev_q / 2).max(20); // at least 20 m walls
|
||
|
||
let (elevation_m, water, vegetation) = if cross_from_centre <= floor_half {
|
||
// ── Fjord floor — deep water ─────────────────────────────────────
|
||
// Below sea level (drainage monotonicity: mouth at sea level).
|
||
// Elevation 0 m (sea level).
|
||
let floor_noise = (sub_chunk_seed & 0x1) as i32; // ±0–1 m
|
||
let elev = floor_noise; // 0 or 1 m at most
|
||
(elev, Water::Deep, Vegetation::Barren)
|
||
} else if cross_from_centre <= moraine_outer {
|
||
// ── Moraine band — rocky debris mound ────────────────────────────
|
||
// Moraines always present — upstream classifier gate guarantees
|
||
// GlaciationGrade ≥ 2 (D-239 §5). Slight elevation bump; Rock substrate.
|
||
let moraine_noise = ((sub_chunk_seed >> 2) & 0x3) as i32; // 0–3 m
|
||
let moraine_dist = cross_from_centre - floor_half;
|
||
// Bell-shape: rises then falls across the band.
|
||
let half_band = (moraine_outer - floor_half) / 2;
|
||
let moraine_height = if moraine_dist <= half_band {
|
||
(moraine_dist * 3) / half_band.max(1) // 0 → 3 m rise
|
||
} else {
|
||
3 - ((moraine_dist - half_band) * 3) / half_band.max(1) // 3 → 0 m fall
|
||
};
|
||
let elev = (wall_elev_base / 4 + moraine_height + moraine_noise).max(0);
|
||
// Moraines hold some Shallow pools (meltwater) in humid zones.
|
||
let water = if district.moisture_q >= 60 && moraine_noise >= 2 {
|
||
Water::Shallow
|
||
} else {
|
||
Water::Dry
|
||
};
|
||
(elev, water, Vegetation::Barren)
|
||
} else {
|
||
// ── Valley wall — near-vertical face ─────────────────────────────
|
||
// Rock→vertical faces (D-239 §8). Elevation rises steeply from
|
||
// moraine_outer to the wall top.
|
||
let wall_dist = cross_from_centre - moraine_outer;
|
||
// Steep linear rise: 6 m per metre of cross-distance (approximates
|
||
// vertical), saturating at a 120 m plateau 20 m out — cross distance
|
||
// is district-scale post-T-1041, so the rise must not grow unbounded to
|
||
// the district edge (clamp before multiplying: i32 overflow otherwise).
|
||
let wall_rise = wall_dist.min(20) * 6;
|
||
// Cirques (grade ≥ 2): occasional hollowed pocket (2–4 m depression) at
|
||
// upper wall. seed-gated, ~1-in-8 frequency.
|
||
let cirque_depression = if glacier_grade >= 2 && (sub_chunk_seed >> 6) & 0x7 == 0 {
|
||
// 2–5 m
|
||
((sub_chunk_seed >> 9) & 0x3) as i32 + 2
|
||
} else {
|
||
0
|
||
};
|
||
// Wall noise: jagged rock face (bits [1:3]).
|
||
let wall_noise = ((sub_chunk_seed >> 1) & 0x7) as i32 - 4; // [−4, +3]
|
||
let elev = (wall_elev_base + wall_rise + wall_noise - cirque_depression).max(0);
|
||
// Very sparse vegetation on high rocky walls.
|
||
let veg = match district.vegetation_class {
|
||
VegetationClass::Forest | VegetationClass::Scrub => {
|
||
if (sub_chunk_seed >> 12) & 0xF < 2 {
|
||
Vegetation::Scrub
|
||
} else {
|
||
Vegetation::Barren
|
||
}
|
||
}
|
||
_ => Vegetation::Barren,
|
||
};
|
||
(elev, Water::Dry, veg)
|
||
};
|
||
|
||
// ── Along-axis variation (avalanche debris / longitudinal texture) ────
|
||
// Use along coordinate modulo the meander wavelength for slow variation.
|
||
let _along_used = along; // structural reference; prevents unused-variable warning
|
||
|
||
VoxelColumn {
|
||
terrain: TerrainMaterial::Rock,
|
||
floor: FloorMaterial::None,
|
||
vegetation,
|
||
water,
|
||
elevation_m,
|
||
cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CliffCoast generator (T-1029, D-239 §5 / §8 Rock law)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// CliffCoast voxel generator.
|
||
///
|
||
/// Vertical rock face at the coastal water edge:
|
||
/// - `Rock` terrain (D-239 §8 Rock→vertical faces law).
|
||
/// - Sharp elevation drop to `Shallow`/`Deep` water at the base.
|
||
/// - The coast face sits on the district-anchored line `chunk.coast_anchor_m`
|
||
/// along the seaward axis (T-1041, D-239 §10): ONE continuous coast line
|
||
/// per district (warp-displaced), not a 64 m sawtooth. Inland of the line is
|
||
/// high ground; seaward is ocean.
|
||
/// - Narrow ledge/platform at the cliff base (passable ground, Shallow).
|
||
/// - Virtually no flat ground (D-239 §8 — Rock→vertical, not terraced).
|
||
fn generate_cliff_coast(
|
||
district: &DistrictProfile,
|
||
chunk: &ChunkContext,
|
||
voxel_pos: VoxelPos,
|
||
sub_chunk_seed: u64,
|
||
) -> VoxelColumn {
|
||
// ── Coast orientation ─────────────────────────────────────────────────
|
||
// basin_direction points seaward (water flows to ocean). coast_d is the
|
||
// signed seaward distance from the district-anchored coast line in
|
||
// continuous world metres (T-1041): negative = inland, positive = seaward.
|
||
let coast_d = match chunk.basin_direction {
|
||
crate::atlas::chunk_context::BasinDirection::North => chunk.coast_anchor_m - voxel_pos.1,
|
||
crate::atlas::chunk_context::BasinDirection::South => voxel_pos.1 - chunk.coast_anchor_m,
|
||
crate::atlas::chunk_context::BasinDirection::East => voxel_pos.0 - chunk.coast_anchor_m,
|
||
crate::atlas::chunk_context::BasinDirection::West => chunk.coast_anchor_m - voxel_pos.0,
|
||
};
|
||
|
||
// ── Cliff face ────────────────────────────────────────────────────────
|
||
// Inland (coast_d < 0): high ground, rocky top.
|
||
// Face (0..8): steep drop (>6 m per metre — near-vertical, D-239 §8).
|
||
// Ledge (8..13): rocky beach platform, Shallow.
|
||
// Ocean (≥13): Deep water.
|
||
//
|
||
// Cliff height derived from district elevation.
|
||
let cliff_top_elev = (district.elev_q / 2).max(15); // at least 15 m cliff
|
||
|
||
// Rock-face jaggedness noise: bits [0:2] → ±3 m.
|
||
let face_noise = (sub_chunk_seed & 0x7) as i32 - 4;
|
||
// Ledge variation noise: bits [3:5] → 0–2 m.
|
||
let ledge_noise = ((sub_chunk_seed >> 3) & 0x3) as i32;
|
||
|
||
let (elevation_m, water) = if coast_d < 0 {
|
||
// Inland high ground — rocky plateau.
|
||
let inland_noise = ((sub_chunk_seed >> 5) & 0xF) as i32 - 7; // [−7, +8]
|
||
let elev = (cliff_top_elev + inland_noise).max(cliff_top_elev / 4);
|
||
(elev, Water::Dry)
|
||
} else if coast_d < 8 {
|
||
// Cliff face — steep drop. Each metre seaward drops ~cliff_top/8 metres.
|
||
let face_dist = coast_d; // 0–7
|
||
let drop = (face_dist * cliff_top_elev) / 8;
|
||
let elev = (cliff_top_elev - drop + face_noise).max(1);
|
||
(elev, Water::Dry)
|
||
} else if coast_d < 13 {
|
||
// Rocky ledge / splash zone.
|
||
let elev = (1 + ledge_noise).max(0);
|
||
(elev, Water::Shallow)
|
||
} else {
|
||
// Ocean — drainage monotonicity: mouths at sea level (elev 0).
|
||
(0, Water::Deep)
|
||
};
|
||
|
||
// Vegetation: nearly barren on rock faces; tiny scatter of Scrub on the
|
||
// inland top (≥18 m / ≥28 m inland of the face — same offsets as the old
|
||
// chunk-frame layout, now measured from the district-anchored coast line).
|
||
let vegetation = match district.vegetation_class {
|
||
VegetationClass::Forest | VegetationClass::RiparianThicket => {
|
||
if coast_d < -18 && (sub_chunk_seed >> 10) & 0x7 < 3 {
|
||
Vegetation::Scrub
|
||
} else {
|
||
Vegetation::Barren
|
||
}
|
||
}
|
||
VegetationClass::Scrub | VegetationClass::RiparianScrub => {
|
||
if coast_d < -28 && (sub_chunk_seed >> 10) & 0x3 == 0 {
|
||
Vegetation::Scrub
|
||
} else {
|
||
Vegetation::Barren
|
||
}
|
||
}
|
||
_ => Vegetation::Barren,
|
||
};
|
||
|
||
VoxelColumn {
|
||
terrain: TerrainMaterial::Rock,
|
||
floor: FloorMaterial::None,
|
||
vegetation,
|
||
water,
|
||
elevation_m,
|
||
cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// BraidedDelta generator (T-1029, D-239 §5 / §8 Gravel law)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// BraidedDelta voxel generator.
|
||
///
|
||
/// Braided channel / fan topology:
|
||
/// - `Gravel` terrain (D-239 §8 Gravel→braided channels/fans law).
|
||
/// - Multiple shallow distributary channels (NOT single-thread meander).
|
||
/// - Very low elevation — near sea level (D-239 §8 drainage monotonicity:
|
||
/// mouths at sea level).
|
||
/// - Channel determination: 3 independent braided threads anastomosing
|
||
/// across a fan belt centred on the district-anchored axis
|
||
/// `chunk.channel_anchor_m` (T-1041, D-239 §10), each thread 3–8 m wide.
|
||
/// Thread offsets are seeded at district scale (meander_phase encodes them),
|
||
/// so the threads run continuously across the district's chunks instead of
|
||
/// restarting every 64 m.
|
||
/// - `Shallow` in-channel; `Dry` on gravel bars between threads.
|
||
fn generate_braided_delta(
|
||
district: &DistrictProfile,
|
||
chunk: &ChunkContext,
|
||
voxel_pos: VoxelPos,
|
||
sub_chunk_seed: u64,
|
||
) -> VoxelColumn {
|
||
// ── Base elevation ────────────────────────────────────────────────────
|
||
// Delta is near sea level. elev_q typically very low in delta zones; cap
|
||
// at 5 m to honour drainage monotonicity (mouths at sea level).
|
||
let base_elev_m = (district.elev_q / 4).min(5); // 0–5 m; near sea level
|
||
// Gravel bar micro-relief: ±1 m scatter on bars between channels.
|
||
let bar_noise = (sub_chunk_seed & 0x3) as i32; // 0–3
|
||
|
||
// ── Braided thread geometry ───────────────────────────────────────────
|
||
// Cross-channel coordinate (perpendicular to basin_direction).
|
||
let (_along, cross) = match chunk.basin_direction {
|
||
crate::atlas::chunk_context::BasinDirection::North
|
||
| crate::atlas::chunk_context::BasinDirection::South => (voxel_pos.1, voxel_pos.0),
|
||
crate::atlas::chunk_context::BasinDirection::East
|
||
| crate::atlas::chunk_context::BasinDirection::West => (voxel_pos.0, voxel_pos.1),
|
||
};
|
||
|
||
// Three braided threads with well-separated centres derived from meander_phase,
|
||
// spread across a 64 m fan belt centred on the district-anchored axis
|
||
// (T-1041 — world cross coordinates, constant for all chunks of the district).
|
||
// Each centre uses independent bit-mixing (splitmix64-style) so threads are
|
||
// provably separated regardless of phase value — no correlated bit-slices.
|
||
let phase_u64 = chunk.meander_phase as u64;
|
||
// Mix A: phase itself through splitmix64 finaliser.
|
||
let mix_a = {
|
||
let mut h = phase_u64.wrapping_add(0x9e3779b97f4a7c15);
|
||
h = (h ^ (h >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
|
||
h = (h ^ (h >> 27)).wrapping_mul(0x94d049bb133111eb);
|
||
h ^ (h >> 31)
|
||
};
|
||
// Mix B: phase XOR'd with a distinct salt before mixing.
|
||
let mix_b = {
|
||
let mut h = phase_u64.wrapping_add(0x9e3779b97f4a7c15) ^ 0xdeadbeefcafe1234;
|
||
h = (h ^ (h >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
|
||
h = (h ^ (h >> 27)).wrapping_mul(0x94d049bb133111eb);
|
||
h ^ (h >> 31)
|
||
};
|
||
// Mix C: phase XOR'd with a second distinct salt.
|
||
let mix_c = {
|
||
let mut h = phase_u64.wrapping_add(0x9e3779b97f4a7c15) ^ 0xfeedface0badcafe;
|
||
h = (h ^ (h >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
|
||
h = (h ^ (h >> 27)).wrapping_mul(0x94d049bb133111eb);
|
||
h ^ (h >> 31)
|
||
};
|
||
let thread_centres: [i32; 3] = [
|
||
chunk.channel_anchor_m - 32 + (mix_a % 64) as i32,
|
||
chunk.channel_anchor_m - 32 + (mix_b % 64) as i32,
|
||
chunk.channel_anchor_m - 32 + (mix_c % 64) as i32,
|
||
];
|
||
|
||
// Thread width: 3–6 m half-width derived from channel_width_m.
|
||
let thread_half = (chunk.channel_width_m / 2).clamp(2, 4);
|
||
|
||
// Check if voxel is in any braided thread — continuous world-coordinate
|
||
// distance (the old [0, 64) ring fold restarted the threads every chunk).
|
||
let in_any_thread = thread_centres
|
||
.iter()
|
||
.any(|¢re| (cross - centre).abs() <= thread_half);
|
||
|
||
let (elevation_m, water) = if in_any_thread {
|
||
// In a distributary channel — at or just below base elevation.
|
||
// Channels are cut 1 m below gravel bars (D-239 §9 ElevationDelta).
|
||
let channel_elev = (base_elev_m - 1).max(0);
|
||
(channel_elev, Water::Shallow)
|
||
} else {
|
||
// Gravel bar between channels — slightly above channel floor.
|
||
(base_elev_m + bar_noise.min(1), Water::Dry)
|
||
};
|
||
|
||
// Near the delta mouth (elev = 0), some bars are submerged (Shallow).
|
||
let water = if elevation_m == 0 && !in_any_thread && bar_noise == 0 {
|
||
Water::Shallow
|
||
} else {
|
||
water
|
||
};
|
||
|
||
// Vegetation: riparian scrub on stable bars; barren on active channel sediment.
|
||
let vegetation = if !in_any_thread && district.moisture_q >= 50 {
|
||
match district.vegetation_class {
|
||
VegetationClass::Forest
|
||
| VegetationClass::RiparianThicket
|
||
| VegetationClass::RiparianScrub => {
|
||
if (sub_chunk_seed >> 4) & 0x7 < 3 {
|
||
Vegetation::Scrub
|
||
} else {
|
||
Vegetation::Barren
|
||
}
|
||
}
|
||
VegetationClass::Scrub => {
|
||
if (sub_chunk_seed >> 4) & 0x3 == 0 {
|
||
Vegetation::Scrub
|
||
} else {
|
||
Vegetation::Barren
|
||
}
|
||
}
|
||
_ => Vegetation::Barren,
|
||
}
|
||
} else {
|
||
Vegetation::Barren
|
||
};
|
||
|
||
VoxelColumn {
|
||
terrain: TerrainMaterial::Gravel,
|
||
floor: FloorMaterial::None,
|
||
vegetation,
|
||
water,
|
||
elevation_m,
|
||
cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DuneStrand generator (T-1029, D-239 §5 / §8 Sand law)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// DuneStrand voxel generator.
|
||
///
|
||
/// Wind-formed dune topology:
|
||
/// - `Sand` terrain (D-239 §8 Sand→≤~32° angle of repose, dunes not cliffs).
|
||
/// - Wind-aligned ridges (orientation derived from `basin_direction`).
|
||
/// - Dune crests and troughs — amplitude ≤ what a 32° slope permits at this
|
||
/// scale (slope constraint: at most 1 m rise per ~1.6 m horizontal run).
|
||
/// - Mostly `Dry`; `Shallow` at the seaward toe where surf meets sand.
|
||
/// - `Barren` or very sparse vegetation (coastal strand).
|
||
fn generate_dune_strand(
|
||
district: &DistrictProfile,
|
||
chunk: &ChunkContext,
|
||
voxel_pos: VoxelPos,
|
||
sub_chunk_seed: u64,
|
||
) -> VoxelColumn {
|
||
// ── Dune orientation ──────────────────────────────────────────────────
|
||
// Dunes are wind-aligned; dune ridges run perpendicular to basin_direction
|
||
// (wind blows onshore, ridges parallel the shoreline).
|
||
// Along-wind = basin_direction; dune crest height varies along-wind.
|
||
let (along_wind, cross_wind) = match chunk.basin_direction {
|
||
crate::atlas::chunk_context::BasinDirection::North
|
||
| crate::atlas::chunk_context::BasinDirection::South => (voxel_pos.1, voxel_pos.0),
|
||
crate::atlas::chunk_context::BasinDirection::East
|
||
| crate::atlas::chunk_context::BasinDirection::West => (voxel_pos.0, voxel_pos.1),
|
||
};
|
||
|
||
// ── Dune wavelength ───────────────────────────────────────────────────
|
||
// Dune spacing is shorter than meander wavelength; derive from slope_q.
|
||
// Low slope → long dunes (8–20 m wavelength); high slope → shorter (4–8 m).
|
||
// Integer arithmetic (D-010).
|
||
let dune_wavelength = {
|
||
let slope_clamped = district.slope_q.clamp(0, 40);
|
||
20 - slope_clamped / 2 // 20 m flat → 4 m at slope 32
|
||
}
|
||
.max(4);
|
||
|
||
// Phase offset from meander_phase (district-scale, D-239 §10).
|
||
let phase = chunk.meander_phase as i32;
|
||
let along_mod = (along_wind + phase).rem_euclid(dune_wavelength);
|
||
|
||
// ── Dune cross-section height ─────────────────────────────────────────
|
||
// Triangle-wave: ramp from trough to crest and back over one wavelength.
|
||
// D-239 §8 Sand law: ≤32° angle of repose enforced by the integer physics cap:
|
||
// tan(32°) ≈ 0.625 → max_height ≤ 0.625 × (wavelength/2).
|
||
// The integer form `(wavelength * 625) / 2000` directly encodes this.
|
||
// Also capped by slope_q/4 (district terrain height proxy) to give local variety.
|
||
let max_height = {
|
||
let physics_cap = (dune_wavelength * 625) / 2000; // tan(32°)×wavelength/2, integer
|
||
let district_cap = (district.slope_q / 4).max(1); // district terrain height proxy
|
||
physics_cap.min(district_cap).max(1)
|
||
};
|
||
let half_wl = dune_wavelength / 2;
|
||
let dune_height = if along_mod < half_wl {
|
||
(2 * max_height * along_mod) / dune_wavelength.max(1) // 0 → max
|
||
} else {
|
||
max_height - ((along_mod - half_wl) * max_height) / half_wl.max(1) // max → 0
|
||
};
|
||
|
||
// ── Base elevation ────────────────────────────────────────────────────
|
||
// Beach/strand is near sea level; add dune height above that base.
|
||
// Cross-wind variation: sub-dune bar variation (±1 m).
|
||
let cross_noise = cross_wind.abs() & 0x1; // 0 or 1
|
||
// Bits [4:6] of seed → ±1 m additional scatter on the dune surface.
|
||
let surface_scatter = ((sub_chunk_seed >> 4) & 0x3) as i32 - 1; // [−1, +2]
|
||
|
||
let base_strand_elev = (district.elev_q / 8).max(0); // 0–12 m strand base
|
||
let elevation_m = (base_strand_elev + dune_height + cross_noise + surface_scatter).max(0);
|
||
|
||
// ── Water state ───────────────────────────────────────────────────────
|
||
// Shallow at the strand toe (base elev = 0) and in dune troughs near
|
||
// the coast. Dry elsewhere.
|
||
let water = if elevation_m == 0 || (dune_height == 0 && base_strand_elev <= 1) {
|
||
Water::Shallow // surf line / wave-washed troughs
|
||
} else {
|
||
Water::Dry
|
||
};
|
||
|
||
// ── Vegetation ────────────────────────────────────────────────────────
|
||
// Dunes are mostly barren; scattered dune grass on stable crests
|
||
// in humid zones. No trees (wind/salt prevents forest).
|
||
let vegetation = if district.moisture_q >= 55 && dune_height >= max_height / 2 {
|
||
if (sub_chunk_seed >> 8) & 0x7 < 2 {
|
||
Vegetation::Grass // dune grass on humid crests
|
||
} else {
|
||
Vegetation::Barren
|
||
}
|
||
} else {
|
||
Vegetation::Barren
|
||
};
|
||
|
||
VoxelColumn {
|
||
terrain: TerrainMaterial::Sand,
|
||
floor: FloorMaterial::None,
|
||
vegetation,
|
||
water,
|
||
elevation_m,
|
||
cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// IncisedGorge generator (T-1029, D-239 §5 / §8 / §9)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// IncisedGorge voxel generator — shared by MountainPass zones.
|
||
///
|
||
/// Deep narrow gorge:
|
||
/// - `Rock` terrain (D-239 §8 Rock→vertical faces).
|
||
/// - Steep walls; single incised channel at the gorge floor.
|
||
/// - **Gorge floor 2–8 m wide** (D-239 §9 chokepoint constraint).
|
||
/// - Massive elevation differential: walls are 20–60 m above the floor.
|
||
/// - `Shallow` water in the incised channel at the floor.
|
||
/// - Cross-section: wall | wall | narrow floor (with channel) | wall | wall.
|
||
/// - The gorge runs along `basin_direction` at the district-anchored
|
||
/// centreline `chunk.channel_anchor_m` (T-1041, D-239 §10): ONE gorge
|
||
/// spans the district's chunks.
|
||
fn generate_incised_gorge(
|
||
district: &DistrictProfile,
|
||
chunk: &ChunkContext,
|
||
voxel_pos: VoxelPos,
|
||
sub_chunk_seed: u64,
|
||
) -> VoxelColumn {
|
||
// ── Cross-gorge coordinate ─────────────────────────────────────────────
|
||
let (_along, cross) = match chunk.basin_direction {
|
||
crate::atlas::chunk_context::BasinDirection::North
|
||
| crate::atlas::chunk_context::BasinDirection::South => (voxel_pos.1, voxel_pos.0),
|
||
crate::atlas::chunk_context::BasinDirection::East
|
||
| crate::atlas::chunk_context::BasinDirection::West => (voxel_pos.0, voxel_pos.1),
|
||
};
|
||
|
||
// Distance from the district-anchored gorge centreline (T-1041): continuous
|
||
// world coordinates — folding into the 64 m chunk frame carved a gorge in
|
||
// every chunk (D-239 §10 violation).
|
||
let cross_from_centre = (cross - chunk.channel_anchor_m).abs();
|
||
|
||
// ── Gorge floor width (D-239 §9: 2–8 m) ──────────────────────────────
|
||
// Floor half-width: 1–4 m (total 2–8 m). Derived from channel_width_m
|
||
// (capped to 4 m half-width to stay within §9).
|
||
let floor_half = chunk.channel_width_m.clamp(1, 4); // 1–4 m half-width
|
||
|
||
// ── Wall geometry ─────────────────────────────────────────────────────
|
||
// The wall rises steeply: each metre away from the floor adds ~8 m of
|
||
// height. Wall top at district elev_q.
|
||
let wall_top = (district.elev_q / 2).max(30); // at least 30 m wall height
|
||
let floor_elev = (district.elev_q / 8).max(0); // floor is much lower than surroundings
|
||
|
||
// Rock wall jaggedness: bits [0:3] → ±4 m.
|
||
let wall_noise = (sub_chunk_seed & 0xF) as i32 - 8; // [−8, +7]
|
||
// Floor noise: bits [4:5] → ±1 m floor scatter.
|
||
let floor_noise = ((sub_chunk_seed >> 4) & 0x3) as i32 - 1; // [−1, +2]
|
||
|
||
let (elevation_m, water) = if cross_from_centre <= floor_half {
|
||
// ── Gorge floor — narrow and deep ─────────────────────────────────
|
||
// Active incised channel (D-239 §9: "single incised channel").
|
||
// The channel runs along the floor; water is Shallow.
|
||
let elev = (floor_elev + floor_noise).max(0);
|
||
(elev, Water::Shallow)
|
||
} else {
|
||
// ── Gorge wall — near-vertical face ───────────────────────────────
|
||
// Elevation rises steeply: each metre of cross-distance from floor edge
|
||
// adds wall_step metres. We model this as a steep ramp.
|
||
let wall_dist = cross_from_centre - floor_half;
|
||
// Steep wall: 8 m rise per metre of wall distance (near-vertical, §8
|
||
// Rock law). The rise saturates at the wall_top clamp below; clamp the
|
||
// district-scale distance (T-1041) before multiplying — i32 overflow at
|
||
// extreme coordinates otherwise.
|
||
let wall_rise = wall_dist.min(16) * 8;
|
||
let elev = (floor_elev + wall_rise + wall_noise).clamp(floor_elev, wall_top);
|
||
(elev, Water::Dry)
|
||
};
|
||
|
||
// Vegetation: barren in the gorge (shadow / rock). Tiny scatter of Scrub
|
||
// on the upper wall rim in humid temperate zones.
|
||
let vegetation = if cross_from_centre > (floor_half + 20) && district.moisture_q >= 60 {
|
||
match district.vegetation_class {
|
||
VegetationClass::Forest | VegetationClass::RiparianThicket => {
|
||
if (sub_chunk_seed >> 8) & 0x7 < 2 {
|
||
Vegetation::Scrub
|
||
} else {
|
||
Vegetation::Barren
|
||
}
|
||
}
|
||
VegetationClass::Scrub => {
|
||
if (sub_chunk_seed >> 8) & 0x3 == 0 {
|
||
Vegetation::Scrub
|
||
} else {
|
||
Vegetation::Barren
|
||
}
|
||
}
|
||
_ => Vegetation::Barren,
|
||
}
|
||
} else {
|
||
Vegetation::Barren
|
||
};
|
||
|
||
VoxelColumn {
|
||
terrain: TerrainMaterial::Rock,
|
||
floor: FloorMaterial::None,
|
||
vegetation,
|
||
water,
|
||
elevation_m,
|
||
cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// MeanderReach generator (T-1029, D-239 §5 / §8 / §9)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// MeanderReach voxel generator.
|
||
///
|
||
/// Single-thread meandering river floodplain:
|
||
/// - `Soil` terrain (D-239 §8 Soil→rolling/floodplain law).
|
||
/// - Stronger single meander than AlluvialPlain (higher sinuosity).
|
||
/// - `river crossings 3–15 m` width (D-239 §9 game-feel constraint).
|
||
/// - Levees above / channel below the floodplain surface
|
||
/// (D-239 §9 ElevationDelta: channels below, levees above high-water threshold).
|
||
/// - Floodplain vegetation from district class with riparian band at waterway.
|
||
fn generate_meander_reach(
|
||
district: &DistrictProfile,
|
||
chunk: &ChunkContext,
|
||
voxel_pos: VoxelPos,
|
||
sub_chunk_seed: u64,
|
||
) -> VoxelColumn {
|
||
// ── Terrain material ───────────────────────────────────────────────────
|
||
// MeanderReach → Soil. Wetland sub-zone possible when very wet and flat.
|
||
let terrain = if matches!(district.morphology_zone, MorphologyZone::Wetland)
|
||
|| (district.slope_q <= 3 && district.moisture_q >= 70)
|
||
{
|
||
TerrainMaterial::Wetland
|
||
} else {
|
||
TerrainMaterial::Soil
|
||
};
|
||
|
||
// ── Base elevation ──────────────────────────────────────────────────────
|
||
// MeanderReach is a low-gradient floodplain; similar to AlluvialPlain but
|
||
// typically lower (mature river valley). Map elev_q → metres with a
|
||
// slightly lower ceiling than AlluvialPlain.
|
||
let base_elev_m = district.elev_q / 3; // 0–33 m range (lower than Alluvial's /2)
|
||
let micro_relief = (sub_chunk_seed & 0x7) as i32 - 4; // [−4, +3] m
|
||
let floodplain_elev = (base_elev_m + micro_relief).max(0);
|
||
|
||
// ── Stronger meander channel (D-239 §9: river crossings 3–15 m) ───────
|
||
// MeanderReach uses higher sinuosity than AlluvialPlain. We achieve this
|
||
// by increasing the effective amplitude multiplier from 1/6 → 1/4 of
|
||
// wavelength, producing ~25% sinuosity (vs ~17% for AlluvialPlain).
|
||
//
|
||
// Channel computation mirrors AlluvialPlain's compute_channel_state but
|
||
// with the stronger amplitude.
|
||
let (in_channel, is_deep) = if chunk.has_active_channel {
|
||
compute_meander_reach_channel(voxel_pos, chunk, sub_chunk_seed)
|
||
} else {
|
||
(false, false)
|
||
};
|
||
|
||
// ── Levee / channel elevation (D-239 §9 ElevationDelta) ───────────────
|
||
// Levees are natural berms built up by flood deposition, elevated above
|
||
// the floodplain. Channels cut below. Elevation relationship:
|
||
// levee_top > floodplain > channel_floor (D-239 §9: "levees above,
|
||
// channels below the high-water threshold").
|
||
let (elevation_m, water, vegetation) = if is_deep {
|
||
// Deep channel thalweg — 3 m below floodplain.
|
||
let elev = (floodplain_elev - 3).max(0);
|
||
(elev, Water::Deep, Vegetation::Thicket)
|
||
} else if in_channel {
|
||
// Shallow channel margin — 2 m below floodplain.
|
||
let elev = (floodplain_elev - 2).max(0);
|
||
// Riparian thicket along the channel bank (D-239 §8 climate→vegetation).
|
||
let veg = match district.vegetation_class {
|
||
VegetationClass::Absent | VegetationClass::Barren => Vegetation::Barren,
|
||
_ => Vegetation::Thicket,
|
||
};
|
||
(elev, Water::Shallow, veg)
|
||
} else {
|
||
// ── Off-channel floodplain ────────────────────────────────────────
|
||
// Check for levee proximity: within 3 m of channel edge → levee.
|
||
// Levee is elevated 1–2 m above floodplain.
|
||
let levee_elev = {
|
||
let levee_extra = if in_levee_band(voxel_pos, chunk, sub_chunk_seed) {
|
||
// 1–4 m
|
||
((sub_chunk_seed >> 10) & 0x3) as i32 + 1
|
||
} else {
|
||
0
|
||
};
|
||
floodplain_elev + levee_extra
|
||
};
|
||
|
||
// Vegetation from district class; riparian thicket near the levee.
|
||
let base_veg = Vegetation::from_vegetation_class(district.vegetation_class);
|
||
let vegetation = scatter_vegetation(base_veg, sub_chunk_seed);
|
||
(levee_elev, Water::Dry, vegetation)
|
||
};
|
||
|
||
VoxelColumn {
|
||
terrain,
|
||
floor: FloorMaterial::None,
|
||
vegetation,
|
||
water,
|
||
elevation_m,
|
||
cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SeasonalCover derivation (D-239 §3, T-1030)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Cluster size for spatially-coherent freeze scatter (D-239 §3).
|
||
///
|
||
/// Neighbouring voxels within the same CLUSTER_M × CLUSTER_M cell share a
|
||
/// per-cluster scatter offset, producing ragged clustered patches rather than
|
||
/// per-tile salt-and-pepper. The spec calls for ~4–8 m; we use 6 m.
|
||
const CLUSTER_M: i32 = 6;
|
||
|
||
/// Freshwater freeze band upper edge (district mean °C, D-239 §3).
|
||
///
|
||
/// Night-frost can ice-over the coldest/most-exposed freshwater tiles even when
|
||
/// the district mean is still positive (diurnal swing). +5 °C is the onset of
|
||
/// the scatter band.
|
||
const FRESH_BAND_HIGH_C: i32 = 5;
|
||
|
||
/// Freshwater freeze band lower edge (D-239 §3).
|
||
///
|
||
/// Below −10 °C the entire freshwater surface is frozen across the full
|
||
/// day/night cycle. No scatter below this threshold.
|
||
const FRESH_BAND_LOW_C: i32 = -10;
|
||
|
||
/// Salt-water (sea ice) freeze onset temperature (D-239 §3).
|
||
///
|
||
/// Seawater freezes at ≈ −2 °C due to dissolved salt.
|
||
const SALT_ONSET_C: i32 = -2;
|
||
|
||
/// Salt-water freeze band width in integer °C (D-239 §3).
|
||
///
|
||
/// The sea-ice band is lower and wider than the freshwater band. We model it
|
||
/// as [SALT_ONSET_C, SALT_ONSET_C − SALT_BAND_WIDTH]. Below that, permanent
|
||
/// pack ice everywhere (no scatter).
|
||
const SALT_BAND_WIDTH: i32 = 12;
|
||
|
||
/// Snow (land) onset temperature — same scattered band as freshwater (D-239 §3).
|
||
///
|
||
/// On land, cover transitions from bare frozen ground to Snow as temp descends
|
||
/// through this band, but only when moisture_q is above `SNOW_MOISTURE_GATE`.
|
||
const SNOW_BAND_HIGH_C: i32 = 2;
|
||
|
||
/// Snow moisture gate — moisture_q threshold below which cold land stays bare.
|
||
///
|
||
/// Cold + dry → no snow accumulation (D-239 §3 "gated on moisture").
|
||
const SNOW_MOISTURE_GATE: i32 = 30;
|
||
|
||
/// Classify a `MorphologyZone` as salt (ocean/sea) vs fresh water vs land.
|
||
///
|
||
/// D-239 §3: "salt vs fresh: derive from `morphology_zone`".
|
||
/// - OpenOcean / Sea zones → salt water.
|
||
/// - Lake / RiverBank / MeanderReach / Delta / BraidedPlain / Estuarine /
|
||
/// TidalFlat → fresh water.
|
||
/// - All other zones → land (no water-freeze branch).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum SurfaceClass {
|
||
SaltWater,
|
||
FreshWater,
|
||
Land,
|
||
}
|
||
|
||
fn classify_surface(zone: &MorphologyZone, water: Water) -> SurfaceClass {
|
||
match zone {
|
||
// Salt water: open ocean.
|
||
MorphologyZone::OpenOcean => SurfaceClass::SaltWater,
|
||
// Fresh surface water: lakes and all river/wetland zones with standing water.
|
||
// We additionally check Water state — a RiverBank tile that happens to be
|
||
// Dry at this voxel is functionally land from a freeze perspective.
|
||
MorphologyZone::Lake => SurfaceClass::FreshWater,
|
||
MorphologyZone::RiverBank
|
||
| MorphologyZone::MeanderReach
|
||
| MorphologyZone::Delta
|
||
| MorphologyZone::BraidedPlain
|
||
| MorphologyZone::Estuarine
|
||
| MorphologyZone::TidalFlat => {
|
||
if water != Water::Dry {
|
||
SurfaceClass::FreshWater
|
||
} else {
|
||
SurfaceClass::Land
|
||
}
|
||
}
|
||
// Everything else is land (including Fjord walls, DuneStrand, etc.).
|
||
_ => SurfaceClass::Land,
|
||
}
|
||
}
|
||
|
||
/// Per-cluster scatter offset (D-239 §3).
|
||
///
|
||
/// Hash a coarse cluster cell `(cx, cy)` — derived from `(voxel_x, voxel_y)`
|
||
/// by `div_euclid(CLUSTER_M)` — into a body-scoped u64 scatter value via
|
||
/// `SeedChain`. All voxels that share the same cluster cell get the **same**
|
||
/// scatter value (coherent patches). Different world seeds and body ids yield
|
||
/// different freeze patterns.
|
||
///
|
||
/// ## Key correctness properties
|
||
///
|
||
/// - The cluster-cell coordinates may be negative (voxels at negative positions).
|
||
/// We zigzag-encode each signed `cx`/`cy` to a non-negative `u64` before
|
||
/// Cantor-pairing, giving a collision-free bijection for the full signed range.
|
||
/// (The old cast-to-u64 was wrong for negative inputs — huge two's-complement
|
||
/// values broke the "no collision" claim.)
|
||
/// - The seed is derived through `SeedChain::for_body(world_seed, body_id)
|
||
/// .derive(SeedDomain::Cover, cluster_pair_id)` — a body-scoped, domain-
|
||
/// separated derivation, not a raw `splitmix64` on the per-voxel sub_chunk_seed.
|
||
/// This is the root fix for the coherence defect: `sub_chunk_seed` is per-voxel,
|
||
/// so passing it here gave each voxel its own scatter value → salt-and-pepper.
|
||
/// `SeedChain::for_body` is body-scoped and the cluster id is a cluster-cell
|
||
/// property — all voxels in the same cell derive the same seed.
|
||
#[inline]
|
||
fn cluster_scatter(world_seed: u64, body_id: &str, voxel_x: i32, voxel_y: i32) -> u64 {
|
||
use crate::seed::{SeedChain, SeedDomain};
|
||
// Coarse cluster cell index (Euclidean div so negatives map cleanly).
|
||
let cx_i = voxel_x.div_euclid(CLUSTER_M);
|
||
let cy_i = voxel_y.div_euclid(CLUSTER_M);
|
||
// Zigzag encode signed → unsigned (same pattern as voxel_pos_to_id and
|
||
// domain_warp::pos_to_id — bijective over the full i32 range, no collisions
|
||
// between positive and negative cluster coords).
|
||
let zz = |v: i32| -> u64 {
|
||
let v = v as i64;
|
||
((v << 1) ^ (v >> 63)) as u64
|
||
};
|
||
let cx = zz(cx_i);
|
||
let cy = zz(cy_i);
|
||
// Cantor-pairing (now collision-free because both inputs are non-negative u64).
|
||
let s = cx.wrapping_add(cy);
|
||
let cluster_pair_id = s
|
||
.wrapping_mul(s.wrapping_add(1))
|
||
.wrapping_div(2)
|
||
.wrapping_add(cy);
|
||
// Body-scoped, domain-separated derivation via SeedChain (D-224 / D-010).
|
||
// All voxels sharing this cluster cell → same cluster_pair_id → same seed.
|
||
SeedChain::for_body(world_seed, body_id)
|
||
.derive(SeedDomain::Cover, cluster_pair_id)
|
||
.seed()
|
||
}
|
||
|
||
/// Derive the `SeasonalCover` overlay for a single voxel (D-239 §3, T-1030).
|
||
///
|
||
/// ## Rules (D-239 §3 verbatim mapping)
|
||
///
|
||
/// ### Airless body
|
||
/// `temperature_c == None` → `SeasonalCover::None`. No climate branch.
|
||
///
|
||
/// ### Fresh water (lakes / rivers)
|
||
/// Scatter band **+5 °C → −10 °C** (district mean temperature_c as i32).
|
||
/// - Above +5 °C: `None` (open water).
|
||
/// - Within the band [−10, +5]: cluster-scatter decides `Ice` vs `None`.
|
||
/// The scatter is driven by a per-cluster hash; more of the band → more Ice.
|
||
/// - Below −10 °C: `Ice` everywhere (permanent).
|
||
///
|
||
/// ### Salt water (sea ice)
|
||
/// Band onset ≈ −2 °C, width 12 °C (so solid below −14 °C).
|
||
/// - Above −2 °C: `None`.
|
||
/// - Within the band [−14, −2]: cluster-scatter → `Ice` vs `None`.
|
||
/// Pack-ice pattern: larger coherent sheets + occasional open "lead" gaps.
|
||
/// Leads use a separate coarser cluster size (3× CLUSTER_M) to produce
|
||
/// sheet-scale coherence rather than fine-grained scatter.
|
||
/// - Below −14 °C: `Ice` everywhere (permanent pack ice).
|
||
///
|
||
/// ### Land (snow)
|
||
/// Moisture-gated: `moisture_q >= SNOW_MOISTURE_GATE` (30) required.
|
||
/// Band **+2 °C → −10 °C** (same low edge as freshwater).
|
||
/// - Above +2 °C, or dry (moisture_q < 30): `None` (bare frozen ground if cold).
|
||
/// - Within the band [−10, +2] and moist: cluster-scatter → `Snow` vs `None`.
|
||
/// - Below −10 °C and moist: `Snow` everywhere (permanent).
|
||
///
|
||
/// ## Spatial coherence (D-239 §3)
|
||
///
|
||
/// Coherence is implemented by hashing the **coarse cluster cell**
|
||
/// `(voxel_x.div_euclid(CLUSTER_M), voxel_y.div_euclid(CLUSTER_M))` into a
|
||
/// per-cluster scatter offset. All voxels in the same 6 m × 6 m cluster share
|
||
/// the same scatter decision, producing ragged but spatially coherent freeze
|
||
/// patches — "clustered patches, NEVER per-tile dice" (D-239 §3).
|
||
///
|
||
/// ## Q-105 forward contract
|
||
///
|
||
/// This function models the **static mean-state** only. The transient
|
||
/// (clock-bound) cover — dawn frost that burns off by noon, a stream iced at
|
||
/// dawn that is crossable by midday — requires the cheap regional clock state
|
||
/// described in Q-105. When Q-105 is implemented, callers should treat the
|
||
/// `cover` field as the base state and apply the Q-105 modifier on top.
|
||
///
|
||
/// ## D-010 compliance
|
||
///
|
||
/// All decisions are integer. `temperature_c` is cast to `i32` once (positional
|
||
/// quantisation, not a structural float gate). All scatter is hash/integer only.
|
||
pub fn derive_cover(
|
||
world_seed: u64,
|
||
body_id: &str,
|
||
district: &DistrictProfile,
|
||
column: &VoxelColumn,
|
||
voxel_pos: VoxelPos,
|
||
) -> SeasonalCover {
|
||
// ── Airless body (D-239 §2) ───────────────────────────────────────────
|
||
// No atmosphere → no climate branch → no seasonal cover.
|
||
let Some(temp_f) = district.temperature_c else {
|
||
return SeasonalCover::None;
|
||
};
|
||
|
||
// Integer cast: all gating is on i32 (D-010; f32→i32 cast is positional
|
||
// quantisation, not a structural float comparison).
|
||
let temp_i: i32 = temp_f as i32;
|
||
|
||
let (voxel_x, voxel_y) = voxel_pos;
|
||
|
||
// Per-cluster scatter: keyed ONLY on (world, body, cluster-cell) — never
|
||
// on the per-voxel sub_chunk_seed. All voxels in the same 6×6 m cell share
|
||
// this value, producing ragged clustered patches (D-239 §3).
|
||
let scatter = cluster_scatter(world_seed, body_id, voxel_x, voxel_y);
|
||
|
||
let surface = classify_surface(&district.morphology_zone, column.water);
|
||
|
||
match surface {
|
||
// ── Fresh water (lakes / rivers) ──────────────────────────────────
|
||
SurfaceClass::FreshWater => {
|
||
if temp_i >= FRESH_BAND_HIGH_C {
|
||
// Above band — open water.
|
||
SeasonalCover::None
|
||
} else if temp_i < FRESH_BAND_LOW_C {
|
||
// Below band — permanently frozen.
|
||
SeasonalCover::Ice
|
||
} else {
|
||
// Within the scatter band [FRESH_BAND_LOW_C, FRESH_BAND_HIGH_C).
|
||
// Threshold: scatter % band_width < depth_into_band → Ice.
|
||
// depth 1 (just below +5°C) ≈ 1/15 ≈ 7% Ice.
|
||
// depth 14 (just above −10°C) ≈ 14/15 ≈ 93% Ice.
|
||
// Ice-fraction is monotonically increasing as temp drops.
|
||
let band_width = (FRESH_BAND_HIGH_C - FRESH_BAND_LOW_C) as u64; // 15
|
||
let depth_into_band = (FRESH_BAND_HIGH_C - temp_i) as u64; // 1..=14
|
||
if scatter % band_width < depth_into_band {
|
||
SeasonalCover::Ice
|
||
} else {
|
||
SeasonalCover::None
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Salt water (sea ice) ──────────────────────────────────────────
|
||
SurfaceClass::SaltWater => {
|
||
let salt_band_low = SALT_ONSET_C - SALT_BAND_WIDTH; // −14 °C
|
||
if temp_i >= SALT_ONSET_C {
|
||
// Above onset — open ocean.
|
||
SeasonalCover::None
|
||
} else if temp_i < salt_band_low {
|
||
// Below band — permanent pack ice.
|
||
SeasonalCover::Ice
|
||
} else {
|
||
// Within the sea-ice scatter band [−14, −2).
|
||
//
|
||
// Ice-fraction is monotonically increasing as temp drops
|
||
// (same linear model as freshwater: scatter % band_width <
|
||
// depth_into_band). At onset (depth 1) ≈ 8% ice; at depth 11
|
||
// (just above −14°C) ≈ 92% ice.
|
||
//
|
||
// Pack-ice sheets + leads structure: on the cold, mostly-frozen
|
||
// end we apply a 3× coarser cluster (super-cell = 3×CLUSTER_M)
|
||
// to carve occasional open "lead" gaps within what would otherwise
|
||
// be solid ice. A lead forms when the super-cell scatter indicates
|
||
// a gap AND the voxel is in the cold zone where ice would normally
|
||
// be solid. This gives sheet-scale coherence (large contiguous ice
|
||
// panels) with a small fraction of narrow persistent leads.
|
||
let depth_into_band = (SALT_ONSET_C - temp_i) as u64; // 1..=11
|
||
let band_width = SALT_BAND_WIDTH as u64; // 12
|
||
|
||
// Primary ice decision (monotonic, same logic as freshwater).
|
||
let is_ice = scatter % band_width < depth_into_band;
|
||
|
||
if is_ice {
|
||
// In the cold half of the band (depth > band_width/2), apply
|
||
// the pack-ice lead pattern using a coarser super-cell cluster.
|
||
// The lead cluster is keyed on the super-cell index directly,
|
||
// not on pre-quantized coordinates, so the intent is clear.
|
||
let cold_threshold = band_width / 2;
|
||
if depth_into_band > cold_threshold {
|
||
// Super-cell index: one step per 3×CLUSTER_M metres.
|
||
let super_cell_size = CLUSTER_M * 3;
|
||
let scx = voxel_x.div_euclid(super_cell_size);
|
||
let scy = voxel_y.div_euclid(super_cell_size);
|
||
let sheet_scatter = cluster_scatter(
|
||
world_seed,
|
||
body_id,
|
||
scx * super_cell_size,
|
||
scy * super_cell_size,
|
||
);
|
||
// ~8% open leads in the cold zone (persistent gaps in pack ice).
|
||
let is_lead = sheet_scatter % 100 < 8;
|
||
if is_lead {
|
||
SeasonalCover::None
|
||
} else {
|
||
SeasonalCover::Ice
|
||
}
|
||
} else {
|
||
SeasonalCover::Ice
|
||
}
|
||
} else {
|
||
SeasonalCover::None
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Land (snow) ───────────────────────────────────────────────────
|
||
SurfaceClass::Land => {
|
||
// Moisture gate: cold + dry → bare frozen ground (no snow).
|
||
if district.moisture_q < SNOW_MOISTURE_GATE {
|
||
return SeasonalCover::None;
|
||
}
|
||
if temp_i >= SNOW_BAND_HIGH_C {
|
||
// Above snow band — too warm for snow accumulation.
|
||
SeasonalCover::None
|
||
} else if temp_i < FRESH_BAND_LOW_C {
|
||
// Below snow band — permanent snow (same low edge as freshwater).
|
||
SeasonalCover::Snow
|
||
} else {
|
||
// Within the scatter band [FRESH_BAND_LOW_C, SNOW_BAND_HIGH_C).
|
||
// Same monotonic model: ice-fraction increases as temp drops.
|
||
let band_width = (SNOW_BAND_HIGH_C - FRESH_BAND_LOW_C) as u64; // 12
|
||
let depth_into_band = (SNOW_BAND_HIGH_C - temp_i) as u64; // 1..=11
|
||
if scatter % band_width < depth_into_band {
|
||
SeasonalCover::Snow
|
||
} else {
|
||
SeasonalCover::None
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Compute channel state for MeanderReach — higher sinuosity than AlluvialPlain.
|
||
///
|
||
/// Uses amplitude = wavelength/4 (~25% sinuosity) vs AlluvialPlain's /6 (~17%).
|
||
/// This produces a more pronounced meander — a wider swing across the floodplain,
|
||
/// creating more tactical variety (D-239 §9 game-feel).
|
||
fn compute_meander_reach_channel(
|
||
voxel_pos: VoxelPos,
|
||
chunk: &ChunkContext,
|
||
sub_chunk_seed: u64,
|
||
) -> (bool, bool) {
|
||
let (vx, vy) = voxel_pos;
|
||
|
||
let (along, cross) = match chunk.basin_direction {
|
||
crate::atlas::chunk_context::BasinDirection::North
|
||
| crate::atlas::chunk_context::BasinDirection::South => (vy, vx),
|
||
crate::atlas::chunk_context::BasinDirection::East
|
||
| crate::atlas::chunk_context::BasinDirection::West => (vx, vy),
|
||
};
|
||
|
||
let wavelength_m = (chunk.meander_wavelength_m as i32).max(10);
|
||
let phase_offset = chunk.meander_phase as i32;
|
||
let angle_mod = (along + phase_offset).rem_euclid(wavelength_m);
|
||
|
||
// MeanderReach amplitude: 1/4 of wavelength (stronger meander than AlluvialPlain).
|
||
let amplitude = (wavelength_m / 4).max(3); // at least ±3 m amplitude
|
||
let half_wl = wavelength_m / 2;
|
||
let meander_displacement = if angle_mod < half_wl {
|
||
(2 * amplitude * angle_mod) / half_wl.max(1) - amplitude
|
||
} else {
|
||
amplitude - (2 * amplitude * (angle_mod - half_wl)) / half_wl.max(1)
|
||
};
|
||
|
||
// District-anchored centreline (T-1040) — same anchor as AlluvialPlain.
|
||
let perp_distance = (cross - chunk.channel_anchor_m - meander_displacement).abs();
|
||
|
||
// Edge jitter from sub-chunk seed (±1 m), same as AlluvialPlain.
|
||
let edge_jitter = (sub_chunk_seed & 0x3) as i32;
|
||
let half_width = (chunk.channel_width_m / 2).max(2); // MeanderReach channels at least 4 m total
|
||
let channel_edge = half_width + edge_jitter;
|
||
let deep_edge = (half_width / 2).max(1);
|
||
|
||
let in_channel = perp_distance <= channel_edge;
|
||
let is_deep = perp_distance <= deep_edge;
|
||
|
||
(in_channel, is_deep)
|
||
}
|
||
|
||
/// Determine if a voxel is in the levee band adjacent to the MeanderReach channel.
|
||
///
|
||
/// Levees are natural berms 3–8 m wide on each bank, just outside the channel
|
||
/// edge. They are elevated 1–4 m above the floodplain. The levee band is the
|
||
/// zone from channel_edge to channel_edge + levee_width.
|
||
fn in_levee_band(voxel_pos: VoxelPos, chunk: &ChunkContext, sub_chunk_seed: u64) -> bool {
|
||
let (vx, vy) = voxel_pos;
|
||
|
||
let (along, cross) = match chunk.basin_direction {
|
||
crate::atlas::chunk_context::BasinDirection::North
|
||
| crate::atlas::chunk_context::BasinDirection::South => (vy, vx),
|
||
crate::atlas::chunk_context::BasinDirection::East
|
||
| crate::atlas::chunk_context::BasinDirection::West => (vx, vy),
|
||
};
|
||
|
||
let wavelength_m = (chunk.meander_wavelength_m as i32).max(10);
|
||
let phase_offset = chunk.meander_phase as i32;
|
||
let angle_mod = (along + phase_offset).rem_euclid(wavelength_m);
|
||
|
||
let amplitude = (wavelength_m / 4).max(3);
|
||
let half_wl = wavelength_m / 2;
|
||
let meander_displacement = if angle_mod < half_wl {
|
||
(2 * amplitude * angle_mod) / half_wl.max(1) - amplitude
|
||
} else {
|
||
amplitude - (2 * amplitude * (angle_mod - half_wl)) / half_wl.max(1)
|
||
};
|
||
|
||
// District-anchored centreline (T-1040) — same anchor as the channel itself.
|
||
let perp_distance = (cross - chunk.channel_anchor_m - meander_displacement).abs();
|
||
|
||
let edge_jitter = (sub_chunk_seed & 0x3) as i32;
|
||
let half_width = (chunk.channel_width_m / 2).max(2);
|
||
let channel_edge = half_width + edge_jitter;
|
||
let levee_width = 4 + ((sub_chunk_seed >> 6) & 0x3) as i32; // 4–7 m levee band
|
||
|
||
// In levee band: just outside channel, within levee_width metres of channel edge.
|
||
perp_distance > channel_edge && perp_distance <= channel_edge + levee_width
|
||
}
|
||
|
||
/// Determine if a voxel is in the meander channel, and if it is deep.
|
||
///
|
||
/// The channel is a sine-wave path oriented along `chunk.basin_direction`.
|
||
/// We compute the perpendicular distance (integer, D-010) from the voxel to
|
||
/// the wave centreline. Within half the channel width → in-channel; within
|
||
/// one-third of the channel width → deep water.
|
||
///
|
||
/// ## Integer arithmetic (D-010)
|
||
///
|
||
/// We use a 10× fixed-point approximation for the sine: `sin(θ) ≈ phase_term / 10`
|
||
/// where `phase_term` is derived from the meander phase and the voxel's
|
||
/// along-channel coordinate. This avoids any float comparison while producing
|
||
/// a spatially coherent (though imprecise) meander curve.
|
||
fn compute_channel_state(
|
||
voxel_pos: VoxelPos,
|
||
chunk: &ChunkContext,
|
||
sub_chunk_seed: u64,
|
||
) -> (bool, bool) {
|
||
let (vx, vy) = voxel_pos;
|
||
|
||
// Along-channel (parallel) and cross-channel (perpendicular) coordinate.
|
||
// basin_direction determines the primary axis.
|
||
let (along, cross) = match chunk.basin_direction {
|
||
crate::atlas::chunk_context::BasinDirection::North
|
||
| crate::atlas::chunk_context::BasinDirection::South => (vy, vx),
|
||
crate::atlas::chunk_context::BasinDirection::East
|
||
| crate::atlas::chunk_context::BasinDirection::West => (vx, vy),
|
||
};
|
||
|
||
// Meander wavelength in metres (integer approximation: truncate f64 → i32).
|
||
// The f64 wavelength is position math (D-239 §4), not a gate comparison —
|
||
// we truncate to integer here before any structural decision.
|
||
let wavelength_m = (chunk.meander_wavelength_m as i32).max(10);
|
||
|
||
// Phase angle in [0, wavelength_m). Deterministic integer modulo.
|
||
let phase_offset = chunk.meander_phase as i32;
|
||
let angle_mod = (along + phase_offset).rem_euclid(wavelength_m);
|
||
|
||
// Integer sine approximation: map angle_mod to a cross-channel displacement
|
||
// in [−amplitude, +amplitude]. We use a triangle wave (cheaper than a
|
||
// true sine table but still spatially coherent for a walking skeleton).
|
||
//
|
||
// Triangle wave on [0, wavelength_m]:
|
||
// - [0, wavelength_m/2]: displacement = 2*amplitude * angle / wavelength_m − amplitude
|
||
// - [wavelength_m/2, wavelength_m]: displacement = amplitude − 2*amplitude * (angle − wl/2) / wl
|
||
//
|
||
// Amplitude: ~15% of wavelength for a moderate sinuosity.
|
||
let amplitude = wavelength_m / 6; // ~17% sinuosity
|
||
let amplitude = amplitude.max(2); // at least ±2 m amplitude
|
||
let half_wl = wavelength_m / 2;
|
||
let meander_displacement = if angle_mod < half_wl {
|
||
// Rising half: ramps from -amplitude to +amplitude.
|
||
(2 * amplitude * angle_mod) / half_wl - amplitude
|
||
} else {
|
||
// Falling half: ramps from +amplitude to -amplitude.
|
||
amplitude - (2 * amplitude * (angle_mod - half_wl)) / half_wl
|
||
};
|
||
|
||
// Perpendicular distance from voxel to the district-anchored channel
|
||
// centreline (T-1040): the channel axis is `chunk.channel_anchor_m` on the
|
||
// cross axis — a district-scale world coordinate, never the world origin.
|
||
let perp_distance = (cross - chunk.channel_anchor_m - meander_displacement).abs();
|
||
|
||
// Sub-chunk noise: ±1 m jitter on the channel edge (wavelength < 64 m).
|
||
// Use low bits of seed for integer jitter — D-010.
|
||
let edge_jitter = (sub_chunk_seed & 0x3) as i32; // [0, 3]
|
||
let half_width = (chunk.channel_width_m / 2).max(1);
|
||
let channel_edge = half_width + edge_jitter;
|
||
let deep_edge = (half_width / 2).max(1);
|
||
|
||
let in_channel = perp_distance <= channel_edge;
|
||
let is_deep = perp_distance <= deep_edge;
|
||
|
||
(in_channel, is_deep)
|
||
}
|
||
|
||
/// Apply sub-chunk vegetation scatter using the sub-chunk seed.
|
||
///
|
||
/// The base class from the district establishes the dominant cover type; the
|
||
/// scatter adds variety within the class (e.g. some Grass in a Forest zone
|
||
/// at clearings). Wavelength < 64 m — seeded from sub_chunk_seed (D-239 §10).
|
||
///
|
||
/// All integer arithmetic (D-010).
|
||
fn scatter_vegetation(base: Vegetation, sub_chunk_seed: u64) -> Vegetation {
|
||
// Use bits [4:7] of the seed (4-bit mask, range [0, 15]) for scatter —
|
||
// independent from the channel
|
||
// computation which uses bits [0:3].
|
||
let scatter_bits = ((sub_chunk_seed >> 4) & 0xF) as u8;
|
||
|
||
match base {
|
||
Vegetation::Forest => {
|
||
// 15% Grass clearings, 15% Scrub margin, 70% Forest.
|
||
if scatter_bits < 3 {
|
||
Vegetation::Grass
|
||
} else if scatter_bits < 6 {
|
||
Vegetation::Scrub
|
||
} else {
|
||
Vegetation::Forest
|
||
}
|
||
}
|
||
Vegetation::Scrub => {
|
||
// 20% Grass, 80% Scrub.
|
||
if scatter_bits < 4 {
|
||
Vegetation::Grass
|
||
} else {
|
||
Vegetation::Scrub
|
||
}
|
||
}
|
||
Vegetation::Thicket => {
|
||
// Riparian thicket: mostly Thicket with some Forest.
|
||
if scatter_bits < 5 {
|
||
Vegetation::Thicket
|
||
} else {
|
||
Vegetation::Forest
|
||
}
|
||
}
|
||
// Barren, Grass, Crop, Cleared: no scatter.
|
||
other => other,
|
||
}
|
||
}
|
||
|
||
/// Fold a `VoxelPos` into a u64 id for seed derivation.
|
||
///
|
||
/// Same bijective approach as `chunk_context::pos_to_id` and
|
||
/// `domain_warp::pos_to_id` — zigzag encode + Cantor pairing. Integer-only (D-010).
|
||
#[inline]
|
||
fn voxel_pos_to_id(pos: VoxelPos) -> u64 {
|
||
let zz = |v: i32| -> u64 {
|
||
let v = v as i64;
|
||
((v << 1) ^ (v >> 63)) as u64
|
||
};
|
||
let x = zz(pos.0);
|
||
let y = zz(pos.1);
|
||
let s = x.wrapping_add(y);
|
||
s.wrapping_mul(s.wrapping_add(1))
|
||
.wrapping_div(2)
|
||
.wrapping_add(y)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// VoxelCache — LRU-style on-demand cache (D-227)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Cache key: `(body_id, voxel_pos_after_warp)`.
|
||
///
|
||
/// The body_id is stored as a u64 hash (FNV-1a) to keep the key `Copy`
|
||
/// and `Ord`-able (D-010: `BTreeMap` key).
|
||
/// `(world_seed, body_id_hash, post-warp voxel address)`. world_seed is part of
|
||
/// the voxel's identity — a cache must never return another world's column.
|
||
type CacheKey = (u64, u64, VoxelPos);
|
||
|
||
/// One cache entry: the derived column plus an access generation counter.
|
||
struct CacheEntry {
|
||
column: VoxelColumn,
|
||
/// Monotonically increasing access counter — the LRU eviction key.
|
||
access_gen: u64,
|
||
}
|
||
|
||
/// Bounded LRU-style cache for derived `VoxelColumn`s (D-227).
|
||
///
|
||
/// ## Invariants
|
||
///
|
||
/// - At most `capacity` entries are stored at any time.
|
||
/// - Entries are evicted in LRU order (lowest `access_gen`).
|
||
/// - Nothing is persisted to disk; eviction forces re-derivation.
|
||
///
|
||
/// ## Caller precondition — chunk context must cover the tile (T-1042)
|
||
///
|
||
/// After T-1042, `derive_voxel_column` reads `chunk.blend_weight` and
|
||
/// `chunk.secondary` to apply cross-district terrain blending. The cache
|
||
/// key is `(seed, body_id_hash, post-warp voxel position)` — it does NOT
|
||
/// capture the blend parameters. The cache invariant therefore holds only
|
||
/// under the following precondition:
|
||
///
|
||
/// **The `chunk` argument passed to `get_or_derive` must be the
|
||
/// `ChunkContext` derived for the chunk that actually contains the tile.**
|
||
/// Concretely: `chunk = derive_chunk_context(seed, body_id, district,
|
||
/// tile_pos.div_euclid(CHUNK_M), secondary)`. A boundary tile derived with
|
||
/// a blended context (blend_weight < 255) and later re-requested with a
|
||
/// pure-primary context (blend_weight = 255) would produce a different
|
||
/// column — the cache would return the first (blended) value, silently
|
||
/// giving the wrong result. The caller must ensure the same context
|
||
/// construction is used on every call for the same tile.
|
||
///
|
||
/// **In practice this is satisfied by construction**: callers derive one
|
||
/// `ChunkContext` per chunk and call `get_or_derive` only for tiles within
|
||
/// that chunk. The precondition is made explicit here so Phase-5 render-loop
|
||
/// callers do not inadvertently mix contexts across calls.
|
||
///
|
||
/// ## D-010 compliance
|
||
///
|
||
/// Uses `BTreeMap` (ordered) for the cache store. Iteration order is
|
||
/// deterministic; eviction scans for the minimum `access_gen`.
|
||
pub struct VoxelCache {
|
||
capacity: usize,
|
||
entries: BTreeMap<CacheKey, CacheEntry>,
|
||
gen: u64,
|
||
}
|
||
|
||
impl VoxelCache {
|
||
/// Create a new cache with the given capacity.
|
||
///
|
||
/// The default capacity in production is [`DEFAULT_VOXEL_CACHE_CAPACITY`].
|
||
pub fn new(capacity: usize) -> Self {
|
||
Self {
|
||
capacity: capacity.max(1),
|
||
entries: BTreeMap::new(),
|
||
gen: 0,
|
||
}
|
||
}
|
||
|
||
/// Look up or derive the `VoxelColumn` for the given position.
|
||
///
|
||
/// Cache hit: returns a clone of the stored column, bumps its access
|
||
/// generation (LRU recency).
|
||
/// Cache miss: derives the column, inserts it (evicting LRU if full),
|
||
/// returns a clone.
|
||
///
|
||
/// The body-id hash is computed internally via the canonical
|
||
/// [`crate::seed::fnv1a_64`] — callers pass the raw `body_id`.
|
||
///
|
||
/// ## Precondition — context must cover the tile (T-1042)
|
||
///
|
||
/// `chunk` must be the `ChunkContext` derived for the chunk that
|
||
/// contains `(tile_x, tile_y)`. The cache key does not capture
|
||
/// `chunk.blend_weight` or `chunk.secondary`; if the same tile is
|
||
/// requested with a different context (e.g. once blended, once
|
||
/// unblended) the cache will return the first-derived column for both
|
||
/// calls. See the struct-level doc for the full constraint.
|
||
pub fn get_or_derive(
|
||
&mut self,
|
||
world_seed: u64,
|
||
body_id: &str,
|
||
district: &DistrictProfile,
|
||
chunk: &ChunkContext,
|
||
tile_x: i32,
|
||
tile_y: i32,
|
||
) -> VoxelColumn {
|
||
// Apply domain warp to get the canonical voxel address (same as in
|
||
// derive_voxel_column — the cache key is the POST-warp voxel address).
|
||
let (dx, dy) = domain_warp(world_seed, body_id, (tile_x, tile_y));
|
||
let voxel_x = (tile_x as f64 + dx) as i32;
|
||
let voxel_y = (tile_y as f64 + dy) as i32;
|
||
let key: CacheKey = (
|
||
world_seed,
|
||
crate::seed::fnv1a_64(body_id),
|
||
(voxel_x, voxel_y),
|
||
);
|
||
|
||
self.gen += 1;
|
||
let current_gen = self.gen;
|
||
|
||
if let Some(entry) = self.entries.get_mut(&key) {
|
||
entry.access_gen = current_gen;
|
||
return entry.column.clone();
|
||
}
|
||
|
||
// Cache miss — derive and insert.
|
||
let column = derive_voxel_column(world_seed, body_id, district, chunk, tile_x, tile_y);
|
||
|
||
// Evict LRU if at capacity.
|
||
if self.entries.len() >= self.capacity {
|
||
self.evict_lru();
|
||
}
|
||
|
||
self.entries.insert(
|
||
key,
|
||
CacheEntry {
|
||
column: column.clone(),
|
||
access_gen: current_gen,
|
||
},
|
||
);
|
||
|
||
column
|
||
}
|
||
|
||
/// Number of entries currently in the cache.
|
||
#[cfg(test)]
|
||
pub fn len(&self) -> usize {
|
||
self.entries.len()
|
||
}
|
||
|
||
/// Whether the cache holds no entries.
|
||
#[cfg(test)]
|
||
pub fn is_empty(&self) -> bool {
|
||
self.entries.is_empty()
|
||
}
|
||
|
||
/// Test helper: is the voxel for this `(world_seed, body_id, tile)` currently
|
||
/// cached? Recomputes the canonical post-warp key without mutating LRU state,
|
||
/// so tests can assert exactly which entry survived or was evicted.
|
||
#[cfg(test)]
|
||
pub fn contains_voxel(&self, world_seed: u64, body_id: &str, tile_x: i32, tile_y: i32) -> bool {
|
||
let (dx, dy) = domain_warp(world_seed, body_id, (tile_x, tile_y));
|
||
let voxel_x = (tile_x as f64 + dx) as i32;
|
||
let voxel_y = (tile_y as f64 + dy) as i32;
|
||
let key: CacheKey = (
|
||
world_seed,
|
||
crate::seed::fnv1a_64(body_id),
|
||
(voxel_x, voxel_y),
|
||
);
|
||
self.entries.contains_key(&key)
|
||
}
|
||
|
||
/// Evict the entry with the lowest `access_gen` (the LRU entry).
|
||
fn evict_lru(&mut self) {
|
||
if self.entries.is_empty() {
|
||
return;
|
||
}
|
||
// Scan for the minimum access_gen. BTreeMap iteration is ordered by
|
||
// key (deterministic, D-010), so a tie is broken by key order.
|
||
let lru_key = self
|
||
.entries
|
||
.iter()
|
||
.min_by_key(|(_, v)| v.access_gen)
|
||
.map(|(k, _)| *k)
|
||
.expect("entries is non-empty");
|
||
self.entries.remove(&lru_key);
|
||
}
|
||
}
|
||
|
||
/// Default voxel cache capacity for production use.
|
||
///
|
||
/// 64×64 = 4096 voxels per chunk; with a 4-chunk radius lookahead (common
|
||
/// streaming pattern) that is ~65k voxels in flight. 8k entries keeps the
|
||
/// most-recently-used chunk and a partial second chunk warm without blowing
|
||
/// the memory budget (~200 bytes/entry × 8192 = ~1.6 MB).
|
||
pub const DEFAULT_VOXEL_CACHE_CAPACITY: usize = 8192;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::atlas::chunk_context::{derive_chunk_context, BasinDirection};
|
||
use crate::atlas::district_profile::{
|
||
GlaciationGrade, PrecipitationClass, TectonicClass, VegetationClass,
|
||
};
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Test fixtures
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// The ChunkContext of the district-(0,0) chunk whose cross-range contains
|
||
/// the channel anchor — guaranteed inside the T-1040 channel band.
|
||
/// Feature placement is district-anchored, so tests sample around
|
||
/// `chunk.channel_anchor_m` instead of assuming chunk-frame positions.
|
||
fn anchor_chunk(seed: u64, body: &str, district: &DistrictProfile) -> ChunkContext {
|
||
let probe = derive_chunk_context(seed, body, district, (0, 0), None);
|
||
let idx = probe.channel_anchor_m.div_euclid(64);
|
||
let pos = match probe.basin_direction {
|
||
BasinDirection::North | BasinDirection::South => (idx, 0),
|
||
BasinDirection::East | BasinDirection::West => (0, idx),
|
||
};
|
||
derive_chunk_context(seed, body, district, pos, None)
|
||
}
|
||
|
||
/// Map a (cross, along) coordinate pair to (tile_x, tile_y) for the
|
||
/// chunk's basin axes — cross ⊥ basin_direction, along ∥ basin_direction.
|
||
fn cross_along_to_tile(chunk: &ChunkContext, cross: i32, along: i32) -> (i32, i32) {
|
||
match chunk.basin_direction {
|
||
BasinDirection::North | BasinDirection::South => (cross, along),
|
||
BasinDirection::East | BasinDirection::West => (along, cross),
|
||
}
|
||
}
|
||
|
||
fn alluvial_district() -> DistrictProfile {
|
||
DistrictProfile {
|
||
morphology_zone: MorphologyZone::AlluvialPlain,
|
||
tectonic_class: TectonicClass::Stable,
|
||
glaciation_grade: GlaciationGrade::None,
|
||
precipitation_class: PrecipitationClass::Temperate,
|
||
slope_q: 5,
|
||
elev_q: 20,
|
||
ocean_fraction_q: 15,
|
||
river_threshold: 200,
|
||
temperature_c: Some(18.0),
|
||
moisture_q: 55,
|
||
vegetation_class: VegetationClass::Forest,
|
||
basin_direction: BasinDirection::South,
|
||
}
|
||
}
|
||
|
||
fn alluvial_chunk(district: &DistrictProfile) -> ChunkContext {
|
||
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);
|
||
}
|
||
|
||
// ── Voxel-tier mid-scale relief (T-1081) ─────────────────────────────────
|
||
|
||
#[test]
|
||
fn voxel_relief_gives_rugged_land_navigable_hills() {
|
||
// T-1081: a rugged land district must produce mid-scale relief across a ~2 km
|
||
// span far exceeding the ±4 m micro-scatter (the "flat 0–3 m, no hills" symptom).
|
||
let mut district = alluvial_district();
|
||
district.slope_q = 60; // rugged → relief headroom
|
||
let chunk = alluvial_chunk(&district);
|
||
let mut elevs = Vec::new();
|
||
for i in 0..12 {
|
||
let p = i * 256; // step across the voxel-relief band (0.25–2 km)
|
||
let col = derive_voxel_column(42, "GJ1c", &district, &chunk, p, p);
|
||
if col.water == Water::Dry {
|
||
elevs.push(col.elevation_m);
|
||
}
|
||
}
|
||
assert!(elevs.len() >= 4, "expected mostly dry-land samples");
|
||
let min = *elevs.iter().min().unwrap();
|
||
let max = *elevs.iter().max().unwrap();
|
||
assert!(
|
||
max - min > 20,
|
||
"rugged district should have navigable mid-scale relief, got {min}..{max} m"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn voxel_relief_respects_low_flat_envelope() {
|
||
// No slope AND no elevation → no terrain signal → no invented relief (the
|
||
// envelope rule: don't sprout hills on a flat sea-level plain). Elevation stays
|
||
// the family base (elev_q/2 = 0 m) plus only the ±4 m micro-scatter / channel cut.
|
||
let mut district = alluvial_district();
|
||
district.slope_q = 0;
|
||
district.elev_q = 0;
|
||
let chunk = alluvial_chunk(&district);
|
||
for i in 0..12 {
|
||
let p = i * 256;
|
||
let col = derive_voxel_column(42, "GJ1c", &district, &chunk, p, p);
|
||
assert!(
|
||
col.elevation_m <= 4,
|
||
"low-flat district gained mid-scale relief: {} m",
|
||
col.elevation_m
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn voxel_relief_from_elevation_even_with_zero_slope() {
|
||
// The T-1081 root cause: the coarse heightmap gives slope_q ≈ 0 even on high
|
||
// terrain, so ELEVATION must drive the relief envelope. A high district with
|
||
// slope_q == 0 must still gain navigable hills (else high ground reads flat).
|
||
let mut district = alluvial_district();
|
||
district.slope_q = 0;
|
||
district.elev_q = 90; // high ground, coarse-heightmap-flat
|
||
let chunk = alluvial_chunk(&district);
|
||
let mut elevs = Vec::new();
|
||
for i in 0..12 {
|
||
let p = i * 256;
|
||
let col = derive_voxel_column(42, "GJ1c", &district, &chunk, p, p);
|
||
if col.water == Water::Dry {
|
||
elevs.push(col.elevation_m);
|
||
}
|
||
}
|
||
let range = elevs.iter().max().unwrap() - elevs.iter().min().unwrap();
|
||
assert!(
|
||
range > 20,
|
||
"high terrain must gain relief despite slope_q=0, got range {range} m"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn voxel_relief_is_deterministic() {
|
||
let mut district = alluvial_district();
|
||
district.slope_q = 50;
|
||
let chunk = alluvial_chunk(&district);
|
||
let a = derive_voxel_column(42, "GJ1c", &district, &chunk, 777, 1234);
|
||
let b = derive_voxel_column(42, "GJ1c", &district, &chunk, 777, 1234);
|
||
assert_eq!(a.elevation_m, b.elevation_m);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// TerrainMaterial discriminant pins
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn terrain_material_discriminants_pinned() {
|
||
// Append-only invariant (D-010).
|
||
assert_eq!(TerrainMaterial::Soil as u8, 0);
|
||
assert_eq!(TerrainMaterial::Sand as u8, 1);
|
||
assert_eq!(TerrainMaterial::Gravel as u8, 2);
|
||
assert_eq!(TerrainMaterial::Rock as u8, 3);
|
||
assert_eq!(TerrainMaterial::Wetland as u8, 4);
|
||
assert_eq!(TerrainMaterial::Lava as u8, 5);
|
||
}
|
||
|
||
#[test]
|
||
fn water_discriminants_pinned() {
|
||
assert_eq!(Water::Dry as u8, 0);
|
||
assert_eq!(Water::Shallow as u8, 1);
|
||
assert_eq!(Water::Deep as u8, 2);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// AlluvialPlain properties
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn alluvial_plain_produces_soil_terrain() {
|
||
let district = alluvial_district();
|
||
let chunk = alluvial_chunk(&district);
|
||
// Sample a voxel that shouldn't be in a wetland zone.
|
||
let col = derive_voxel_column(42, "GJ1c", &district, &chunk, 100, 100);
|
||
assert_eq!(
|
||
col.terrain,
|
||
TerrainMaterial::Soil,
|
||
"AlluvialPlain must produce Soil terrain"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn alluvial_plain_floor_is_none() {
|
||
let district = alluvial_district();
|
||
let chunk = alluvial_chunk(&district);
|
||
let col = derive_voxel_column(42, "GJ1c", &district, &chunk, 100, 100);
|
||
assert_eq!(col.floor, FloorMaterial::None, "Phase 4 floor must be None");
|
||
}
|
||
|
||
#[test]
|
||
fn alluvial_plain_vegetation_from_district_class() {
|
||
let district = alluvial_district(); // vegetation_class = Forest
|
||
let chunk = alluvial_chunk(&district);
|
||
// Multiple samples to account for sub-chunk scatter; all should be
|
||
// in the Forest/Scrub/Grass range (D-239 §8: no-skip law — no Barren in Forest zone).
|
||
for tx in [100, 101, 102, 103, 104] {
|
||
let col = derive_voxel_column(42, "GJ1c", &district, &chunk, tx, 100);
|
||
assert!(
|
||
matches!(
|
||
col.vegetation,
|
||
Vegetation::Forest | Vegetation::Scrub | Vegetation::Grass
|
||
),
|
||
"Forest district must produce Forest/Scrub/Grass vegetation, got {:?}",
|
||
col.vegetation
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn elevation_is_non_negative() {
|
||
let district = alluvial_district();
|
||
let chunk = alluvial_chunk(&district);
|
||
for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] {
|
||
let col = derive_voxel_column(42, "GJ1c", &district, &chunk, tx, ty);
|
||
assert!(
|
||
col.elevation_m >= 0,
|
||
"elevation must be >= 0, got {}",
|
||
col.elevation_m
|
||
);
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Determinism — the core T-1028 contract
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// End-to-end determinism: same (seed, body, district) → same voxel columns.
|
||
/// Two calls at the same position must produce identical results.
|
||
#[test]
|
||
fn voxel_derivation_is_deterministic() {
|
||
let district = alluvial_district();
|
||
let chunk = alluvial_chunk(&district);
|
||
|
||
let positions = [(100, 100), (0, 0), (-50, 25), (200, -10), (64, 64)];
|
||
for (tx, ty) in positions {
|
||
let a = derive_voxel_column(42, "GJ1c", &district, &chunk, tx, ty);
|
||
let b = derive_voxel_column(42, "GJ1c", &district, &chunk, tx, ty);
|
||
assert_eq!(
|
||
a, b,
|
||
"voxel at ({tx},{ty}) must be identical on repeated derivation"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Different positions must (in general) produce different columns.
|
||
#[test]
|
||
fn different_positions_produce_different_outputs() {
|
||
let district = alluvial_district();
|
||
let chunk = alluvial_chunk(&district);
|
||
let a = derive_voxel_column(42, "GJ1c", &district, &chunk, 100, 100);
|
||
let b = derive_voxel_column(42, "GJ1c", &district, &chunk, 1000, 2000);
|
||
// Not strictly guaranteed (hash collision possible) but overwhelmingly likely.
|
||
assert!(
|
||
a != b || a.elevation_m != b.elevation_m,
|
||
"positions 100m apart should produce different columns"
|
||
);
|
||
}
|
||
|
||
/// Different world seeds produce different results.
|
||
#[test]
|
||
fn different_seeds_produce_different_outputs() {
|
||
let district = alluvial_district();
|
||
let chunk1 = derive_chunk_context(1, "GJ1c", &district, (10, 20), None);
|
||
let chunk2 = derive_chunk_context(2, "GJ1c", &district, (10, 20), None);
|
||
let a = derive_voxel_column(1, "GJ1c", &district, &chunk1, 100, 100);
|
||
let b = derive_voxel_column(2, "GJ1c", &district, &chunk2, 100, 100);
|
||
assert!(
|
||
a != b,
|
||
"different world seeds must produce different voxel outputs"
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Cache behaviour (D-227)
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn cache_returns_same_column_as_direct_derivation() {
|
||
let district = alluvial_district();
|
||
let chunk = alluvial_chunk(&district);
|
||
let body_id = "GJ1c";
|
||
|
||
let mut cache = VoxelCache::new(64);
|
||
let cached = cache.get_or_derive(42, body_id, &district, &chunk, 100, 100);
|
||
let direct = derive_voxel_column(42, body_id, &district, &chunk, 100, 100);
|
||
assert_eq!(
|
||
cached, direct,
|
||
"cache must return same result as direct derivation"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cache_second_call_is_a_hit() {
|
||
let district = alluvial_district();
|
||
let chunk = alluvial_chunk(&district);
|
||
let body_id = "GJ1c";
|
||
|
||
let mut cache = VoxelCache::new(64);
|
||
let first = cache.get_or_derive(42, body_id, &district, &chunk, 100, 100);
|
||
assert_eq!(cache.len(), 1, "first call must insert one entry");
|
||
let second = cache.get_or_derive(42, body_id, &district, &chunk, 100, 100);
|
||
assert_eq!(
|
||
cache.len(),
|
||
1,
|
||
"second call must be a cache hit, not a new insertion"
|
||
);
|
||
assert_eq!(first, second, "cache hit must return same column");
|
||
}
|
||
|
||
#[test]
|
||
fn cache_evicts_lru_on_overflow() {
|
||
let district = alluvial_district();
|
||
let chunk = alluvial_chunk(&district);
|
||
let body_id = "GJ1c";
|
||
|
||
let capacity = 8;
|
||
let mut cache = VoxelCache::new(capacity);
|
||
|
||
// Fill cache to capacity with distinct positions.
|
||
for i in 0..capacity as i32 {
|
||
cache.get_or_derive(42, body_id, &district, &chunk, i * 64, 0);
|
||
}
|
||
assert_eq!(cache.len(), capacity);
|
||
|
||
// Access position 0 to refresh it (not the LRU).
|
||
cache.get_or_derive(42, body_id, &district, &chunk, 0, 0);
|
||
|
||
// Insert a new entry — should evict the LRU (position 64, not the
|
||
// just-refreshed position 0).
|
||
cache.get_or_derive(42, body_id, &district, &chunk, 999, 999);
|
||
assert_eq!(
|
||
cache.len(),
|
||
capacity,
|
||
"cache size must stay at capacity after eviction"
|
||
);
|
||
// Verify the RIGHT entry was evicted, not just that some eviction happened.
|
||
assert!(
|
||
cache.contains_voxel(42, body_id, 0, 0),
|
||
"refreshed entry (pos 0) must survive eviction"
|
||
);
|
||
assert!(
|
||
!cache.contains_voxel(42, body_id, 64, 0),
|
||
"LRU entry (pos 64) must have been the one evicted"
|
||
);
|
||
assert!(
|
||
cache.contains_voxel(42, body_id, 999, 999),
|
||
"newly inserted entry must be present"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cache_eviction_does_not_break_derivation() {
|
||
// After eviction, re-deriving the evicted entry must produce the same result.
|
||
let district = alluvial_district();
|
||
let chunk = alluvial_chunk(&district);
|
||
let body_id = "GJ1c";
|
||
|
||
let capacity = 2;
|
||
let mut cache = VoxelCache::new(capacity);
|
||
|
||
// Populate the cache fully.
|
||
let col0 = cache.get_or_derive(42, body_id, &district, &chunk, 0, 0);
|
||
let _col1 = cache.get_or_derive(42, body_id, &district, &chunk, 100, 0);
|
||
|
||
// Insert a third entry — evicts (0,0) which is the LRU.
|
||
let _col2 = cache.get_or_derive(42, body_id, &district, &chunk, 200, 0);
|
||
|
||
// Re-derive position (0,0) — cache miss, must re-derive correctly.
|
||
let col0_again = cache.get_or_derive(42, body_id, &district, &chunk, 0, 0);
|
||
assert_eq!(
|
||
col0, col0_again,
|
||
"re-derived column after cache eviction must be identical"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cache_boundary_tile_blend_is_stable() {
|
||
// T-1042 / H3: VoxelCache.get_or_derive must return identical blended
|
||
// output on two successive calls for the same boundary tile. This test
|
||
// exercises the `Cow::Owned` path (blend_weight=128, secondary=Some(...))
|
||
// through the cache and guards against future cache-key drift where a
|
||
// second call with a different context returns a stale unblended entry.
|
||
//
|
||
// Precondition (VoxelCache struct doc, T-1042): both calls use the SAME
|
||
// ChunkContext — the one derived for the chunk containing the tile. This
|
||
// is the correct call pattern; the test intentionally validates it.
|
||
use crate::atlas::chunk_context::district_boundary_blend_weight;
|
||
|
||
let district_primary = DistrictProfile {
|
||
morphology_zone: MorphologyZone::AlluvialPlain,
|
||
tectonic_class: crate::atlas::district_profile::TectonicClass::Stable,
|
||
glaciation_grade: crate::atlas::district_profile::GlaciationGrade::None,
|
||
precipitation_class: crate::atlas::district_profile::PrecipitationClass::Temperate,
|
||
slope_q: 5,
|
||
elev_q: 20, // low elevation
|
||
ocean_fraction_q: 0, // no channel — simpler tile layout for elevation check
|
||
river_threshold: 200,
|
||
temperature_c: Some(18.0),
|
||
moisture_q: 55,
|
||
vegetation_class: VegetationClass::Forest,
|
||
basin_direction: BasinDirection::South,
|
||
};
|
||
let district_secondary = DistrictProfile {
|
||
morphology_zone: MorphologyZone::AlluvialPlain,
|
||
tectonic_class: crate::atlas::district_profile::TectonicClass::Stable,
|
||
glaciation_grade: crate::atlas::district_profile::GlaciationGrade::None,
|
||
precipitation_class: crate::atlas::district_profile::PrecipitationClass::Temperate,
|
||
slope_q: 5,
|
||
elev_q: 70, // high elevation — makes the blend measurable
|
||
ocean_fraction_q: 0,
|
||
river_threshold: 200,
|
||
temperature_c: Some(18.0),
|
||
moisture_q: 55,
|
||
vegetation_class: VegetationClass::Forest,
|
||
basin_direction: BasinDirection::South,
|
||
};
|
||
|
||
let (seed, body_id) = (42u64, "blend_cache_test");
|
||
// Chunk (31, 0): the last chunk in district (0, 0) — at the district boundary.
|
||
let boundary_chunk_pos = (31i32, 0i32);
|
||
let (near, blend_w) = district_boundary_blend_weight(boundary_chunk_pos);
|
||
assert!(near, "chunk (31, 0) must be detected as a boundary chunk");
|
||
assert_eq!(blend_w, 128);
|
||
|
||
let chunk = derive_chunk_context(
|
||
seed,
|
||
body_id,
|
||
&district_primary,
|
||
boundary_chunk_pos,
|
||
Some((&district_secondary, blend_w)),
|
||
);
|
||
|
||
// A tile inside the boundary chunk — world position = chunk_x * 64 + offset.
|
||
let tile_x = boundary_chunk_pos.0 * 64 + 16;
|
||
let tile_y = boundary_chunk_pos.1 * 64 + 16;
|
||
|
||
let mut cache = VoxelCache::new(64);
|
||
|
||
// First call: cache miss → derives the blended column and inserts it.
|
||
let col1 = cache.get_or_derive(seed, body_id, &district_primary, &chunk, tile_x, tile_y);
|
||
assert_eq!(cache.len(), 1, "first call must produce one cache entry");
|
||
|
||
// Second call: cache hit → must return the same blended column.
|
||
let col2 = cache.get_or_derive(seed, body_id, &district_primary, &chunk, tile_x, tile_y);
|
||
assert_eq!(
|
||
cache.len(),
|
||
1,
|
||
"second call must be a cache hit (len unchanged)"
|
||
);
|
||
|
||
assert_eq!(
|
||
col1.elevation_m, col2.elevation_m,
|
||
"cache hit must return identical blended elevation for boundary tile"
|
||
);
|
||
assert_eq!(
|
||
col1.terrain, col2.terrain,
|
||
"cache hit must return identical terrain for boundary tile"
|
||
);
|
||
|
||
// Sanity: the blended elevation must sit between the two district values.
|
||
// primary elev_q=20 → base_elev_m=10; secondary elev_q=70 → base_elev_m=35.
|
||
// At 50-50 blend the blended elev_q = (20*128 + 70*127 + 127)/255 = 44 (rounded).
|
||
// base_elev_m = 44/2 = 22. With micro-relief ±4 m, range is [18, 25] m.
|
||
// This asserts the blend actually ran (not the pure-primary 10 m result).
|
||
assert!(
|
||
col1.elevation_m > 15,
|
||
"blended boundary tile elevation ({}) must be above pure-primary level (~10 m) \
|
||
— blend did not apply",
|
||
col1.elevation_m
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Domain warp sanity
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn domain_warp_is_applied_before_derivation() {
|
||
// The warp should produce different columns at nearby integer tile
|
||
// coordinates — the warp displaces the lookup point, so adjacent tiles
|
||
// should not always look identical even when terrain is homogeneous.
|
||
let district = alluvial_district();
|
||
let chunk = alluvial_chunk(&district);
|
||
|
||
// Sample a grid of 4 neighbouring tiles; at least some should differ.
|
||
let cols: Vec<VoxelColumn> = (0..4)
|
||
.map(|i| derive_voxel_column(42, "GJ1c", &district, &chunk, i, 0))
|
||
.collect();
|
||
let all_same = cols.windows(2).all(|w| w[0] == w[1]);
|
||
assert!(
|
||
!all_same,
|
||
"adjacent tiles should differ after domain warp (elevation scatter)"
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Family dispatch stubs — must not panic
|
||
// -----------------------------------------------------------------------
|
||
|
||
// -----------------------------------------------------------------------
|
||
// T-1029 — LavaField
|
||
// -----------------------------------------------------------------------
|
||
|
||
fn lava_district() -> DistrictProfile {
|
||
DistrictProfile {
|
||
morphology_zone: MorphologyZone::Volcanic,
|
||
tectonic_class: TectonicClass::Volcanic,
|
||
vegetation_class: VegetationClass::Barren,
|
||
ocean_fraction_q: 0,
|
||
slope_q: 8,
|
||
elev_q: 30,
|
||
moisture_q: 20,
|
||
..alluvial_district()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn lava_field_terrain_is_lava() {
|
||
let district = lava_district();
|
||
let chunk = derive_chunk_context(42, "Io", &district, (0, 0), None);
|
||
for pos in [(10, 10), (0, 0), (50, 25), (-5, 5)] {
|
||
let col = derive_voxel_column(42, "Io", &district, &chunk, pos.0, pos.1);
|
||
assert_eq!(
|
||
col.terrain,
|
||
TerrainMaterial::Lava,
|
||
"LavaField must produce Lava terrain at {:?}",
|
||
pos
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn lava_field_vegetation_is_barren() {
|
||
let district = lava_district();
|
||
let chunk = derive_chunk_context(42, "Io", &district, (0, 0), None);
|
||
for tx in 0..20i32 {
|
||
let col = derive_voxel_column(42, "Io", &district, &chunk, tx, 10);
|
||
assert_eq!(
|
||
col.vegetation,
|
||
Vegetation::Barren,
|
||
"LavaField must be Barren at x={tx}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn lava_field_no_deep_water() {
|
||
// Immature drainage law (D-239 §8): no deep organised channels on lava.
|
||
let district = lava_district();
|
||
let chunk = derive_chunk_context(42, "Io", &district, (0, 0), None);
|
||
for (tx, ty) in (0..50).map(|i| (i * 7, i * 3)) {
|
||
let col = derive_voxel_column(42, "Io", &district, &chunk, tx, ty);
|
||
assert_ne!(
|
||
col.water,
|
||
Water::Deep,
|
||
"LavaField must have no Deep water (immature drainage)"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn lava_field_is_deterministic() {
|
||
let district = lava_district();
|
||
let chunk = derive_chunk_context(7, "Io", &district, (5, 3), None);
|
||
for (tx, ty) in [(0, 0), (10, 20), (-5, 7)] {
|
||
let a = derive_voxel_column(7, "Io", &district, &chunk, tx, ty);
|
||
let b = derive_voxel_column(7, "Io", &district, &chunk, tx, ty);
|
||
assert_eq!(a, b, "LavaField must be deterministic at ({tx},{ty})");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn lava_field_elevation_non_negative() {
|
||
let district = lava_district();
|
||
let chunk = derive_chunk_context(42, "Io", &district, (0, 0), None);
|
||
for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] {
|
||
let col = derive_voxel_column(42, "Io", &district, &chunk, tx, ty);
|
||
assert!(col.elevation_m >= 0, "LavaField elevation must be >= 0");
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// T-1029 — FjordWall
|
||
// -----------------------------------------------------------------------
|
||
|
||
fn fjord_district() -> DistrictProfile {
|
||
DistrictProfile {
|
||
morphology_zone: MorphologyZone::Fjord,
|
||
tectonic_class: TectonicClass::Active,
|
||
glaciation_grade: GlaciationGrade::Moderate, // grade ≥ 2
|
||
slope_q: 60,
|
||
elev_q: 80,
|
||
ocean_fraction_q: 30,
|
||
moisture_q: 70,
|
||
vegetation_class: VegetationClass::Barren,
|
||
..alluvial_district()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn fjord_wall_terrain_is_rock() {
|
||
let district = fjord_district();
|
||
let chunk = derive_chunk_context(42, "Fjordheim", &district, (0, 0), None);
|
||
for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5)] {
|
||
let col = derive_voxel_column(42, "Fjordheim", &district, &chunk, tx, ty);
|
||
assert_eq!(
|
||
col.terrain,
|
||
TerrainMaterial::Rock,
|
||
"FjordWall must produce Rock terrain at ({tx},{ty})"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn fjord_wall_centre_has_deep_water() {
|
||
// The fjord trough (district-anchored centreline, T-1041) must have Deep
|
||
// water. Sample a small window around the anchor — the domain warp can
|
||
// displace any single tile off the 4–8 m floor.
|
||
let district = fjord_district();
|
||
let chunk = derive_chunk_context(42, "Fjordheim", &district, (0, 0), None);
|
||
let anchor = chunk.channel_anchor_m;
|
||
let any_deep = (anchor - 4..=anchor + 4).any(|c| {
|
||
let (tx, ty) = cross_along_to_tile(&chunk, c, 50);
|
||
derive_voxel_column(42, "Fjordheim", &district, &chunk, tx, ty).water == Water::Deep
|
||
});
|
||
assert!(
|
||
any_deep,
|
||
"FjordWall centreline (anchor {anchor}) must have Deep water (the fjord inlet)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fjord_wall_walls_are_high_relative_to_floor() {
|
||
// Wall elevation must be substantially higher than fjord floor.
|
||
let district = fjord_district();
|
||
let chunk = derive_chunk_context(42, "Fjordheim", &district, (0, 0), None);
|
||
let anchor = chunk.channel_anchor_m;
|
||
// Floor: the lowest tile in the trough window around the anchor.
|
||
let floor_elev = (anchor - 4..=anchor + 4)
|
||
.map(|c| {
|
||
let (tx, ty) = cross_along_to_tile(&chunk, c, 50);
|
||
derive_voxel_column(42, "Fjordheim", &district, &chunk, tx, ty).elevation_m
|
||
})
|
||
.min()
|
||
.unwrap();
|
||
// Wall: 30 m off the centreline (past the moraine band at ≤14 m).
|
||
let (wx, wy) = cross_along_to_tile(&chunk, anchor - 30, 50);
|
||
let wall_col = derive_voxel_column(42, "Fjordheim", &district, &chunk, wx, wy);
|
||
assert!(
|
||
wall_col.elevation_m > floor_elev,
|
||
"FjordWall walls ({}) must be higher than the floor ({})",
|
||
wall_col.elevation_m,
|
||
floor_elev
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fjord_wall_is_deterministic() {
|
||
let district = fjord_district();
|
||
let chunk = derive_chunk_context(11, "Fjordheim", &district, (2, 3), None);
|
||
for (tx, ty) in [(32, 50), (0, 50), (63, 20)] {
|
||
let a = derive_voxel_column(11, "Fjordheim", &district, &chunk, tx, ty);
|
||
let b = derive_voxel_column(11, "Fjordheim", &district, &chunk, tx, ty);
|
||
assert_eq!(a, b, "FjordWall must be deterministic at ({tx},{ty})");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn fjord_wall_elevation_non_negative() {
|
||
let district = fjord_district();
|
||
let chunk = derive_chunk_context(42, "Fjordheim", &district, (0, 0), None);
|
||
for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] {
|
||
let col = derive_voxel_column(42, "Fjordheim", &district, &chunk, tx, ty);
|
||
assert!(col.elevation_m >= 0, "FjordWall elevation must be >= 0");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn fjord_wall_floor_width_in_spec() {
|
||
// D-239 §9: fjord floor (Deep water) must be 2–8 m wide across a cross-section.
|
||
// With floor_half = glacier_grade.clamp(2,4) and grade ≥ 2 (upstream gate),
|
||
// total Deep-water width = 2×floor_half + noise ∈ [4, 8] m.
|
||
// Cross-section spans the district-anchored trough (T-1041).
|
||
let district = fjord_district(); // glaciation_grade = Moderate (= 2)
|
||
let chunk = derive_chunk_context(42, "Fjordheim", &district, (0, 0), None);
|
||
let anchor = chunk.channel_anchor_m;
|
||
// Count Deep-water tiles across a 64-tile cross-section around the anchor.
|
||
let deep_tiles = (anchor - 32..anchor + 32)
|
||
.filter(|&c| {
|
||
let (tx, ty) = cross_along_to_tile(&chunk, c, 50);
|
||
derive_voxel_column(42, "Fjordheim", &district, &chunk, tx, ty).water == Water::Deep
|
||
})
|
||
.count();
|
||
assert!(
|
||
(2..=8).contains(&deep_tiles),
|
||
"FjordWall floor width {deep_tiles} m outside [2, 8] m spec (D-239 §9)"
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// T-1029 — CliffCoast
|
||
// -----------------------------------------------------------------------
|
||
|
||
fn cliff_district() -> DistrictProfile {
|
||
DistrictProfile {
|
||
morphology_zone: MorphologyZone::CliffCoast,
|
||
tectonic_class: TectonicClass::Active,
|
||
slope_q: 70,
|
||
elev_q: 60,
|
||
ocean_fraction_q: 40,
|
||
moisture_q: 50,
|
||
vegetation_class: VegetationClass::Barren,
|
||
..alluvial_district()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cliff_coast_terrain_is_rock() {
|
||
let district = cliff_district();
|
||
let chunk = derive_chunk_context(42, "Velen", &district, (0, 0), None);
|
||
for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5)] {
|
||
let col = derive_voxel_column(42, "Velen", &district, &chunk, tx, ty);
|
||
assert_eq!(
|
||
col.terrain,
|
||
TerrainMaterial::Rock,
|
||
"CliffCoast must produce Rock terrain at ({tx},{ty})"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cliff_coast_steep_elevation_drop_near_water() {
|
||
// Verify the CliffCoast structural property: there must be tiles with Deep
|
||
// water AND there must be a significant elevation range (high ground + ocean).
|
||
// The coast face sits on the district-anchored line `coast_anchor_m` along
|
||
// the seaward (basin) axis (T-1041) — sample a transect across it.
|
||
let district = cliff_district();
|
||
let chunk = derive_chunk_context(42, "Velen", &district, (0, 0), None);
|
||
let coast = chunk.coast_anchor_m;
|
||
// Sample 64 positions along the seaward axis, crossing the coast line.
|
||
let cols: Vec<VoxelColumn> = (coast - 32..coast + 32)
|
||
.map(|a| {
|
||
let (tx, ty) = cross_along_to_tile(&chunk, 50, a);
|
||
derive_voxel_column(42, "Velen", &district, &chunk, tx, ty)
|
||
})
|
||
.collect();
|
||
let has_deep = cols.iter().any(|c| c.water == Water::Deep);
|
||
let max_elev = cols.iter().map(|c| c.elevation_m).max().unwrap();
|
||
let ocean_elev = cols
|
||
.iter()
|
||
.filter(|c| c.water == Water::Deep)
|
||
.map(|c| c.elevation_m)
|
||
.min()
|
||
.unwrap_or(0);
|
||
assert!(
|
||
has_deep,
|
||
"CliffCoast must have at least one tile with Deep water"
|
||
);
|
||
assert!(
|
||
max_elev > ocean_elev + 5,
|
||
"CliffCoast: max elevation ({max_elev}) must be >> ocean elevation ({ocean_elev}) — vertical face law"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cliff_coast_is_deterministic() {
|
||
let district = cliff_district();
|
||
let chunk = derive_chunk_context(5, "Velen", &district, (1, 2), None);
|
||
for (tx, ty) in [(5, 50), (30, 20), (62, 50)] {
|
||
let a = derive_voxel_column(5, "Velen", &district, &chunk, tx, ty);
|
||
let b = derive_voxel_column(5, "Velen", &district, &chunk, tx, ty);
|
||
assert_eq!(a, b, "CliffCoast must be deterministic at ({tx},{ty})");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cliff_coast_elevation_non_negative() {
|
||
let district = cliff_district();
|
||
let chunk = derive_chunk_context(42, "Velen", &district, (0, 0), None);
|
||
for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] {
|
||
let col = derive_voxel_column(42, "Velen", &district, &chunk, tx, ty);
|
||
assert!(col.elevation_m >= 0, "CliffCoast elevation must be >= 0");
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// T-1029 — BraidedDelta
|
||
// -----------------------------------------------------------------------
|
||
|
||
fn delta_district() -> DistrictProfile {
|
||
DistrictProfile {
|
||
morphology_zone: MorphologyZone::Delta,
|
||
slope_q: 2,
|
||
elev_q: 4,
|
||
ocean_fraction_q: 50,
|
||
moisture_q: 80,
|
||
vegetation_class: VegetationClass::Scrub,
|
||
..alluvial_district()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn braided_delta_terrain_is_gravel() {
|
||
let district = delta_district();
|
||
let chunk = derive_chunk_context(42, "delta_body", &district, (0, 0), None);
|
||
for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5)] {
|
||
let col = derive_voxel_column(42, "delta_body", &district, &chunk, tx, ty);
|
||
assert_eq!(
|
||
col.terrain,
|
||
TerrainMaterial::Gravel,
|
||
"BraidedDelta must produce Gravel terrain at ({tx},{ty})"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn braided_delta_low_elevation() {
|
||
// Delta is near sea level — D-239 §8 drainage monotonicity: mouths at sea level.
|
||
let district = delta_district();
|
||
let chunk = derive_chunk_context(42, "delta_body", &district, (0, 0), None);
|
||
for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5), (63, 0)] {
|
||
let col = derive_voxel_column(42, "delta_body", &district, &chunk, tx, ty);
|
||
assert!(
|
||
col.elevation_m <= 8,
|
||
"BraidedDelta must be near sea level (<= 8 m), got {} at ({tx},{ty})",
|
||
col.elevation_m
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn braided_delta_has_multiple_shallow_channels() {
|
||
// Gravel→braided (D-239 §8): NOT single-thread. Multiple channels means
|
||
// a range of positions across the fan belt should contain some Shallow
|
||
// water. The belt is centred on the district-anchored fan axis (T-1041).
|
||
let district = delta_district();
|
||
let chunk = anchor_chunk(42, "delta_body", &district);
|
||
let anchor = chunk.channel_anchor_m;
|
||
// Sample 80 positions across the fan belt (anchor ± 40 covers the
|
||
// 64 m belt plus thread width).
|
||
let shallow_count = (anchor - 40..anchor + 40)
|
||
.filter(|&c| {
|
||
let (tx, ty) = cross_along_to_tile(&chunk, c, 100);
|
||
derive_voxel_column(42, "delta_body", &district, &chunk, tx, ty).water
|
||
== Water::Shallow
|
||
})
|
||
.count();
|
||
// Three braided threads, each 3–8 m wide → expect at least 6 shallow tiles
|
||
// in the belt transect.
|
||
assert!(
|
||
shallow_count >= 6,
|
||
"BraidedDelta must have multiple braided channels; only {shallow_count} shallow in transect"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn braided_delta_no_single_thread() {
|
||
// BraidedDelta must NOT look like a single-thread meander — the channel
|
||
// should appear in more than one spatial cluster across the cross-section.
|
||
// We verify by checking that Shallow water appears in at least two
|
||
// disjoint groups separated by Dry ground.
|
||
let district = delta_district();
|
||
let chunk = anchor_chunk(42, "delta_body", &district);
|
||
let anchor = chunk.channel_anchor_m;
|
||
let waters: Vec<Water> = (anchor - 40..anchor + 40)
|
||
.map(|c| {
|
||
let (tx, ty) = cross_along_to_tile(&chunk, c, 100);
|
||
derive_voxel_column(42, "delta_body", &district, &chunk, tx, ty).water
|
||
})
|
||
.collect();
|
||
// Count Dry→Shallow transitions = number of channel entrances.
|
||
let transitions = waters
|
||
.windows(2)
|
||
.filter(|w| w[0] == Water::Dry && w[1] == Water::Shallow)
|
||
.count();
|
||
assert!(
|
||
transitions >= 2,
|
||
"BraidedDelta must have ≥2 braided channel threads; only {transitions} transitions"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn braided_delta_is_deterministic() {
|
||
let district = delta_district();
|
||
let chunk = derive_chunk_context(99, "delta_body", &district, (3, 1), None);
|
||
for (tx, ty) in [(10, 10), (0, 100), (-5, 7)] {
|
||
let a = derive_voxel_column(99, "delta_body", &district, &chunk, tx, ty);
|
||
let b = derive_voxel_column(99, "delta_body", &district, &chunk, tx, ty);
|
||
assert_eq!(a, b, "BraidedDelta must be deterministic at ({tx},{ty})");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn braided_delta_elevation_non_negative() {
|
||
let district = delta_district();
|
||
let chunk = derive_chunk_context(42, "delta_body", &district, (0, 0), None);
|
||
for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] {
|
||
let col = derive_voxel_column(42, "delta_body", &district, &chunk, tx, ty);
|
||
assert!(col.elevation_m >= 0, "BraidedDelta elevation must be >= 0");
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// T-1029 — DuneStrand
|
||
// -----------------------------------------------------------------------
|
||
|
||
fn dune_district() -> DistrictProfile {
|
||
DistrictProfile {
|
||
morphology_zone: MorphologyZone::DuneStrand,
|
||
slope_q: 8,
|
||
elev_q: 6,
|
||
ocean_fraction_q: 30,
|
||
moisture_q: 40,
|
||
vegetation_class: VegetationClass::Barren,
|
||
..alluvial_district()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn dune_strand_terrain_is_sand() {
|
||
let district = dune_district();
|
||
let chunk = derive_chunk_context(42, "dune_body", &district, (0, 0), None);
|
||
for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5)] {
|
||
let col = derive_voxel_column(42, "dune_body", &district, &chunk, tx, ty);
|
||
assert_eq!(
|
||
col.terrain,
|
||
TerrainMaterial::Sand,
|
||
"DuneStrand must produce Sand terrain at ({tx},{ty})"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn dune_strand_respects_angle_of_repose() {
|
||
// D-239 §8 Sand law: ≤~32° angle of repose. For a dune with period W,
|
||
// the maximum allowed height H satisfies H ≤ tan(32°) × (W/2) ≈ 0.625 × W/2.
|
||
// We verify by checking that elevation differences between adjacent tiles
|
||
// don't exceed the physics cap.
|
||
let district = dune_district();
|
||
let chunk = derive_chunk_context(42, "dune_body", &district, (0, 0), None);
|
||
// Sample a row of tiles along the dune wave direction.
|
||
let elevations: Vec<i32> = (0..40i32)
|
||
.map(|y| derive_voxel_column(42, "dune_body", &district, &chunk, 10, y).elevation_m)
|
||
.collect();
|
||
// Max elevation in the sample.
|
||
let max_elev = *elevations.iter().max().unwrap();
|
||
// Dune wavelength for slope_q=8: 20 - 8/2 = 16 m. Physics cap per wave = 0.625 × 8 = 5 m.
|
||
// Allow a margin of 2 for scatter noise.
|
||
let physics_cap = 8; // generous upper bound including noise
|
||
assert!(
|
||
max_elev <= physics_cap,
|
||
"DuneStrand max elevation {} exceeds angle-of-repose physics cap {}",
|
||
max_elev,
|
||
physics_cap
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dune_strand_mostly_dry() {
|
||
// Dunes are mostly dry; only the strand toe may be Shallow.
|
||
let district = dune_district();
|
||
let chunk = derive_chunk_context(42, "dune_body", &district, (0, 0), None);
|
||
let dry_or_shallow_count = (0..100i32)
|
||
.filter(|&x| {
|
||
let col = derive_voxel_column(42, "dune_body", &district, &chunk, x, 50);
|
||
col.water != Water::Deep // no Deep water on dunes
|
||
})
|
||
.count();
|
||
assert_eq!(
|
||
dry_or_shallow_count, 100,
|
||
"DuneStrand must have no Deep water"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dune_strand_is_deterministic() {
|
||
let district = dune_district();
|
||
let chunk = derive_chunk_context(3, "dune_body", &district, (1, 0), None);
|
||
for (tx, ty) in [(10, 10), (0, 30), (-5, 7)] {
|
||
let a = derive_voxel_column(3, "dune_body", &district, &chunk, tx, ty);
|
||
let b = derive_voxel_column(3, "dune_body", &district, &chunk, tx, ty);
|
||
assert_eq!(a, b, "DuneStrand must be deterministic at ({tx},{ty})");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn dune_strand_elevation_non_negative() {
|
||
let district = dune_district();
|
||
let chunk = derive_chunk_context(42, "dune_body", &district, (0, 0), None);
|
||
for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] {
|
||
let col = derive_voxel_column(42, "dune_body", &district, &chunk, tx, ty);
|
||
assert!(col.elevation_m >= 0, "DuneStrand elevation must be >= 0");
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// T-1029 — IncisedGorge
|
||
// -----------------------------------------------------------------------
|
||
|
||
fn gorge_district() -> DistrictProfile {
|
||
DistrictProfile {
|
||
morphology_zone: MorphologyZone::MountainPass,
|
||
tectonic_class: TectonicClass::Active,
|
||
slope_q: 80,
|
||
elev_q: 90,
|
||
ocean_fraction_q: 5,
|
||
moisture_q: 50,
|
||
vegetation_class: VegetationClass::Barren,
|
||
..alluvial_district()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn incised_gorge_terrain_is_rock() {
|
||
let district = gorge_district();
|
||
let chunk = derive_chunk_context(42, "gorge_body", &district, (0, 0), None);
|
||
for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5)] {
|
||
let col = derive_voxel_column(42, "gorge_body", &district, &chunk, tx, ty);
|
||
assert_eq!(
|
||
col.terrain,
|
||
TerrainMaterial::Rock,
|
||
"IncisedGorge must produce Rock terrain at ({tx},{ty})"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn incised_gorge_floor_width_in_spec() {
|
||
// D-239 §9: gorge floors 2–8 m wide. We verify by checking that the
|
||
// total width of the Shallow water zone (the incised channel on the floor)
|
||
// across a cross-section is within [2, 8] m. Cross-section spans the
|
||
// district-anchored gorge centreline (T-1041).
|
||
let district = gorge_district();
|
||
let chunk = derive_chunk_context(42, "gorge_body", &district, (0, 0), None);
|
||
let anchor = chunk.channel_anchor_m;
|
||
// The floor's intrinsic width is the D-239 §9 quantity. A single warped
|
||
// cross-section is noisy (the ±8 m domain warp can split the band at some
|
||
// along-rows), so measure across several rows and take the widest — the
|
||
// floor's true cross-section, independent of where the anchor falls.
|
||
let floor_tiles = (60..160)
|
||
.step_by(7)
|
||
.map(|along| {
|
||
(anchor - 32..anchor + 32)
|
||
.filter(|&c| {
|
||
let (tx, ty) = cross_along_to_tile(&chunk, c, along);
|
||
derive_voxel_column(42, "gorge_body", &district, &chunk, tx, ty).water
|
||
== Water::Shallow
|
||
})
|
||
.count()
|
||
})
|
||
.max()
|
||
.unwrap();
|
||
assert!(
|
||
(2..=8).contains(&floor_tiles),
|
||
"IncisedGorge floor width {floor_tiles} m outside [2, 8] m spec (D-239 §9)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn incised_gorge_walls_much_higher_than_floor() {
|
||
// Walls must be substantially higher than the gorge floor.
|
||
let district = gorge_district();
|
||
let chunk = derive_chunk_context(42, "gorge_body", &district, (0, 0), None);
|
||
let anchor = chunk.channel_anchor_m;
|
||
// Floor: the lowest tile in the trough window around the anchor
|
||
// (the warp can displace any single tile off the 2–8 m floor).
|
||
let floor_elev = (anchor - 4..=anchor + 4)
|
||
.map(|c| {
|
||
let (tx, ty) = cross_along_to_tile(&chunk, c, 100);
|
||
derive_voxel_column(42, "gorge_body", &district, &chunk, tx, ty).elevation_m
|
||
})
|
||
.min()
|
||
.unwrap();
|
||
// Wall: 30 m off the centreline.
|
||
let (wx, wy) = cross_along_to_tile(&chunk, anchor + 30, 100);
|
||
let wall_col = derive_voxel_column(42, "gorge_body", &district, &chunk, wx, wy);
|
||
assert!(
|
||
wall_col.elevation_m > floor_elev + 10,
|
||
"IncisedGorge walls ({}) must be >> gorge floor ({})",
|
||
wall_col.elevation_m,
|
||
floor_elev
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn incised_gorge_is_deterministic() {
|
||
let district = gorge_district();
|
||
let chunk = derive_chunk_context(17, "gorge_body", &district, (4, 2), None);
|
||
for (tx, ty) in [(32, 100), (62, 100), (10, 50)] {
|
||
let a = derive_voxel_column(17, "gorge_body", &district, &chunk, tx, ty);
|
||
let b = derive_voxel_column(17, "gorge_body", &district, &chunk, tx, ty);
|
||
assert_eq!(a, b, "IncisedGorge must be deterministic at ({tx},{ty})");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn incised_gorge_elevation_non_negative() {
|
||
let district = gorge_district();
|
||
let chunk = derive_chunk_context(42, "gorge_body", &district, (0, 0), None);
|
||
for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] {
|
||
let col = derive_voxel_column(42, "gorge_body", &district, &chunk, tx, ty);
|
||
assert!(col.elevation_m >= 0, "IncisedGorge elevation must be >= 0");
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// T-1029 — MeanderReach
|
||
// -----------------------------------------------------------------------
|
||
|
||
fn meander_district() -> DistrictProfile {
|
||
DistrictProfile {
|
||
morphology_zone: MorphologyZone::MeanderReach,
|
||
slope_q: 3,
|
||
elev_q: 15,
|
||
ocean_fraction_q: 20,
|
||
moisture_q: 70,
|
||
vegetation_class: VegetationClass::Forest,
|
||
..alluvial_district()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn meander_reach_terrain_is_soil_or_wetland() {
|
||
// MeanderReach terrain is Soil (or Wetland sub-zone on very flat/wet ground).
|
||
// D-239 §8 Soil law: rolling/floodplain. Wetland is a valid sub-classification
|
||
// for saturated floodplains (slope_q ≤ 3, moisture_q ≥ 70 in the meander_district).
|
||
// This test checks that NO other material appears (no Rock, Sand, Gravel, Lava).
|
||
let district = meander_district();
|
||
let chunk = derive_chunk_context(42, "meander_body", &district, (0, 0), None);
|
||
for (tx, ty) in [
|
||
(0, 50),
|
||
(5, 50),
|
||
(10, 50),
|
||
(15, 50),
|
||
(100, 50),
|
||
(200, 50),
|
||
(300, 50),
|
||
(400, 50),
|
||
] {
|
||
let col = derive_voxel_column(42, "meander_body", &district, &chunk, tx, ty);
|
||
assert!(
|
||
matches!(
|
||
col.terrain,
|
||
TerrainMaterial::Soil | TerrainMaterial::Wetland
|
||
),
|
||
"MeanderReach terrain must be Soil or Wetland, got {:?} at ({tx},{ty})",
|
||
col.terrain
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn meander_reach_non_wetland_region_produces_soil() {
|
||
// A less-wet MeanderReach district (moisture_q < 70) should produce Soil, not Wetland.
|
||
let district = DistrictProfile {
|
||
morphology_zone: MorphologyZone::MeanderReach,
|
||
slope_q: 5, // above wetland slope threshold
|
||
elev_q: 15,
|
||
ocean_fraction_q: 20,
|
||
moisture_q: 55, // below wetland moisture threshold
|
||
vegetation_class: VegetationClass::Forest,
|
||
..alluvial_district()
|
||
};
|
||
let chunk = derive_chunk_context(42, "meander_body2", &district, (0, 0), None);
|
||
let mut soil_count = 0;
|
||
for tx in [0, 5, 10, 15, 100, 200] {
|
||
let col = derive_voxel_column(42, "meander_body2", &district, &chunk, tx, 50);
|
||
if col.terrain == TerrainMaterial::Soil {
|
||
soil_count += 1;
|
||
}
|
||
}
|
||
assert!(
|
||
soil_count >= 5,
|
||
"Non-wetland MeanderReach must be mostly Soil, got {soil_count}/6"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn meander_reach_has_active_channel() {
|
||
// MeanderReach must have a channel with Shallow/Deep water somewhere in
|
||
// a transect across the district-anchored channel band (T-1040).
|
||
let district = meander_district();
|
||
let chunk = anchor_chunk(42, "meander_body", &district);
|
||
// The anchor-covering chunk has an active channel (ocean_fraction_q=20 >= 10).
|
||
assert!(
|
||
chunk.has_active_channel,
|
||
"MeanderReach anchor chunk must have active channel"
|
||
);
|
||
let anchor = chunk.channel_anchor_m;
|
||
// Sample a wide cross-section and find at least one wet tile.
|
||
let wet_count = (anchor - 200..anchor + 200)
|
||
.filter(|&c| {
|
||
let (tx, ty) = cross_along_to_tile(&chunk, c, 50);
|
||
let col = derive_voxel_column(42, "meander_body", &district, &chunk, tx, ty);
|
||
col.water != Water::Dry
|
||
})
|
||
.count();
|
||
assert!(
|
||
wet_count >= 3,
|
||
"MeanderReach must have a visible channel with wet tiles in cross-section; got {wet_count}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn meander_reach_channel_below_levee() {
|
||
// D-239 §9 ElevationDelta: channel tiles must sit below off-channel
|
||
// tiles in the vicinity. Per-voxel micro-relief is ±4 m, so a single
|
||
// wet/dry tile pair is noise — compare the channel bottom (min wet)
|
||
// against the floodplain/levee top (max dry) across the transect.
|
||
let district = meander_district();
|
||
let chunk = anchor_chunk(42, "meander_body", &district);
|
||
let anchor = chunk.channel_anchor_m;
|
||
let mut min_wet: Option<i32> = None;
|
||
let mut max_dry: Option<i32> = None;
|
||
for c in anchor - 200..anchor + 200 {
|
||
let (tx, ty) = cross_along_to_tile(&chunk, c, 50);
|
||
let col = derive_voxel_column(42, "meander_body", &district, &chunk, tx, ty);
|
||
if col.water != Water::Dry {
|
||
min_wet = Some(min_wet.map_or(col.elevation_m, |e| e.min(col.elevation_m)));
|
||
} else {
|
||
max_dry = Some(max_dry.map_or(col.elevation_m, |e| e.max(col.elevation_m)));
|
||
}
|
||
}
|
||
// Both tile kinds must exist — a transect across the anchor that finds
|
||
// no channel means the placement regressed (the law must be exercised).
|
||
let (min_wet, max_dry) = (
|
||
min_wet.expect("transect across the anchor must contain channel tiles"),
|
||
max_dry.expect("transect must contain floodplain tiles"),
|
||
);
|
||
assert!(
|
||
min_wet <= max_dry,
|
||
"MeanderReach: channel bottom ({min_wet}) must be <= floodplain/levee top ({max_dry})"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn meander_reach_stronger_sinuosity_than_alluvial() {
|
||
// MeanderReach uses channel amplitude = wavelength/4 vs AlluvialPlain's
|
||
// wavelength/6. Same district params, same seed, same body — the ONLY
|
||
// difference is the morphology_zone driving the dispatch. Different
|
||
// amplitudes shift the channel centreline position differently across the
|
||
// chunk, so the set of wet-tile x-positions must differ. If MeanderReach
|
||
// ever regresses to AlluvialPlain's amplitude (or falls back to the same
|
||
// code path), the wet-tile sets become identical and this test fails.
|
||
//
|
||
// We use the SAME elev_q, slope_q, ocean_fraction_q, and seed for both —
|
||
// so the only driver of the difference is the sinuosity amplitude.
|
||
let shared_district_base = DistrictProfile {
|
||
slope_q: 5,
|
||
elev_q: 20,
|
||
ocean_fraction_q: 20, // ensure has_active_channel = true
|
||
moisture_q: 55,
|
||
vegetation_class: VegetationClass::Forest,
|
||
..alluvial_district()
|
||
};
|
||
let meander_reg = DistrictProfile {
|
||
morphology_zone: MorphologyZone::MeanderReach,
|
||
..shared_district_base.clone()
|
||
};
|
||
let alluvial_reg = DistrictProfile {
|
||
morphology_zone: MorphologyZone::AlluvialPlain,
|
||
..shared_district_base
|
||
};
|
||
// Same seed + body → same ChunkContext (same wavelength, phase, anchor,
|
||
// channel_width). Use the anchor-covering chunk (T-1040 band gating).
|
||
let meander_chunk = anchor_chunk(42, "sinuosity_body", &meander_reg);
|
||
let alluvial_chunk = anchor_chunk(42, "sinuosity_body", &alluvial_reg);
|
||
// Verify the chunks share the same meander params (confirming the test setup).
|
||
assert_eq!(
|
||
meander_chunk.meander_phase, alluvial_chunk.meander_phase,
|
||
"test setup requires identical chunk params"
|
||
);
|
||
assert_eq!(
|
||
meander_chunk.channel_anchor_m, alluvial_chunk.channel_anchor_m,
|
||
"test setup requires identical channel anchors"
|
||
);
|
||
assert!(
|
||
meander_chunk.has_active_channel && alluvial_chunk.has_active_channel,
|
||
"both chunks must have active channels for the test to be meaningful"
|
||
);
|
||
// Collect wet-tile cross-positions over a transect at along=50, centred
|
||
// on the shared anchor. We scan far enough to capture the full meander
|
||
// swing — amplitude for MeanderReach is wl/4, for AlluvialPlain wl/6,
|
||
// so over a ±400 sweep both complete multiple full cycles and their
|
||
// centreline positions diverge.
|
||
let anchor = meander_chunk.channel_anchor_m;
|
||
let meander_wet: Vec<i32> = (anchor - 400..anchor + 400)
|
||
.filter(|&c| {
|
||
let (tx, ty) = cross_along_to_tile(&meander_chunk, c, 50);
|
||
derive_voxel_column(42, "sinuosity_body", &meander_reg, &meander_chunk, tx, ty)
|
||
.water
|
||
!= Water::Dry
|
||
})
|
||
.collect();
|
||
let alluvial_wet: Vec<i32> = (anchor - 400..anchor + 400)
|
||
.filter(|&c| {
|
||
let (tx, ty) = cross_along_to_tile(&alluvial_chunk, c, 50);
|
||
derive_voxel_column(42, "sinuosity_body", &alluvial_reg, &alluvial_chunk, tx, ty)
|
||
.water
|
||
!= Water::Dry
|
||
})
|
||
.collect();
|
||
assert!(
|
||
!meander_wet.is_empty(),
|
||
"MeanderReach must have a wet channel"
|
||
);
|
||
assert!(
|
||
!alluvial_wet.is_empty(),
|
||
"AlluvialPlain must have a wet channel"
|
||
);
|
||
// The wet-tile position sets must differ — proving MeanderReach uses a
|
||
// distinct sinuosity from AlluvialPlain. If this fails, the two generators
|
||
// produce identical channel positions, meaning MeanderReach has regressed.
|
||
assert_ne!(
|
||
meander_wet, alluvial_wet,
|
||
"MeanderReach and AlluvialPlain must produce different wet-tile positions \
|
||
(different channel amplitude = different sinuosity); they were identical, \
|
||
which means MeanderReach has regressed to AlluvialPlain amplitude"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn meander_reach_is_deterministic() {
|
||
let district = meander_district();
|
||
let chunk = derive_chunk_context(23, "meander_body", &district, (2, 5), None);
|
||
for (tx, ty) in [(0, 50), (50, 200), (-20, 100)] {
|
||
let a = derive_voxel_column(23, "meander_body", &district, &chunk, tx, ty);
|
||
let b = derive_voxel_column(23, "meander_body", &district, &chunk, tx, ty);
|
||
assert_eq!(a, b, "MeanderReach must be deterministic at ({tx},{ty})");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn meander_reach_elevation_non_negative() {
|
||
let district = meander_district();
|
||
let chunk = derive_chunk_context(42, "meander_body", &district, (0, 0), None);
|
||
for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] {
|
||
let col = derive_voxel_column(42, "meander_body", &district, &chunk, tx, ty);
|
||
assert!(col.elevation_m >= 0, "MeanderReach elevation must be >= 0");
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// T-1029 — Cross-family: no panics, correct materials, all deterministic
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// All families (7 T-1029 landform + the T-1082 WaterBody zones) must not panic on
|
||
/// any sample position, including extreme coordinates.
|
||
#[test]
|
||
fn all_families_no_panic_at_extreme_positions() {
|
||
let zones = [
|
||
MorphologyZone::Volcanic,
|
||
MorphologyZone::Fjord,
|
||
MorphologyZone::CliffCoast,
|
||
MorphologyZone::Delta,
|
||
MorphologyZone::DuneStrand,
|
||
MorphologyZone::MountainPass,
|
||
MorphologyZone::MeanderReach,
|
||
// T-1082 WaterBody zones.
|
||
MorphologyZone::OpenOcean,
|
||
MorphologyZone::Lake,
|
||
MorphologyZone::TidalFlat,
|
||
];
|
||
let positions = [
|
||
(0, 0),
|
||
(i32::MAX / 4, 0),
|
||
(0, i32::MAX / 4),
|
||
(-100, -100),
|
||
(1000, 2000),
|
||
(-500, 300),
|
||
];
|
||
for zone in &zones {
|
||
let district = DistrictProfile {
|
||
morphology_zone: *zone,
|
||
tectonic_class: TectonicClass::Volcanic,
|
||
glaciation_grade: GlaciationGrade::Moderate,
|
||
slope_q: 50,
|
||
elev_q: 40,
|
||
ocean_fraction_q: 20,
|
||
moisture_q: 50,
|
||
..alluvial_district()
|
||
};
|
||
let chunk = derive_chunk_context(42, "stress_body", &district, (0, 0), None);
|
||
for (tx, ty) in &positions {
|
||
// Must not panic.
|
||
let col = derive_voxel_column(42, "stress_body", &district, &chunk, *tx, *ty);
|
||
assert!(
|
||
col.elevation_m >= 0,
|
||
"elevation must be non-negative for {:?} at ({tx},{ty})",
|
||
zone
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Each T-1029 family produces the expected TerrainMaterial (D-239 §8 lithology law).
|
||
#[test]
|
||
fn family_terrain_materials_match_lithology_law() {
|
||
let cases: &[(MorphologyZone, TerrainMaterial)] = &[
|
||
(MorphologyZone::Volcanic, TerrainMaterial::Lava),
|
||
(MorphologyZone::Fjord, TerrainMaterial::Rock),
|
||
(MorphologyZone::CliffCoast, TerrainMaterial::Rock),
|
||
(MorphologyZone::Delta, TerrainMaterial::Gravel),
|
||
(MorphologyZone::DuneStrand, TerrainMaterial::Sand),
|
||
(MorphologyZone::MountainPass, TerrainMaterial::Rock),
|
||
];
|
||
for (zone, expected_terrain) in cases {
|
||
let district = DistrictProfile {
|
||
morphology_zone: *zone,
|
||
tectonic_class: TectonicClass::Volcanic,
|
||
glaciation_grade: GlaciationGrade::Moderate,
|
||
slope_q: 50,
|
||
elev_q: 40,
|
||
ocean_fraction_q: 5,
|
||
moisture_q: 30,
|
||
vegetation_class: VegetationClass::Barren,
|
||
..alluvial_district()
|
||
};
|
||
let chunk = derive_chunk_context(42, "mat_check", &district, (0, 0), None);
|
||
let col = derive_voxel_column(42, "mat_check", &district, &chunk, 10, 10);
|
||
assert_eq!(
|
||
col.terrain, *expected_terrain,
|
||
"Zone {:?} must produce {:?} terrain",
|
||
zone, expected_terrain
|
||
);
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// T-1030 — SeasonalCover: discriminant pins and derive_cover tests
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn seasonal_cover_discriminants_pinned() {
|
||
// Append-only invariant (D-010).
|
||
assert_eq!(SeasonalCover::None as u8, 0);
|
||
assert_eq!(SeasonalCover::Snow as u8, 1);
|
||
assert_eq!(SeasonalCover::Ice as u8, 2);
|
||
}
|
||
|
||
// ── Helpers for derive_cover tests ──────────────────────────────────────
|
||
|
||
/// Build a minimal VoxelColumn with a given Water state for cover testing.
|
||
/// The cover field is irrelevant (derive_cover is called externally in tests).
|
||
fn make_column(water: Water) -> VoxelColumn {
|
||
VoxelColumn {
|
||
terrain: TerrainMaterial::Soil,
|
||
floor: FloorMaterial::None,
|
||
vegetation: Vegetation::Grass,
|
||
water,
|
||
elevation_m: 5,
|
||
cover: SeasonalCover::None,
|
||
}
|
||
}
|
||
|
||
/// Build a DistrictProfile for cover tests with explicit temperature and moisture.
|
||
fn cover_district(zone: MorphologyZone, temp_c: f32, moisture_q: i32) -> DistrictProfile {
|
||
DistrictProfile {
|
||
morphology_zone: zone,
|
||
temperature_c: Some(temp_c),
|
||
moisture_q,
|
||
..alluvial_district()
|
||
}
|
||
}
|
||
|
||
// ── Airless body ─────────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn cover_airless_body_is_none() {
|
||
// D-239 §2: no atmosphere → temperature_c == None → cover None always.
|
||
let mut district = alluvial_district();
|
||
district.temperature_c = None;
|
||
let col = make_column(Water::Dry);
|
||
let cover = derive_cover(42, "test_body", &district, &col, (0, 0));
|
||
assert_eq!(
|
||
cover,
|
||
SeasonalCover::None,
|
||
"airless body must always produce None cover"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cover_airless_body_even_with_water() {
|
||
let mut district = cover_district(MorphologyZone::Lake, 15.0, 80);
|
||
district.temperature_c = None;
|
||
let col = make_column(Water::Deep);
|
||
let cover = derive_cover(42, "test_body", &district, &col, (50, 50));
|
||
assert_eq!(
|
||
cover,
|
||
SeasonalCover::None,
|
||
"airless + lake must still be None"
|
||
);
|
||
}
|
||
|
||
// ── Freshwater band edges ─────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn cover_freshwater_above_band_is_none() {
|
||
// Above +5 °C: always None (open water), regardless of scatter.
|
||
let district = cover_district(MorphologyZone::Lake, 6.0, 60);
|
||
let col = make_column(Water::Deep);
|
||
// Sample many positions — all must be None above the band.
|
||
for i in 0..50i32 {
|
||
let cover = derive_cover(42, "test_body", &district, &col, (i * 7, i * 3));
|
||
assert_eq!(
|
||
cover,
|
||
SeasonalCover::None,
|
||
"freshwater at temp +6°C must be None (above band) at pos ({},{})",
|
||
i * 7,
|
||
i * 3
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cover_freshwater_below_band_is_all_ice() {
|
||
// Below −10 °C: always Ice (no scatter).
|
||
let district = cover_district(MorphologyZone::Lake, -11.0, 60);
|
||
let col = make_column(Water::Deep);
|
||
for i in 0..50i32 {
|
||
let cover = derive_cover(42, "test_body", &district, &col, (i * 11, i * 5));
|
||
assert_eq!(
|
||
cover,
|
||
SeasonalCover::Ice,
|
||
"freshwater at temp −11°C must be Ice (below band) at pos ({},{})",
|
||
i * 11,
|
||
i * 5
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cover_freshwater_within_band_has_mix_of_ice_and_none() {
|
||
// Within the scatter band [−10, +5]: should produce a mix of Ice and None
|
||
// across different positions. Not all-Ice, not all-None.
|
||
let district = cover_district(MorphologyZone::Lake, -3.0, 60);
|
||
let col = make_column(Water::Deep);
|
||
let mut ice_count = 0;
|
||
let mut none_count = 0;
|
||
for i in 0..200i32 {
|
||
// Spread across a large area to span many cluster cells.
|
||
let pos = (i * CLUSTER_M * 2, i * CLUSTER_M);
|
||
let cover = derive_cover(42, "test_body", &district, &col, pos);
|
||
match cover {
|
||
SeasonalCover::Ice => ice_count += 1,
|
||
SeasonalCover::None => none_count += 1,
|
||
SeasonalCover::Snow => panic!("freshwater should not produce Snow"),
|
||
}
|
||
}
|
||
assert!(
|
||
ice_count > 0,
|
||
"scatter band at −3°C must produce some Ice tiles; got 0 Ice in 200 samples"
|
||
);
|
||
assert!(
|
||
none_count > 0,
|
||
"scatter band at −3°C must produce some None tiles; got 0 None in 200 samples"
|
||
);
|
||
}
|
||
|
||
// ── Sea ice (salt water) ─────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn cover_salt_above_onset_is_none() {
|
||
// Above −2 °C: no sea ice.
|
||
let district = cover_district(MorphologyZone::OpenOcean, 0.0, 50);
|
||
let col = make_column(Water::Deep);
|
||
for i in 0..30i32 {
|
||
let cover = derive_cover(42, "test_body", &district, &col, (i * 13, i * 7));
|
||
assert_eq!(
|
||
cover,
|
||
SeasonalCover::None,
|
||
"salt water at 0°C must be None (above onset −2°C)"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cover_salt_below_band_is_all_ice() {
|
||
// Below −14 °C: permanent pack ice everywhere.
|
||
let district = cover_district(MorphologyZone::OpenOcean, -15.0, 50);
|
||
let col = make_column(Water::Deep);
|
||
for i in 0..30i32 {
|
||
let cover = derive_cover(42, "test_body", &district, &col, (i * 17, i * 9));
|
||
assert_eq!(
|
||
cover,
|
||
SeasonalCover::Ice,
|
||
"salt water at −15°C must be Ice (below band)"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cover_freshwater_not_triggered_for_salt_zone() {
|
||
// OpenOcean should use the salt-water band (onset −2°C), NOT the
|
||
// freshwater band (onset +5°C). At temp = +3°C, ocean must be None.
|
||
let district = cover_district(MorphologyZone::OpenOcean, 3.0, 50);
|
||
let col = make_column(Water::Deep);
|
||
for i in 0..20i32 {
|
||
let cover = derive_cover(42, "test_body", &district, &col, (i * 7, i * 5));
|
||
assert_eq!(
|
||
cover,
|
||
SeasonalCover::None,
|
||
"ocean at +3°C must be None (salt onset is −2°C, not +5°C)"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Snow (land) — moisture gating ────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn cover_snow_cold_wet_land_produces_snow() {
|
||
// Cold + wet land must produce Snow in the permanent zone (below −10°C).
|
||
let district = cover_district(MorphologyZone::AlluvialPlain, -12.0, 60);
|
||
let col = make_column(Water::Dry);
|
||
for i in 0..30i32 {
|
||
let cover = derive_cover(42, "test_body", &district, &col, (i * 9, i * 3));
|
||
assert_eq!(
|
||
cover,
|
||
SeasonalCover::Snow,
|
||
"cold (−12°C) + moist land must produce Snow (permanent zone)"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cover_snow_cold_dry_land_produces_none() {
|
||
// Cold + dry (moisture_q < SNOW_MOISTURE_GATE) → bare frozen ground, not Snow.
|
||
let district = cover_district(MorphologyZone::AlluvialPlain, -12.0, 20);
|
||
let col = make_column(Water::Dry);
|
||
for i in 0..30i32 {
|
||
let cover = derive_cover(42, "test_body", &district, &col, (i * 9, i * 3));
|
||
assert_eq!(
|
||
cover,
|
||
SeasonalCover::None,
|
||
"cold (−12°C) + dry land must produce None (bare frozen ground, not Snow)"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cover_snow_warm_land_produces_none() {
|
||
// Warm land (above snow band): no snow.
|
||
let district = cover_district(MorphologyZone::AlluvialPlain, 10.0, 80);
|
||
let col = make_column(Water::Dry);
|
||
for i in 0..30i32 {
|
||
let cover = derive_cover(42, "test_body", &district, &col, (i * 5, i * 2));
|
||
assert_eq!(
|
||
cover,
|
||
SeasonalCover::None,
|
||
"warm (+10°C) land must produce None even with high moisture"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cover_snow_within_band_has_mix() {
|
||
// Within the snow scatter band (e.g. −5°C) + moist: mix of Snow and None.
|
||
let district = cover_district(MorphologyZone::AlluvialPlain, -5.0, 60);
|
||
let col = make_column(Water::Dry);
|
||
let mut snow_count = 0;
|
||
let mut none_count = 0;
|
||
for i in 0..200i32 {
|
||
let pos = (i * CLUSTER_M * 2, i * CLUSTER_M);
|
||
let cover = derive_cover(42, "test_body", &district, &col, pos);
|
||
match cover {
|
||
SeasonalCover::Snow => snow_count += 1,
|
||
SeasonalCover::None => none_count += 1,
|
||
SeasonalCover::Ice => panic!("land should not produce Ice"),
|
||
}
|
||
}
|
||
assert!(
|
||
snow_count > 0,
|
||
"scatter band at −5°C moist land must produce some Snow; got 0 Snow"
|
||
);
|
||
assert!(
|
||
none_count > 0,
|
||
"scatter band at −5°C moist land must produce some None; got 0 None"
|
||
);
|
||
}
|
||
|
||
// ── Land water-state: Dry riverbank should be land, not freshwater ────────
|
||
|
||
#[test]
|
||
fn cover_dry_riverbank_is_land_not_freshwater() {
|
||
// A RiverBank tile that is Water::Dry should be treated as land (snow),
|
||
// not freshwater (ice). D-239 §3: fresh water only when the voxel is wet.
|
||
let district = cover_district(MorphologyZone::RiverBank, -12.0, 60);
|
||
let dry_col = make_column(Water::Dry);
|
||
let wet_col = make_column(Water::Shallow);
|
||
|
||
let dry_cover = derive_cover(42, "test_body", &district, &dry_col, (0, 0));
|
||
let wet_cover = derive_cover(42, "test_body", &district, &wet_col, (0, 0));
|
||
|
||
assert_eq!(
|
||
dry_cover,
|
||
SeasonalCover::Snow,
|
||
"dry RiverBank at −12°C + moist must be Snow (land branch)"
|
||
);
|
||
assert_eq!(
|
||
wet_cover,
|
||
SeasonalCover::Ice,
|
||
"wet RiverBank at −12°C must be Ice (freshwater branch)"
|
||
);
|
||
}
|
||
|
||
// ── Spatial coherence ────────────────────────────────────────────────────
|
||
|
||
/// Coherence test: exercises the REAL `derive_cover` production signature
|
||
/// (with `world_seed` + `body_id` — the fixed API) to catch the defect from
|
||
/// PR #163.
|
||
///
|
||
/// The defect: in the broken code, `cluster_scatter` received `sub_chunk_seed`
|
||
/// (a per-voxel value), so every voxel had a distinct scatter hash → salt-and-
|
||
/// pepper noise, NOT clustered patches. This test would FAIL against that code
|
||
/// because intra-cluster agreement would be ~50% (random coin flip) instead of
|
||
/// 100%. It PASSES only with the fix: `cluster_scatter` uses
|
||
/// `SeedChain::for_body(world_seed, body_id).derive(Cover, cluster_cell_id)`,
|
||
/// keyed on (world, body, cluster-cell) so every voxel in the same cell gets
|
||
/// the same hash.
|
||
///
|
||
/// ## Why we test `derive_cover` directly rather than via `derive_voxel_column`
|
||
///
|
||
/// `derive_voxel_column` applies domain warp (±8 m) before computing the voxel
|
||
/// address. Two logically-adjacent tiles (1 m apart) may warp to positions that
|
||
/// fall in *different* 6 m cluster cells — which is correct behavior, not a
|
||
/// coherence failure. Testing coherence at the `derive_voxel_column` level would
|
||
/// require knowing the post-warp cluster-cell boundaries, making the test
|
||
/// fragile. Testing `derive_cover` directly with positions that are explicitly
|
||
/// within the same cluster cell is the clean contract-level test.
|
||
#[test]
|
||
fn cover_cluster_coherence_not_per_tile() {
|
||
// Use a cold lake district so cover is in the scatter band (mix of Ice/None)
|
||
// — solid-frozen would pass trivially regardless of coherence.
|
||
let district = cover_district(MorphologyZone::Lake, -3.0, 60);
|
||
let col = make_column(Water::Shallow);
|
||
let world_seed: u64 = 42;
|
||
let body_id = "coherence_body";
|
||
|
||
let mut cluster_agreements = 0;
|
||
let mut cluster_total = 0;
|
||
|
||
// Walk a grid of cluster cells. For each cell, sample 4 voxels that are
|
||
// explicitly inside that cell: offsets (0,0), (1,0), (0,1), (CLUSTER_M-1, CLUSTER_M-1).
|
||
// All 4 must produce identical cover — same cluster-cell → same SeedChain hash.
|
||
for cx in -10..10i32 {
|
||
for cy in -10..10i32 {
|
||
let base_x = cx * CLUSTER_M;
|
||
let base_y = cy * CLUSTER_M;
|
||
let offsets = [(0, 0), (1, 0), (0, 1), (CLUSTER_M - 1, CLUSTER_M - 1)];
|
||
let covers: Vec<SeasonalCover> = offsets
|
||
.iter()
|
||
.map(|&(dx, dy)| {
|
||
derive_cover(
|
||
world_seed,
|
||
body_id,
|
||
&district,
|
||
&col,
|
||
(base_x + dx, base_y + dy),
|
||
)
|
||
})
|
||
.collect();
|
||
// All 4 must agree.
|
||
if covers.windows(2).all(|w| w[0] == w[1]) {
|
||
cluster_agreements += 1;
|
||
}
|
||
cluster_total += 1;
|
||
}
|
||
}
|
||
|
||
assert_eq!(
|
||
cluster_agreements,
|
||
cluster_total,
|
||
"ALL voxels within the same cluster cell must agree (same cluster-cell hash); \
|
||
{}/{} cells disagreed — this means cluster_scatter is NOT keyed solely on the \
|
||
cluster-cell coordinates (salt-and-pepper bug)",
|
||
cluster_total - cluster_agreements,
|
||
cluster_total
|
||
);
|
||
|
||
// Also verify that cover varies across different cluster cells in the scatter
|
||
// band — confirming the hash is not degenerate (not all-Ice or all-None).
|
||
let cell_covers: Vec<SeasonalCover> = (-25..25i32)
|
||
.map(|cx| derive_cover(world_seed, body_id, &district, &col, (cx * CLUSTER_M, 0)))
|
||
.collect();
|
||
let has_ice = cell_covers.contains(&SeasonalCover::Ice);
|
||
let has_none = cell_covers.contains(&SeasonalCover::None);
|
||
assert!(
|
||
has_ice && has_none,
|
||
"cover must vary across cluster cells (both Ice and None expected in scatter \
|
||
band −3°C); got has_ice={has_ice} has_none={has_none} — hash may be degenerate"
|
||
);
|
||
}
|
||
|
||
// ── Determinism ─────────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn cover_derivation_is_deterministic() {
|
||
// Same inputs → same cover, always.
|
||
let district = cover_district(MorphologyZone::Lake, -3.0, 60);
|
||
let col = make_column(Water::Shallow);
|
||
for i in 0..50i32 {
|
||
let pos = (i * CLUSTER_M, i * 3);
|
||
let a = derive_cover(42, "test_body", &district, &col, pos);
|
||
let b = derive_cover(42, "test_body", &district, &col, pos);
|
||
assert_eq!(
|
||
a,
|
||
b,
|
||
"derive_cover must be deterministic at pos ({},{})",
|
||
i * CLUSTER_M,
|
||
i * 3
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cover_end_to_end_via_derive_voxel_column_is_deterministic() {
|
||
// Cover must be deterministic through the full derive_voxel_column path.
|
||
let district = cover_district(MorphologyZone::AlluvialPlain, -12.0, 60);
|
||
let chunk = derive_chunk_context(42, "cold_body", &district, (0, 0), None);
|
||
for (tx, ty) in [(0, 0), (50, 100), (-20, 30), (200, -10)] {
|
||
let a = derive_voxel_column(42, "cold_body", &district, &chunk, tx, ty);
|
||
let b = derive_voxel_column(42, "cold_body", &district, &chunk, tx, ty);
|
||
assert_eq!(
|
||
a.cover, b.cover,
|
||
"cover must be deterministic at ({tx},{ty})"
|
||
);
|
||
}
|
||
}
|
||
}
|