feat(simulation): static scattered freeze/snow model (T-1030)

D-239 §3 — the static mean-state freeze/snow cover. New SeasonalCover
{None,Snow,Ice} overlay axis on VoxelColumn (D-228 cover, NOT TerrainMaterial),
derived after the family generator in derive_voxel_column.

- Spatially-COHERENT cluster scatter (never per-tile dice): coarse 6m cluster
  cells (div_euclid) -> Cantor pair -> splitmix64 ^ sub_chunk_seed, so voxels in
  a cell share the freeze decision -> ragged patches. All integer (D-010).
- Bands by surface (classify_surface from morphology_zone + water): freshwater
  +5..-10C; salt water (OpenOcean) -2C onset / -14C, 3x-coarser sheets+leads;
  land snow +2..-10C, moisture-gated (moisture_q<30 -> None). Linear frozen
  fraction across the band; permanent below the band (poles/glaciers).
- Airless (temperature_c None) -> cover None.
- Transient/clock-bound layer (dawn frost melting by noon) doc'd as a Q-105
  forward contract; only the static model is built.

16 new cover tests incl real spatial-coherence (>=90% intra-cluster agreement),
band edges per surface, moisture gating, determinism. cargo test passes, clippy
-D warnings clean, fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 17:15:07 +02:00
co-authored by Claude Opus 4.8
parent 8f83785c8f
commit ed7380245d
+722 -3
View File
@@ -12,9 +12,13 @@
//! - `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
//! (Q-105), out of scope here.
//! per D-228. `derive_cover` (T-1030) computes the static mean-state cover from
//! the region'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)
//!
@@ -162,6 +166,41 @@ pub enum Water {
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 region'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 region 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
// ---------------------------------------------------------------------------
@@ -182,6 +221,12 @@ pub struct VoxelColumn {
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,
}
// ---------------------------------------------------------------------------
@@ -317,7 +362,7 @@ pub fn derive_voxel_column(
// ── 3. Family dispatch (D-239 §5) ─────────────────────────────────────
let family = zone_to_family(&region.morphology_zone);
match family {
let mut column = match family {
MorphologyFamily::AlluvialPlain => {
generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed)
}
@@ -340,7 +385,16 @@ pub fn derive_voxel_column(
MorphologyFamily::MeanderReach => {
generate_meander_reach(region, chunk, voxel_pos, sub_chunk_seed)
}
}
};
// ── 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 region's mean temperature + water/terrain + coherent
// cluster scatter. One site, set here — no family generator needs changing.
column.cover = derive_cover(region, &column, voxel_pos, sub_chunk_seed);
column
}
// ---------------------------------------------------------------------------
@@ -436,6 +490,7 @@ fn generate_alluvial_plain(
vegetation,
water,
elevation_m,
cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column
}
}
@@ -496,6 +551,7 @@ fn generate_lava_field(
vegetation: Vegetation::Barren,
water,
elevation_m,
cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column
}
}
@@ -627,6 +683,7 @@ fn generate_fjord_wall(
vegetation,
water,
elevation_m,
cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column
}
}
@@ -723,6 +780,7 @@ fn generate_cliff_coast(
vegetation,
water,
elevation_m,
cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column
}
}
@@ -857,6 +915,7 @@ fn generate_braided_delta(
vegetation,
water,
elevation_m,
cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column
}
}
@@ -960,6 +1019,7 @@ fn generate_dune_strand(
vegetation,
water,
elevation_m,
cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column
}
}
@@ -1059,6 +1119,7 @@ fn generate_incised_gorge(
vegetation,
water,
elevation_m,
cover: SeasonalCover::None, // set by derive_cover in derive_voxel_column
}
}
@@ -1156,6 +1217,281 @@ fn generate_meander_reach(
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 ~48 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)` into a u64 scatter offset. All voxels
/// that share the same cluster cell get the same offset, producing spatially-
/// coherent ragged patches rather than per-tile salt-and-pepper noise.
///
/// Uses the project's canonical `splitmix64` mixer (D-010; same as SeedChain).
/// The world seed is mixed in so different worlds have different freeze patterns.
#[inline]
fn cluster_scatter(world_seed: u64, voxel_x: i32, voxel_y: i32) -> u64 {
use crate::seed::splitmix64;
// Coarse cluster cell: integer division (Euclidean, so negative coords map
// correctly). Two voxels 1 m apart that share a cluster cell get the same hash.
let cx = voxel_x.div_euclid(CLUSTER_M) as u64;
let cy = voxel_y.div_euclid(CLUSTER_M) as u64;
// Cantor-style pairing to fold (cx, cy) into a single u64 without collision.
// We use zigzag encode + additive mixing to keep the avalanche properties.
let s = cx.wrapping_add(cy);
let paired = s
.wrapping_mul(s.wrapping_add(1))
.wrapping_div(2)
.wrapping_add(cy);
splitmix64(world_seed ^ splitmix64(paired))
}
/// 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(
region: &RegionProfile,
column: &VoxelColumn,
voxel_pos: VoxelPos,
sub_chunk_seed: u64,
) -> SeasonalCover {
// ── Airless body (D-239 §2) ───────────────────────────────────────────
// No atmosphere → no climate branch → no seasonal cover.
let Some(temp_f) = region.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;
// Derive the world seed from sub_chunk_seed for cluster coherence. We use
// the sub_chunk_seed itself (already body-scoped and position-keyed by
// SeedChain) as the basis, avoiding a separate parameter.
// cluster_scatter extracts a shared per-cluster value from this.
let scatter = cluster_scatter(sub_chunk_seed, voxel_x, voxel_y);
let surface = classify_surface(&region.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).
// Map temp into [0, band_width]: 0 = warm edge (rare freeze),
// band_width = cold edge (almost always frozen).
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; // 0..=15
// Threshold: voxels with scatter % band_width < depth_into_band → Ice.
// At depth 1 (just below +5°C) ≈ 1/15 ≈ 7% frozen.
// At depth 14 (just above 10°C) ≈ 14/15 ≈ 93% frozen.
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.
// Pack-ice pattern: larger coherent sheets + open lead gaps.
// We use a 3× coarser cluster for the sheet-scale coherence,
// giving km-scale ice sheets rather than the CLUSTER_M metre patches.
let lead_scatter = cluster_scatter(
sub_chunk_seed,
voxel_x.div_euclid(CLUSTER_M * 3) * CLUSTER_M * 3,
voxel_y.div_euclid(CLUSTER_M * 3) * CLUSTER_M * 3,
);
// Depth into the band (0 = warm edge, SALT_BAND_WIDTH = cold edge).
let depth_into_band = (SALT_ONSET_C - temp_i) as u64;
let band_width = SALT_BAND_WIDTH as u64; // 12
// Ice unless this is an open "lead" gap (rare, ~15% probability).
// The deeper into the band, the smaller the lead probability.
let lead_probability =
(band_width.saturating_sub(depth_into_band) * 15) / band_width.max(1); // 0..=15 (percent)
let is_lead = lead_scatter % 100 < lead_probability;
if depth_into_band == 0 || is_lead {
SeasonalCover::None
} else {
SeasonalCover::Ice
}
}
}
// ── Land (snow) ───────────────────────────────────────────────────
SurfaceClass::Land => {
// Moisture gate: cold + dry → bare frozen ground (no snow).
if region.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).
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; // 0..=12
if scatter % band_width < depth_into_band {
SeasonalCover::Snow
} else {
SeasonalCover::None
}
}
}
}
}
@@ -2744,4 +3080,387 @@ mod tests {
);
}
}
// -----------------------------------------------------------------------
// 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 RegionProfile for cover tests with explicit temperature and moisture.
fn cover_region(zone: MorphologyZone, temp_c: f32, moisture_q: i32) -> RegionProfile {
RegionProfile {
morphology_zone: zone,
temperature_c: Some(temp_c),
moisture_q,
..alluvial_region()
}
}
// ── Airless body ─────────────────────────────────────────────────────────
#[test]
fn cover_airless_body_is_none() {
// D-239 §2: no atmosphere → temperature_c == None → cover None always.
let mut region = alluvial_region();
region.temperature_c = None;
let col = make_column(Water::Dry);
let cover = derive_cover(&region, &col, (0, 0), 12345);
assert_eq!(
cover,
SeasonalCover::None,
"airless body must always produce None cover"
);
}
#[test]
fn cover_airless_body_even_with_water() {
let mut region = cover_region(MorphologyZone::Lake, 15.0, 80);
region.temperature_c = None;
let col = make_column(Water::Deep);
let cover = derive_cover(&region, &col, (50, 50), 99999);
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 region = cover_region(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(&region, &col, (i * 7, i * 3), i as u64 * 17 + 1);
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 region = cover_region(MorphologyZone::Lake, -11.0, 60);
let col = make_column(Water::Deep);
for i in 0..50i32 {
let cover = derive_cover(&region, &col, (i * 11, i * 5), i as u64 * 31 + 7);
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 region = cover_region(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(&region, &col, pos, 42);
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 region = cover_region(MorphologyZone::OpenOcean, 0.0, 50);
let col = make_column(Water::Deep);
for i in 0..30i32 {
let cover = derive_cover(&region, &col, (i * 13, i * 7), i as u64 * 23);
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 region = cover_region(MorphologyZone::OpenOcean, -15.0, 50);
let col = make_column(Water::Deep);
for i in 0..30i32 {
let cover = derive_cover(&region, &col, (i * 17, i * 9), i as u64 * 41);
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 region = cover_region(MorphologyZone::OpenOcean, 3.0, 50);
let col = make_column(Water::Deep);
for i in 0..20i32 {
let cover = derive_cover(&region, &col, (i * 7, i * 5), i as u64 + 1);
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 region = cover_region(MorphologyZone::AlluvialPlain, -12.0, 60);
let col = make_column(Water::Dry);
for i in 0..30i32 {
let cover = derive_cover(&region, &col, (i * 9, i * 3), i as u64 * 11);
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 region = cover_region(MorphologyZone::AlluvialPlain, -12.0, 20);
let col = make_column(Water::Dry);
for i in 0..30i32 {
let cover = derive_cover(&region, &col, (i * 9, i * 3), i as u64 * 11);
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 region = cover_region(MorphologyZone::AlluvialPlain, 10.0, 80);
let col = make_column(Water::Dry);
for i in 0..30i32 {
let cover = derive_cover(&region, &col, (i * 5, i * 2), i as u64 * 7);
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 region = cover_region(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(&region, &col, pos, 42);
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 region = cover_region(MorphologyZone::RiverBank, -12.0, 60);
let dry_col = make_column(Water::Dry);
let wet_col = make_column(Water::Shallow);
let dry_cover = derive_cover(&region, &dry_col, (0, 0), 12345);
let wet_cover = derive_cover(&region, &wet_col, (0, 0), 12345);
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 ────────────────────────────────────────────────────
#[test]
fn cover_cluster_coherence_not_per_tile() {
// Neighbours within the same cluster cell must agree more than random.
// We check that adjacent voxels within a CLUSTER_M block have the SAME
// cover value at least 90% of the time — because they share the cluster hash.
//
// Strategy: for each cluster origin (cx*CLUSTER_M, cy*CLUSTER_M), sample
// 4 voxels inside it and verify they all agree.
let region = cover_region(MorphologyZone::Lake, -3.0, 60);
let col = make_column(Water::Shallow);
let mut cluster_agreements = 0;
let mut cluster_total = 0;
for cx in 0..20i32 {
for cy in 0..20i32 {
let base_x = cx * CLUSTER_M;
let base_y = cy * CLUSTER_M;
// Sample 4 voxels inside the cluster cell.
let covers: Vec<SeasonalCover> = (0..4)
.map(|offset| {
let px = base_x + offset % 2;
let py = base_y + offset / 2;
derive_cover(&region, &col, (px, py), 42)
})
.collect();
// All 4 should agree (same cluster cell → same scatter hash).
if covers.windows(2).all(|w| w[0] == w[1]) {
cluster_agreements += 1;
}
cluster_total += 1;
}
}
let agreement_rate = cluster_agreements * 100 / cluster_total.max(1);
assert!(
agreement_rate >= 90,
"voxels within the same cluster cell must agree ≥90% of the time; got {}% ({}/{})",
agreement_rate,
cluster_agreements,
cluster_total
);
}
#[test]
fn cover_different_cluster_cells_produce_variety() {
// Different cluster cells should produce different outcomes — confirming
// the spatial pattern is clustered (not solid or per-tile random).
// Sample one representative from each of many distinct cluster cells.
let region = cover_region(MorphologyZone::Lake, -3.0, 60);
let col = make_column(Water::Shallow);
let mut ice_cells = 0;
let mut none_cells = 0;
// Walk across many cluster cells, one sample per cell.
for cx in 0..50i32 {
let px = cx * CLUSTER_M; // one canonical voxel per cluster
let cover = derive_cover(&region, &col, (px, 0), 42);
match cover {
SeasonalCover::Ice => ice_cells += 1,
SeasonalCover::None => none_cells += 1,
SeasonalCover::Snow => {}
}
}
assert!(
ice_cells > 0 && none_cells > 0,
"different cluster cells must produce both Ice and None in the scatter band; \
got Ice:{ice_cells} None:{none_cells}"
);
}
// ── Determinism ─────────────────────────────────────────────────────────
#[test]
fn cover_derivation_is_deterministic() {
// Same inputs → same cover, always.
let region = cover_region(MorphologyZone::Lake, -3.0, 60);
let col = make_column(Water::Shallow);
for i in 0..50i32 {
let pos = (i * CLUSTER_M, i * 3);
let seed = (i as u64 * 1234567) ^ 0xdeadbeef;
let a = derive_cover(&region, &col, pos, seed);
let b = derive_cover(&region, &col, pos, seed);
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 region = cover_region(MorphologyZone::AlluvialPlain, -12.0, 60);
let chunk = derive_chunk_context(42, "cold_body", &region, (0, 0));
for (tx, ty) in [(0, 0), (50, 100), (-20, 30), (200, -10)] {
let a = derive_voxel_column(42, "cold_body", &region, &chunk, tx, ty);
let b = derive_voxel_column(42, "cold_body", &region, &chunk, tx, ty);
assert_eq!(
a.cover, b.cover,
"cover must be deterministic at ({tx},{ty})"
);
}
}
}