Merge remote-tracking branch 'origin/freeze-snow-model'
This commit is contained in:
+793
-3
@@ -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(®ion.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(world_seed, body_id, region, &column, voxel_pos);
|
||||
|
||||
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,334 @@ 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 ~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,
|
||||
region: &RegionProfile,
|
||||
column: &VoxelColumn,
|
||||
voxel_pos: VoxelPos,
|
||||
) -> 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;
|
||||
|
||||
// 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(®ion.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 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).
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2744,4 +3133,405 @@ 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(42, "test_body", ®ion, &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 region = cover_region(MorphologyZone::Lake, 15.0, 80);
|
||||
region.temperature_c = None;
|
||||
let col = make_column(Water::Deep);
|
||||
let cover = derive_cover(42, "test_body", ®ion, &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 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(42, "test_body", ®ion, &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 region = cover_region(MorphologyZone::Lake, -11.0, 60);
|
||||
let col = make_column(Water::Deep);
|
||||
for i in 0..50i32 {
|
||||
let cover = derive_cover(42, "test_body", ®ion, &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 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(42, "test_body", ®ion, &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 region = cover_region(MorphologyZone::OpenOcean, 0.0, 50);
|
||||
let col = make_column(Water::Deep);
|
||||
for i in 0..30i32 {
|
||||
let cover = derive_cover(42, "test_body", ®ion, &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 region = cover_region(MorphologyZone::OpenOcean, -15.0, 50);
|
||||
let col = make_column(Water::Deep);
|
||||
for i in 0..30i32 {
|
||||
let cover = derive_cover(42, "test_body", ®ion, &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 region = cover_region(MorphologyZone::OpenOcean, 3.0, 50);
|
||||
let col = make_column(Water::Deep);
|
||||
for i in 0..20i32 {
|
||||
let cover = derive_cover(42, "test_body", ®ion, &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 region = cover_region(MorphologyZone::AlluvialPlain, -12.0, 60);
|
||||
let col = make_column(Water::Dry);
|
||||
for i in 0..30i32 {
|
||||
let cover = derive_cover(42, "test_body", ®ion, &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 region = cover_region(MorphologyZone::AlluvialPlain, -12.0, 20);
|
||||
let col = make_column(Water::Dry);
|
||||
for i in 0..30i32 {
|
||||
let cover = derive_cover(42, "test_body", ®ion, &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 region = cover_region(MorphologyZone::AlluvialPlain, 10.0, 80);
|
||||
let col = make_column(Water::Dry);
|
||||
for i in 0..30i32 {
|
||||
let cover = derive_cover(42, "test_body", ®ion, &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 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(42, "test_body", ®ion, &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 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(42, "test_body", ®ion, &dry_col, (0, 0));
|
||||
let wet_cover = derive_cover(42, "test_body", ®ion, &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 region so cover is in the scatter band (mix of Ice/None)
|
||||
// — solid-frozen would pass trivially regardless of coherence.
|
||||
let region = cover_region(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,
|
||||
®ion,
|
||||
&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, ®ion, &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 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 a = derive_cover(42, "test_body", ®ion, &col, pos);
|
||||
let b = derive_cover(42, "test_body", ®ion, &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 region = cover_region(MorphologyZone::AlluvialPlain, -12.0, 60);
|
||||
let chunk = derive_chunk_context(42, "cold_body", ®ion, (0, 0));
|
||||
for (tx, ty) in [(0, 0), (50, 100), (-20, 30), (200, -10)] {
|
||||
let a = derive_voxel_column(42, "cold_body", ®ion, &chunk, tx, ty);
|
||||
let b = derive_voxel_column(42, "cold_body", ®ion, &chunk, tx, ty);
|
||||
assert_eq!(
|
||||
a.cover, b.cover,
|
||||
"cover must be deterministic at ({tx},{ty})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,11 @@ pub enum SeedDomain {
|
||||
/// voxel stream can never collide with the region-scale meander seed (D-224
|
||||
/// domain separation).
|
||||
Voxel = 9,
|
||||
/// Seasonal cover cluster scatter (D-239 §3, T-1030). Keyed by the coarse
|
||||
/// cluster-cell id (zigzag-encoded and Cantor-paired). Distinct from `Voxel`
|
||||
/// so the per-cluster cover hash can never collide with the per-voxel terrain
|
||||
/// stream — domain separation guarantees no freeze-to-terrain correlation.
|
||||
Cover = 10,
|
||||
}
|
||||
|
||||
/// A position in the deterministic seed tree (D-224).
|
||||
@@ -263,6 +268,7 @@ mod tests {
|
||||
assert_eq!(SeedDomain::DomainWarp as u64, 7);
|
||||
assert_eq!(SeedDomain::ChunkContext as u64, 8);
|
||||
assert_eq!(SeedDomain::Voxel as u64, 9);
|
||||
assert_eq!(SeedDomain::Cover as u64, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user