Files
settled-reach/server/src/atlas/region_profile.rs
T
jpmschweitzerandClaude Opus 4.8 4984d59bf4 fix(simulation): derive_moisture_q canonical hydrosphere vocab (T-1034)
derive_moisture_q matched hydrosphere on 'ocean'/'rivers'/'subsurface', but the
real bodies.hydrosphere vocab in systems.db is dominated by 'liquid_water' (175
bodies) — so most surface-water worlds fell to the '_ => 30' default, propagating
wrong precipitation_class / glaciation_grade / vegetation. Same root cause as
D-240 (code keyed on a vocab the data doesn't use).

Align the match to the canonical vocab, grouped by available surface moisture
(mirrors the [hydrosphere_maritime] table from T-1033): surface liquid
(liquid_water/ocean/ocean-coastal/extensive) -> 80; rivers/rivers-lakes/moderate
-> 55; ice -> 20; subsurface_liquid -> 15; subsurface/subsurface_ice -> 10;
minimal/trace -> 5; none -> 0. Add a liquid_water regression test and broaden the
clamp sweep to the full vocabulary.

Closes T-1034 — last open ticket under epic T-974 (Atlas-to-tile derivation),
which is now complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:01:43 +02:00

2190 lines
87 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! RegionProfile carrier — ~1 km scale tier of the D-239 refinement chain (T-1023).
//!
//! A `RegionProfile` is the coarsest derivation stage: ~1 km² cells (~6 000 per body,
//! roughly 80 × 75), each a pure deterministic function of
//! `(seed, body_params, terrain_analysis, pos)`. Stored in `BodyWorldState.regions`
//! so the Atlas can read zone labels without triggering voxel derivation (D-239 §10,
//! D-203).
//!
//! ## D-010 compliance
//!
//! All gating parameters (`GlaciationGrade`, `TectonicClass`, `river_threshold`) are
//! derived from integer body params via integer arithmetic. No `HashMap` or
//! floating-point comparison in the derivation path.
//!
//! ## MorphologyZone (T-1027, D-239 §6 freeze point)
//!
//! The `morphology_zone` field uses the D-239 §6 **frozen 17-zone** `MorphologyZone`
//! enum from `simulation::generator`. The enum is the canonical freeze; adding,
//! renaming, or removing a zone requires a D-record amendment.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::atlas::features::TerrainAnalysis;
use crate::seed::SeedChain;
use crate::simulation::generator::MorphologyZone;
// ---------------------------------------------------------------------------
// Supporting enums
// ---------------------------------------------------------------------------
/// Tectonic activity class — integer-discriminant, append-only (D-010).
///
/// Used in morphology gates (D-239 §5): LavaField requires `Volcanic`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[repr(u8)]
pub enum TectonicClass {
/// Stable craton — minimal volcanism, low earthquake risk.
#[default]
Stable = 0,
/// Active rifting / orogenesis — mountains, earthquakes, some volcanism.
Active = 1,
/// Dominant volcanic activity — lava fields, shield volcanoes.
Volcanic = 2,
/// Tidally-stressed body — volcanism driven by gravitational flexing.
TidallyForced = 3,
}
/// Glaciation grade (0–4) — integer-discriminant, append-only (D-010).
///
/// D-239 §5 gates: fjord ≥ 2; U-valleys ≥ 1; moraines ≥ 1; cirques ≥ 2;
/// grade 0 = V-ridges, never glacial U.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[repr(u8)]
pub enum GlaciationGrade {
/// No glaciation — V-ridges only.
#[default]
None = 0,
/// Glacial erosion signatures — U-valleys, moraines.
Light = 1,
/// Moderate glaciation — fjords possible, cirques.
Moderate = 2,
/// Heavy glaciation — ice sheets, extensive fjord systems.
Heavy = 3,
/// Full ice-cap or snowball body.
IceCap = 4,
}
/// Precipitation class derived from temperature + moisture primitives (D-239 §2).
///
/// Integer-discriminant, D-010 compliant. Used to derive `river_threshold`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[repr(u8)]
pub enum PrecipitationClass {
/// Arid — very low precipitation.
Arid = 0,
/// Semi-arid — moderate precipitation.
SemiArid = 1,
/// Temperate — normal precipitation.
#[default]
Temperate = 2,
/// Humid — high precipitation.
Humid = 3,
/// Super-humid / oceanic world.
SuperHumid = 4,
}
/// Vegetation cover class (D-239 §2, §8 climate→vegetation law).
///
/// Ordered by density: `Forest` > `Scrub` > `Barren`. The D-239 §8 binding law
/// states "Forest→Scrub→Barren, no skip" — a region cannot jump from Forest to
/// Barren. Integer-discriminant, D-010 compliant.
///
/// `None` variant: airless body (`temperature_c == None`) — entire
/// climate/vegetation branch is absent (D-239 §2).
///
/// Riparian zones (`RiparianThicket`, `RiparianScrub`) are sub-variants of the
/// 1–3 tile band along perennial waterways (D-239 §8).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[repr(u8)]
pub enum VegetationClass {
/// Airless body — climate/vegetation branch absent (D-239 §2).
#[default]
Absent = 0,
/// Barren — above treeline or hyper-arid; sparse or no plant cover.
Barren = 1,
/// Scrub — transitional band below treeline; shrubs, dwarf plants.
Scrub = 2,
/// Forest — below treeline with sufficient moisture; closed-canopy.
Forest = 3,
/// Riparian Scrub — 1–3 tile band along perennial waterways in Scrub/Barren zones.
/// Takes precedence over the base class when adjacent to a perennial river.
RiparianScrub = 4,
/// Riparian Thicket — 1–3 tile band along perennial waterways in Forest zones.
/// Takes precedence over Forest when adjacent to a perennial river.
RiparianThicket = 5,
}
// ---------------------------------------------------------------------------
// Body parameters (input to derivation)
// ---------------------------------------------------------------------------
/// Body-level physical parameters needed to derive `RegionProfile`.
///
/// Modelled on the `BodyRow` reader at `bin/atlas/common.rs`. Source columns
/// live on the `bodies` table: `hydrosphere`, `atmosphere`, `planet_class`.
/// All are optional (may be NULL in the DB).
///
/// **D-240:** orbit/star fields (`orbital_period_days`, `axial_tilt_deg`,
/// `spectral_class`, `star_type`) are non-canonical placeholder data and are
/// NOT read into this struct. Temperature derives from `planet_class` envelope
/// only — see `derive_temperature_c`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BodyParams {
/// `bodies.hydrosphere` — the real vocabulary in systems.db: "ice",
/// "liquid_water", "none", "rivers", "moderate", "ocean", "minimal", "trace",
/// "subsurface_liquid", "rivers-lakes", "extensive", "subsurface_ice",
/// "subsurface", "ocean-coastal" | NULL. The `[hydrosphere_maritime]` table
/// keys on these for temperature moderation (D-240).
pub hydrosphere: Option<String>,
/// `bodies.atmosphere` — "breathable" | "thin" | "toxic" | "none" | "dense" | NULL
pub atmosphere: Option<String>,
/// `bodies.planet_class` — "temperate" | "arid" | "frozen" | "oceanic" | "volcanic" | … | NULL
pub planet_class: Option<String>,
/// `bodies.tectonic_activity` — optional authored tectonic override.
/// "stable" | "active" | "volcanic" | "tidally_forced". If absent, derived
/// from `planet_class`.
pub tectonic_activity: Option<String>,
/// Latitude of the region's centre in the body's reference frame, in degrees.
/// 0.0 = equator, ±90.0 = poles. Used for latitude-band temperature gradient.
pub region_latitude_deg: f64,
/// Mean elevation of this region relative to sea level, in km. Used for lapse rate.
pub elevation_km: f64,
}
// ---------------------------------------------------------------------------
// RegionPos — position on the ~1 km region grid
// ---------------------------------------------------------------------------
/// Position on the per-body ~1 km region grid.
///
/// Each axis is a cell index; a ~6 000-region body has ~80 columns × ~75 rows.
/// `BTreeMap` key — implements `Ord` for D-010 determinism.
pub type RegionPos = (i32, i32);
// ---------------------------------------------------------------------------
// RegionProfile
// ---------------------------------------------------------------------------
/// Per-region (~1 km) terrain classification derived from body params + heightmap.
///
/// Pure derivation — no per-body authoring. All gating parameters come from
/// `BodyParams`; lore-anchored bodies honour their params, not override hooks.
/// Stored in `BodyWorldState.regions` (D-203, D-239 §10).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegionProfile {
/// Region morphology zone (D-239 §6). Uses the **existing** `MorphologyZone`
/// enum — reconciliation to the D-239 frozen 17-zone vocabulary is T-1027.
pub morphology_zone: MorphologyZone,
/// Tectonic activity class (D-239 §5).
pub tectonic_class: TectonicClass,
/// Glaciation grade 0–4 (D-239 §5 gates).
pub glaciation_grade: GlaciationGrade,
/// Precipitation class, derived from hydrosphere + body params.
pub precipitation_class: PrecipitationClass,
/// Integer-quantized slope summary for the region (0–100 scale, units: 1 = ~0.45 °).
/// Derived from the `TerrainAnalysis.slope_deg` cells that fall in this region.
pub slope_q: i32,
/// Integer-quantized elevation percentile summary (0–100 scale).
/// Average of `TerrainAnalysis.elev_pct` × 100 across region cells.
pub elev_q: i32,
/// Ocean fraction for this region (0–100 scale, integer).
pub ocean_fraction_q: i32,
/// Per-region river threshold (D-239 §1). Replaces the global
/// `RIVER_THRESHOLD = 200` for tile-layer consumers. The drainage constant
/// itself is unchanged — this value is what `RegionProfile` carries downstream.
pub river_threshold: i32,
/// Nullable mean-annual temperature in °C (T-1024 forward declaration).
/// `None` if no atmosphere (airless; see D-239 §2, D-227).
pub temperature_c: Option<f32>,
/// Moisture primitive (T-1024 forward declaration).
/// Integer 0–100; 0 = arid, 100 = saturated.
pub moisture_q: i32,
/// Vegetation cover class (T-1025, D-239 §2 §8).
///
/// `VegetationClass::Absent` when `temperature_c == None` (airless body).
/// Forest→Scrub→Barren ordering; no-skip invariant enforced by `derive_vegetation`.
/// Riparian variants override the base class in the 1–3 tile band along
/// perennial waterways (D-239 §8 climate→vegetation law).
pub vegetation_class: VegetationClass,
}
// ---------------------------------------------------------------------------
// Derivation helpers
// ---------------------------------------------------------------------------
/// Derive `TectonicClass` from body params.
///
/// Respects the optional `tectonic_activity` authored override; falls back to
/// `planet_class` derivation. Integer / string comparison only (D-010).
fn derive_tectonic_class(params: &BodyParams) -> TectonicClass {
// Authored override takes precedence.
if let Some(ta) = &params.tectonic_activity {
return match ta.as_str() {
"volcanic" => TectonicClass::Volcanic,
"active" => TectonicClass::Active,
"tidally_forced" => TectonicClass::TidallyForced,
_ => TectonicClass::Stable,
};
}
// Derive from planet_class.
match params.planet_class.as_deref().unwrap_or("") {
"volcanic" => TectonicClass::Volcanic,
"oceanic" | "ocean_world" => TectonicClass::Active,
_ => TectonicClass::Stable,
}
}
/// Derive `PrecipitationClass` from temperature + moisture primitives (D-239 §2).
///
/// Precipitation is f(temperature, moisture). `None` temperature (airless body)
/// → always `Arid` (no precipitation without atmosphere; D-239 §2).
/// All comparisons are on integer `moisture_q` (0–100) and integer °C cast
/// from temperature_c — D-010 compliant (no float gate).
pub fn derive_precipitation_class_from_climate(
temperature_c: Option<f32>,
moisture_q: i32,
) -> PrecipitationClass {
// Airless: no atmosphere → no precipitation cycle.
let Some(temp_c) = temperature_c else {
return PrecipitationClass::Arid;
};
// Integer cast: all gating uses i32 (D-010; f32→i32 cast is a positional
// quantisation, not a structural comparison).
let temp_i = temp_c as i32;
// Very cold + any moisture → frozen world with low effective precipitation.
// Below −40°C the atmosphere holds very little moisture regardless.
if temp_i < -40 {
return if moisture_q >= 20 {
PrecipitationClass::SemiArid
} else {
PrecipitationClass::Arid
};
}
// Main precipitation ladder: moisture_q drives class; temperature modulates.
match moisture_q {
0..=10 => PrecipitationClass::Arid,
11..=25 => PrecipitationClass::SemiArid,
26..=55 => {
// Cold-dry worlds drop one class (thin air → less precipitation cycle).
if temp_i < 0 {
PrecipitationClass::SemiArid
} else {
PrecipitationClass::Temperate
}
}
56..=75 => {
if temp_i < 0 {
PrecipitationClass::Temperate
} else {
PrecipitationClass::Humid
}
}
_ => {
// moisture_q > 75
if temp_i < -10 {
PrecipitationClass::Humid
} else {
PrecipitationClass::SuperHumid
}
}
}
}
/// Derive `GlaciationGrade` from temperature + moisture primitives (D-239 §2, §8).
///
/// D-239 §2: "long-term / seasonal-minimum temperature → GlaciationGrade".
/// D-239 §8 law: "grade 0 = V-ridges, never glacial U".
/// Airless bodies (`temperature_c == None`) → grade 0 (ice is geology, D-227).
/// All gating uses integer °C (D-010).
pub fn derive_glaciation_grade_from_climate(
temperature_c: Option<f32>,
moisture_q: i32,
) -> GlaciationGrade {
// Airless: ice is geology (D-227), no glacial morphology.
let Some(temp_c) = temperature_c else {
return GlaciationGrade::None;
};
let temp_i = temp_c as i32;
// Glaciation needs both cold temperature AND some moisture (D-239 §3: snow
// gated on moisture; cold + dry → bare frozen ground, not glaciers).
// No moisture → no ice accumulation regardless of temperature.
if moisture_q < 10 {
return GlaciationGrade::None;
}
// Temperature bands → glaciation grade.
// These thresholds represent mean-annual temperature; seasonal-minimum
// is always colder, so glaciation forms even with modestly sub-freezing means.
match temp_i {
i32::MIN..=-30 => GlaciationGrade::IceCap, // Perennial ice cover
-29..=-15 => GlaciationGrade::Heavy, // Extensive fjord systems
-14..=-5 => GlaciationGrade::Moderate, // Fjords possible (≥ grade 2 gate)
-4..=5 => GlaciationGrade::Light, // U-valleys, moraines
_ => GlaciationGrade::None, // Temperate/warm: V-ridges only
}
}
/// Derive vegetation class from temperature, moisture, and elevation (D-239 §2, §8).
///
/// Climate→vegetation law (D-239 §8):
/// - treeline band: Forest → Scrub → Barren with NO skipping
/// - riparian: Thicket/Scrub 1–3 tile band along perennial waterways
///
/// Airless body (`temperature_c == None`) → `VegetationClass::Absent`.
/// All comparisons use integer °C + integer elev_q / moisture_q (D-010).
///
/// `near_perennial_water` signals whether this region is within the riparian
/// band (i.e. the region or an adjacent region carries a perennial waterway).
/// When true the riparian sub-variant is returned: `RiparianThicket` over
/// Forest, `RiparianScrub` over Scrub/Barren.
pub fn derive_vegetation(
temperature_c: Option<f32>,
moisture_q: i32,
elev_q: i32,
near_perennial_water: bool,
) -> VegetationClass {
// Airless: entire climate/vegetation branch absent.
let Some(temp_c) = temperature_c else {
return VegetationClass::Absent;
};
let temp_i = temp_c as i32;
// Extreme cold → Barren regardless of water proximity: below ~−50 °C mean
// there is no liquid surface water, so a perennial-waterway micro-oasis is
// physically impossible (surface ice is geology per D-227, not vegetation).
if temp_i < -50 {
return VegetationClass::Barren;
}
// Hyper-arid → Barren, but a perennial waterway sustains a riparian micro-oasis.
if moisture_q < 5 {
return if near_perennial_water {
VegetationClass::RiparianScrub
} else {
VegetationClass::Barren
};
}
// Treeline derivation: elevation × temperature interaction.
// High elevation or cold temperature pushes toward Barren → Scrub → Forest.
// D-239 §8: no-skip guaranteed by ladder structure below.
//
// Treeline elevation (elev_q threshold above which forest cannot form):
// - Warm (temp_i > 10): treeline at elev_q ≈ 75 (alpine zone above)
// - Cool (0–10): treeline at elev_q ≈ 55 (trees don't grow as high)
// - Cold (−15 to 0): treeline at elev_q ≈ 35 (subarctic treeline is low)
// - Very cold (< −15): no forest possible
let base_class = if temp_i < -15 {
// Tundra/ice: Scrub or Barren only.
if moisture_q >= 15 {
VegetationClass::Scrub
} else {
VegetationClass::Barren
}
} else if temp_i < 0 {
// Cold but not ice-cap: Forest possible only at low elevation.
let treeline_q = 35_i32;
if elev_q > treeline_q {
VegetationClass::Barren
} else if elev_q > treeline_q - 15 || moisture_q < 20 {
VegetationClass::Scrub
} else {
VegetationClass::Forest
}
} else if temp_i <= 10 {
// Cool temperate.
let treeline_q = 55_i32;
if elev_q > treeline_q {
VegetationClass::Barren
} else if elev_q > treeline_q - 20 || moisture_q < 20 {
VegetationClass::Scrub
} else {
VegetationClass::Forest
}
} else {
// Warm temperate to tropical.
let treeline_q = 75_i32;
if elev_q > treeline_q {
VegetationClass::Barren
} else if elev_q > treeline_q - 20 || moisture_q < 15 {
VegetationClass::Scrub
} else {
VegetationClass::Forest
}
};
// Riparian override: 1–3 tile band along perennial waterways.
// Upgrades Scrub/Barren → RiparianScrub; upgrades Forest → RiparianThicket.
if near_perennial_water {
match base_class {
VegetationClass::Forest => VegetationClass::RiparianThicket,
VegetationClass::Scrub | VegetationClass::Barren => VegetationClass::RiparianScrub,
// Absent already returned above.
other => other,
}
} else {
base_class
}
}
/// Derive per-region river threshold from tectonic class + precipitation (D-239 §1).
///
/// Replaces the global `RIVER_THRESHOLD = 200` for tile-layer consumers.
/// Higher precipitation → lower threshold (more rivers).
/// All arithmetic is integer (D-010).
pub fn derive_river_threshold(tectonic: TectonicClass, precip: PrecipitationClass) -> i32 {
let tectonic_bonus: i32 = match tectonic {
TectonicClass::Active => -30,
TectonicClass::Volcanic => -20,
TectonicClass::TidallyForced => -10,
TectonicClass::Stable => 0,
};
let precip_factor: i32 = match precip {
PrecipitationClass::Arid => 100,
PrecipitationClass::SemiArid => 50,
PrecipitationClass::Temperate => 0,
PrecipitationClass::Humid => -50,
PrecipitationClass::SuperHumid => -80,
};
// Base of 200 + body-level adjustments, clamped to [20, 500].
(200 + precip_factor + tectonic_bonus).clamp(20, 500)
}
/// Derive `MorphologyZone` from `RegionProfile` gating params (D-239 §5, §6, §7).
///
/// Implements the 8-family decision tree over integer inputs, in the gate order
/// specified by D-239 §5. Hard boolean gates are pre-selection (§5); output is
/// then refined to one of the 17 frozen zone labels (§6) by sub-classification
/// from family + elevation/water-height (§6: four zones are sub-classifications).
///
/// Gate order (D-239 §5, most-constrained first per §7):
/// 1. Water bodies (submerged fraction)
/// 2. LavaField — gate: tectonic == Volcanic (§5, §8 lava law)
/// 3. FjordWall — gate: GlaciationGrade ≥ 2 + slope + coastal (§5)
/// 4. CliffCoast — gate: high slope + coastal + NOT fjord (§8 Rock→vertical)
/// 5. BraidedDelta — gate: very low slope + low elev + coastal (§8 Gravel→braided)
/// 6. DuneStrand — gate: low slope + coastal + moderate elev (§8 Sand→dunes)
/// 7. IncisedGorge — gate: high slope + inland + high elev (→MountainPass label)
/// 8. MeanderReach — gate: gentle slope + some water presence
/// 9. AlluvialPlain — fallback (§5)
///
/// Sub-classifications (§6): after family selection, apply. NOTE: the §6 "parent
/// family" names are descriptive of the typical source, not exhaustive — these
/// zones derive from morphological conditions (low elev + tidal signal; flat +
/// moist) that legitimately overlap several families, so a zone can arise from
/// more than one family context. The actual emission sites:
/// - Alpine: IncisedGorge family + elev_q ≥ ALPINE_ELEV_THRESHOLD.
/// - Wetland: flat (slope_q ≤ 5) + moisture ≥ 60; emitted from the AlluvialPlain fallback AND within the MeanderReach gate (§8 Wetland ≤5° flats).
/// - TidalFlat: very low elev + ocean signal; emitted from the BraidedDelta and DuneStrand gates AND a standalone low-coast gate after Family 5.
/// - Estuarine: Delta family + strong ocean signal (brackish tidal zone, river mouth).
/// - ValleyFloor and RiverBank are their own gates (ValleyFloor between IncisedGorge and MeanderReach; RiverBank within the MeanderReach gate).
/// - BraidedPlain (§6) is NOT emitted at region scale — distinguishing it from Delta needs a lithology signal (§8 Gravel→braided) RegionProfile lacks; deferred to ChunkContext (see the D-239 §6 implementation note).
///
/// D-010: all gates are integer comparisons. No float arithmetic in this function.
pub fn derive_morphology_zone(
tectonic: TectonicClass,
glaciation: GlaciationGrade,
slope_q: i32,
elev_q: i32,
ocean_fraction_q: i32,
moisture_q: i32,
) -> MorphologyZone {
// ── Tier 0: fully submerged ──────────────────────────────────────────────
if ocean_fraction_q >= 80 {
// Very high ocean fraction: open ocean or lake depending on context.
// No body-scale salinity signal at region level yet; treat all as OpenOcean.
// Lake differentiation lives at ChunkContext (D-239 §10).
return MorphologyZone::OpenOcean;
}
if ocean_fraction_q >= 60 {
return MorphologyZone::Lake;
}
// ── Family 1: LavaField ─────────────────────────────────────────────────
// Hard gate: tectonic == Volcanic (D-239 §5; §8 Lava→sheets/shield slopes).
if tectonic == TectonicClass::Volcanic {
return MorphologyZone::Volcanic;
}
// ── Family 2: FjordWall ─────────────────────────────────────────────────
// Hard gate: GlaciationGrade ≥ 2 (D-239 §5) + steep slope + coastal.
// §8: glaciation→form law: fjord ≥ 2.
if glaciation >= GlaciationGrade::Moderate && slope_q >= 40 && ocean_fraction_q >= 20 {
return MorphologyZone::Fjord;
}
// ── Family 3: CliffCoast ────────────────────────────────────────────────
// §8 lithology law: Rock → vertical faces. High slope + coastal + not fjord.
if slope_q >= 55 && ocean_fraction_q >= 15 && elev_q >= 20 {
return MorphologyZone::CliffCoast;
}
// ── Family 4: BraidedDelta ──────────────────────────────────────────────
// §8 lithology law: Gravel → braided channels/fans, not single-thread meander.
// Very flat + low elevation + coastal/water presence.
if slope_q <= 5 && elev_q < 20 && ocean_fraction_q >= 10 {
// Sub-classification: Estuarine if strong ocean signal (brackish tidal zone).
if ocean_fraction_q >= 30 {
return MorphologyZone::Estuarine;
}
// Sub-classification: TidalFlat if very low elev (regularly inundated zone).
if elev_q < 10 && ocean_fraction_q >= 15 {
return MorphologyZone::TidalFlat;
}
return MorphologyZone::Delta;
}
// ── Family 5: DuneStrand ────────────────────────────────────────────────
// §8 lithology law: Sand → ≤~32° angle of repose, dunes not cliffs.
// Low slope + coastal + arid/semi-arid (low moisture → loose sand).
if slope_q <= 20 && ocean_fraction_q >= 15 && elev_q < 30 && moisture_q <= 30 {
// Sub-classification: TidalFlat if very low elev + tidal signal (ocean_fraction_q).
if elev_q < 10 && ocean_fraction_q >= 20 {
return MorphologyZone::TidalFlat;
}
return MorphologyZone::DuneStrand;
}
// Tidal flat also reachable from non-arid low coasts.
if ocean_fraction_q >= 20 && elev_q < 8 && slope_q <= 8 {
return MorphologyZone::TidalFlat;
}
// ── Family 6: IncisedGorge → MountainPass / Alpine ──────────────────────
// High slope + high elevation + inland (non-coastal).
// §5: MountainPass is a zone label sharing IncisedGorge geometry.
// §6: Alpine is a sub-classification from IncisedGorge family + very high elevation.
if slope_q >= 40 && elev_q >= 50 && ocean_fraction_q < 20 {
// Sub-classification: Alpine above treeline elevation threshold.
// elev_q ≥ 75 → alpine zone (no forest, exposed rock/ice).
if elev_q >= 75 {
return MorphologyZone::Alpine;
}
return MorphologyZone::MountainPass;
}
// ValleyFloor: moderate slope + high-ish elevation + enclosed.
if (15..40).contains(&slope_q) && elev_q >= 40 && ocean_fraction_q < 20 {
return MorphologyZone::ValleyFloor;
}
// ── Family 7: MeanderReach ──────────────────────────────────────────────
// §8 lithology law: Soil → rolling/floodplain; single-thread meander.
// Gentle slope + some water presence.
if slope_q <= 20 && ocean_fraction_q >= 5 {
// Sub-classification: Wetland if very flat + high moisture (§8 Wetland ≤5° flats).
if slope_q <= 5 && moisture_q >= 60 {
return MorphologyZone::Wetland;
}
// RiverBank if moderate water presence but not delta/braided.
if (10..30).contains(&ocean_fraction_q) {
return MorphologyZone::RiverBank;
}
return MorphologyZone::MeanderReach;
}
// ── Family 8: AlluvialPlain — fallback ──────────────────────────────────
// §5: fallback for all remaining cases.
// Sub-classification: Wetland if very flat + high moisture.
if slope_q <= 5 && moisture_q >= 60 {
return MorphologyZone::Wetland;
}
MorphologyZone::AlluvialPlain
}
// ---------------------------------------------------------------------------
// Climate constants (T-1024, D-239 §2)
// ---------------------------------------------------------------------------
/// Maximum per-region elevation (km), used to scale a region's normalized
/// elevation (`elev_q`, 0–100) into the temperature-lapse input. Earth-like
/// span (~Everest); a coarse body-agnostic constant for now — a per-body relief
/// span can replace it when body relief data is carried (T-1032).
const MAX_REGION_ELEVATION_KM: f64 = 8.0;
/// Climate tuning constants. `ClimateConstants::default()` holds the embedded
/// values and is the authoritative runtime source **today**; the source-canonical
/// `server/data/climate_constants.toml` mirrors those same values and is the file
/// to edit when tuning. Runtime loading of the TOML ("tunable without recompile")
/// lands with the production dispatch wiring (T-1032) — there is intentionally no
/// `load()` method yet, so keep the TOML and the embedded `default()` in sync by
/// hand until then.
///
/// D-240: temperature derives from `planet_class` envelope, not orbit/star data.
#[derive(Debug, Clone)]
pub struct ClimateConstants {
/// Per-`planet_class` temperature envelope: `(cold_end_c, warm_end_c)` °C.
/// A body can never derive outside its class band (D-240).
/// Keys: "temperate", "arid", "frozen", "tropical", "hot_arid", "volcanic", …
/// Prefix rules applied in `envelope()`: "cold_*" → −10°C shift; "hot_*" → +10°C;
/// "warm_*" → +5°C. Unknown class falls back to "temperate" band.
pub planet_class_temperature: std::collections::BTreeMap<String, (f32, f32)>,
/// Greenhouse warming fraction per atmosphere class (0.0–1.0 of band width).
/// Keys: "thin", "standard", "breathable", "toxic", "dense".
/// "none" / absent = 0.0 (airless bodies return `None` before reaching this).
pub greenhouse_offset_c: std::collections::BTreeMap<String, f32>,
/// Day/night swing amplitude per atmosphere class (°C).
pub diurnal_amplitude_c: std::collections::BTreeMap<String, f32>,
/// Maritime-moderation factor per `hydrosphere` (D-240): scales the
/// equator→pole gradient. `1.0` = full gradient (dry world swings the whole
/// band); `< 1.0` compresses toward the band midpoint (water-rich worlds are
/// milder at both ends). Absent/unknown = `1.0`.
pub hydrosphere_maritime: std::collections::BTreeMap<String, f32>,
}
impl Default for ClimateConstants {
/// Embedded fallback — matches `server/data/climate_constants.toml`.
/// Used when the TOML file is not available (tests, embedded contexts).
fn default() -> Self {
let mut pct = std::collections::BTreeMap::new();
pct.insert("frozen".into(), (-90.0f32, -25.0f32));
pct.insert("ice".into(), (-90.0f32, -25.0f32));
pct.insert("boreal".into(), (-35.0f32, 12.0f32));
pct.insert("cold_arid".into(), (-40.0f32, 20.0f32));
pct.insert("temperate".into(), (-12.0f32, 28.0f32));
pct.insert("oceanic".into(), (-12.0f32, 28.0f32));
pct.insert("subtropical".into(), (2.0f32, 34.0f32));
pct.insert("warm_ocean".into(), (2.0f32, 34.0f32));
pct.insert("tropical".into(), (16.0f32, 40.0f32));
pct.insert("arid".into(), (-5.0f32, 45.0f32));
pct.insert("hot_arid".into(), (20.0f32, 58.0f32));
pct.insert("volcanic".into(), (30.0f32, 90.0f32));
pct.insert("geothermal".into(), (30.0f32, 90.0f32));
let mut gh = std::collections::BTreeMap::new();
gh.insert("thin".into(), 0.10f32);
gh.insert("standard".into(), 0.25f32);
gh.insert("breathable".into(), 0.25f32);
gh.insert("toxic".into(), 0.25f32);
gh.insert("dense".into(), 0.55f32);
let mut da = std::collections::BTreeMap::new();
da.insert("thin".into(), 50.0f32);
da.insert("standard".into(), 15.0f32);
da.insert("breathable".into(), 15.0f32);
da.insert("toxic".into(), 20.0f32);
da.insert("dense".into(), 3.0f32);
// Maritime moderation: water-rich worlds compress the equator–pole gradient.
// Keys are the actual `bodies.hydrosphere` vocabulary in systems.db.
let mut hm = std::collections::BTreeMap::new();
// Large surface liquid — strong moderation.
hm.insert("liquid_water".into(), 0.6f32);
hm.insert("ocean".into(), 0.6f32);
hm.insert("ocean-coastal".into(), 0.6f32);
hm.insert("extensive".into(), 0.6f32);
// Partial surface water — mild moderation.
hm.insert("rivers".into(), 0.8f32);
hm.insert("rivers-lakes".into(), 0.8f32);
hm.insert("moderate".into(), 0.8f32);
// Frozen / subsurface — slight moderation.
hm.insert("ice".into(), 0.85f32);
hm.insert("subsurface_liquid".into(), 0.9f32);
hm.insert("subsurface".into(), 0.95f32);
hm.insert("subsurface_ice".into(), 0.95f32);
// "minimal" / "trace" / "none" / NULL → 1.0 (full gradient) via fallback.
ClimateConstants {
planet_class_temperature: pct,
greenhouse_offset_c: gh,
diurnal_amplitude_c: da,
hydrosphere_maritime: hm,
}
}
}
impl ClimateConstants {
/// Return the temperature envelope `(cold_c, warm_c)` for a planet class.
///
/// Looks up the class directly first. On a miss, applies prefix rules against
/// the "temperate" band: `cold_*` shifts both ends −10 °C, `hot_*` +10 °C,
/// `warm_*` +5 °C. Completely unknown classes fall back to the "temperate" band.
pub fn envelope(&self, planet_class: &str) -> (f32, f32) {
// Direct lookup first.
if let Some(&band) = self.planet_class_temperature.get(planet_class) {
return band;
}
// Prefix rules: shift the temperate fallback band.
let default_band = self
.planet_class_temperature
.get("temperate")
.copied()
.unwrap_or((-12.0, 28.0));
if planet_class.starts_with("cold_") {
(default_band.0 - 10.0, default_band.1 - 10.0)
} else if planet_class.starts_with("hot_") {
(default_band.0 + 10.0, default_band.1 + 10.0)
} else if planet_class.starts_with("warm_") {
(default_band.0 + 5.0, default_band.1 + 5.0)
} else {
default_band
}
}
/// Greenhouse fraction for a given atmosphere class (0.0–1.0), defaulting to 0.0.
pub fn greenhouse(&self, atmosphere: &str) -> f32 {
self.greenhouse_offset_c
.get(atmosphere)
.copied()
.unwrap_or(0.0)
}
/// Maritime-moderation factor for a given `hydrosphere` (D-240): `1.0` = full
/// equator→pole gradient (dry/unknown); `< 1.0` compresses toward the band
/// midpoint (ocean ≈ 0.6 → milder at both ends).
pub fn maritime_factor(&self, hydrosphere: &str) -> f32 {
self.hydrosphere_maritime
.get(hydrosphere)
.copied()
.unwrap_or(1.0)
}
/// Diurnal amplitude for a given atmosphere class (°C), defaulting to 30.
pub fn diurnal_amplitude(&self, atmosphere: &str) -> f32 {
self.diurnal_amplitude_c
.get(atmosphere)
.copied()
.unwrap_or(30.0)
}
}
/// Derive mean-annual district temperature in °C (nullable).
///
/// Implements D-240: temperature derives from `planet_class` envelope only.
/// Orbit/star fields (`orbital_period_days`, `spectral_class`, `star_type`,
/// `axial_tilt_deg`) are non-canonical placeholder data and are NOT inputs.
///
/// Returns `None` if no atmosphere (airless; D-227).
///
/// ## Algorithm (D-240)
///
/// 1. Look up the `(cold, warm)` envelope for `planet_class` via
/// `ClimateConstants::envelope()`. Unknown class falls back to "temperate".
/// 2. **Latitude lerp**: `t_lat = warm - (warm - cold) * (|lat| / 90)` —
/// equator → warm end, pole → cold end.
/// 3. **Atmosphere modulation**: greenhouse fraction nudges `t_lat` toward the
/// warm end by `greenhouse_frac × (warm - cold)`.
/// 4. **Elevation lapse**: subtract `lapse_rate × elevation_km` (thin atmo:
/// 3.5 °C/km, else 6.5 °C/km).
/// 5. **Seed nudge**: deterministic ±~3 °C per-body variation from `body_seed`.
/// 6. **Clamp to `[cold, warm]`** — class band is a hard invariant (D-240).
///
/// Uses f32 throughout (adequate for ~2 km district-mean temperature).
/// D-010: all downstream gating uses `temperature_c as i32`; the float here
/// is positional physics, not a structural comparison.
pub fn derive_temperature_c(
params: &BodyParams,
constants: &ClimateConstants,
body_seed: u64,
) -> Option<f32> {
let atmosphere = params.atmosphere.as_deref().unwrap_or("none");
// No atmosphere → airless body; temperature is None (D-227).
if atmosphere == "none" {
return None;
}
// Step 1: planet_class → (cold, warm) envelope (D-240).
let planet_class = params.planet_class.as_deref().unwrap_or("temperate");
let (cold, warm) = constants.envelope(planet_class);
let band_width = warm - cold;
// Step 2: latitude lerp across the maritime-moderated band (D-240). Water-rich
// worlds compress the equator→pole gradient toward the band midpoint — an ocean
// world is milder at both ends; a dry world swings the full band.
let hydrosphere = params.hydrosphere.as_deref().unwrap_or("none");
let maritime = constants.maritime_factor(hydrosphere);
let mid = (cold + warm) * 0.5;
let half = band_width * 0.5 * maritime;
let lat_frac = (params.region_latitude_deg.abs() as f32 / 90.0).clamp(0.0, 1.0);
// equator (frac 0) → mid + half; pole (frac 1) → mid − half.
let t_lat = (mid + half) - (2.0 * half) * lat_frac;
// Step 3: atmosphere greenhouse nudge — fraction of band_width toward warm end.
let gh_frac = constants.greenhouse(atmosphere);
let t_atmo = t_lat + gh_frac * band_width;
// Step 4: elevation lapse rate (°C/km).
let lapse = if atmosphere == "thin" {
3.5_f32
} else {
6.5_f32
};
let t_lapse = t_atmo - lapse * (params.elevation_km as f32).max(0.0);
// Step 5: seed nudge — deterministic ±~3 °C per-body variety (D-010, D-240).
// Hash body_seed with a mixing constant to produce a per-body offset.
// The nudge is a fraction of the band width, bounded to ±3 °C maximum.
let nudge = {
// Deterministic hash: splitmix64-style single round.
let h = body_seed
.wrapping_add(0x9e37_79b9_7f4a_7c15)
.wrapping_mul(0x6c62_272e_07bb_0142);
// Map to [-1.0, 1.0] and scale to ±3°C.
let unit = (h as i64 as f64 / i64::MAX as f64) as f32;
unit * 3.0_f32
};
let t_nudged = t_lapse + nudge;
// Step 6: clamp to [cold, warm] — class band is a hard invariant (D-240).
Some(t_nudged.clamp(cold, warm))
}
/// Derive moisture primitive (0–100 integer).
///
/// D-239 §2: moisture is derived from hydrosphere + atmosphere; 0 = arid, 100 = saturated.
/// Integer output (D-010).
///
/// The `hydro` arms use the **actual `bodies.hydrosphere` vocabulary in systems.db**
/// — same canonical set the `[hydrosphere_maritime]` table (D-240) keys on — grouped
/// by available surface moisture. Keying on a vocab the data doesn't use (e.g. only
/// `"ocean"`, which is 19 bodies, while 175 use `"liquid_water"`) silently dropped most
/// water worlds to the default (T-1034 — same root cause as D-240).
pub fn derive_moisture_q(params: &BodyParams) -> i32 {
let hydro = params.hydrosphere.as_deref().unwrap_or("none");
let atmo = params.atmosphere.as_deref().unwrap_or("none");
let base: i32 = match hydro {
// Large surface liquid — saturated.
"liquid_water" | "ocean" | "ocean-coastal" | "extensive" => 80,
// Partial surface water (rivers / lakes / seasonal) — moderate.
"rivers" | "rivers-lakes" | "moderate" => 55,
// Frozen surface water — low available moisture.
"ice" => 20,
// Buried water — minimal surface effect.
"subsurface_liquid" => 15,
"subsurface" | "subsurface_ice" => 10,
// Effectively dry.
"minimal" | "trace" => 5,
"none" => 0,
// Unknown vocab — conservative mid-low default.
_ => 30,
};
let atmo_boost: i32 = match atmo {
"none" => -20,
"thin" => -10,
"standard" | "breathable" => 0,
"toxic" => 5,
"dense" => 15,
_ => 0,
};
(base + atmo_boost).clamp(0, 100)
}
// ---------------------------------------------------------------------------
// Public derivation function
// ---------------------------------------------------------------------------
/// Derive a `RegionProfile` for the region at `pos` on a `~1km` grid.
///
/// Pure (no I/O, no side effects). Inputs are the body's params and the
/// pre-computed `TerrainAnalysis` from Layer 1.
///
/// `grid_cells_per_region` controls how many heightmap cells map to one region
/// cell; default is 8 (at 128×64 working grid, that yields ~80×64 regions ≈
/// ~5 000 regions/body, within the D-203 ~6 000/body budget).
///
/// Temperature and moisture are derived inline via D-239 §2 / D-240 climate
/// functions (T-1024). Pass a `&ClimateConstants` to control the tuning constants.
/// The seed chain provides the body-scoped seed for the D-240 temperature nudge.
pub fn derive_region_profile(
seed: SeedChain,
body_params: &BodyParams,
ta: &TerrainAnalysis,
pos: RegionPos,
grid_cells_per_region: usize,
climate: &ClimateConstants,
) -> RegionProfile {
let (rx, ry) = pos;
let w = ta.w;
let h = ta.h;
let gcpr = grid_cells_per_region.max(1);
// Compute aggregate terrain statistics over the cells in this region.
// All arithmetic is integer or quantized-integer (D-010).
let mut slope_sum: i64 = 0;
let mut elev_sum: i64 = 0;
let mut ocean_count: i64 = 0;
let mut cell_count: i64 = 0;
let row_start = (ry as usize).saturating_mul(gcpr).min(h);
let row_end = row_start.saturating_add(gcpr).min(h);
let col_start = (rx as usize).saturating_mul(gcpr).min(w);
let col_end = col_start.saturating_add(gcpr).min(w);
for r in row_start..row_end {
for c in col_start..col_end {
let i = r * w + c;
// Quantize slope to 0–100: slope_deg is ~[0, 45°]; divide by 45 × 100.
// This is a float-to-int boundary inside the aggregation; downstream
// decisions use `slope_q` (integer).
slope_sum += ((ta.slope_deg[i] / 45.0).clamp(0.0, 1.0) * 100.0) as i64;
elev_sum += (ta.elev_pct[i] * 100.0) as i64;
ocean_count += ta.ocean_mask[i] as i64;
cell_count += 1;
}
}
let (slope_q, elev_q, ocean_fraction_q) = if cell_count > 0 {
let sq = (slope_sum / cell_count) as i32;
let eq = (elev_sum / cell_count) as i32;
let oq = (ocean_count * 100 / cell_count) as i32;
(sq, eq, oq)
} else {
(0, 0, 0)
};
let tectonic_class = derive_tectonic_class(body_params);
// Climate derivation (T-1024, D-239 §2). Temperature lapse must vary by THIS
// region's elevation — otherwise every region on a body shares one body-level
// elevation and gets an identical lapse, defeating the per-district temperature
// primitive. Build a region-local BodyParams whose elevation_km comes from the
// region's own normalized elevation (elev_q, 0–100) scaled to the body's
// elevation span. region_latitude_deg is already per-region (set by the caller
// / derive_all_regions). Per-cell refinement happens later at ChunkContext.
let region_climate_params = BodyParams {
elevation_km: (elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM,
..body_params.clone()
};
// D-240: body-scoped seed for the deterministic per-body temperature nudge.
let body_seed = seed.seed();
let temperature_c = derive_temperature_c(&region_climate_params, climate, body_seed);
let moisture_q = derive_moisture_q(body_params);
// Climate-derived fields: computed from temperature + moisture primitives
// (D-239 §2). This is the correct call order — temperature must be resolved
// before precipitation and glaciation are derived from it.
let precipitation_class = derive_precipitation_class_from_climate(temperature_c, moisture_q);
let glaciation_grade = derive_glaciation_grade_from_climate(temperature_c, moisture_q);
let river_threshold = derive_river_threshold(tectonic_class, precipitation_class);
// Vegetation class (T-1025, D-239 §8). No riparian signal at region scale yet
// (requires perennial waterway map from L2+); default to false for now.
// L2 ChunkContext will override per-tile once drainage data is threaded through.
let vegetation_class = derive_vegetation(temperature_c, moisture_q, elev_q, false);
let morphology_zone = derive_morphology_zone(
tectonic_class,
glaciation_grade,
slope_q,
elev_q,
ocean_fraction_q,
moisture_q,
);
RegionProfile {
morphology_zone,
tectonic_class,
glaciation_grade,
precipitation_class,
slope_q,
elev_q,
ocean_fraction_q,
river_threshold,
temperature_c,
moisture_q,
vegetation_class,
}
}
/// Derive region profiles for all regions covering the body.
///
/// Returns a `BTreeMap<RegionPos, RegionProfile>` covering the full
/// heightmap at the given region-grid resolution.
///
/// `grid_cells_per_region = 8` means each region is 8×8 heightmap cells.
///
/// Region latitude is derived from the row index: ry=0 maps to the north pole
/// (+90°), ry=region_rows-1 maps to the south pole (-90°). This is a linear
/// mapping across the equirectangular heightmap.
pub fn derive_all_regions(
seed: SeedChain,
body_params: &BodyParams,
ta: &TerrainAnalysis,
grid_cells_per_region: usize,
) -> BTreeMap<RegionPos, RegionProfile> {
let climate = ClimateConstants::default();
let gcpr = grid_cells_per_region.max(1);
let region_cols = ta.w.div_ceil(gcpr) as i32;
let region_rows = ta.h.div_ceil(gcpr) as i32;
let mut out = BTreeMap::new();
for ry in 0..region_rows {
// Map ry to latitude: row 0 → +90°, row (rows-1) → -90°.
// For a single-row grid, latitude is 0°.
let lat_deg = if region_rows > 1 {
90.0 - (ry as f64 / (region_rows - 1) as f64) * 180.0
} else {
0.0
};
for rx in 0..region_cols {
let pos = (rx, ry);
// Build per-region params: body-level params + this region's latitude.
// Per-region elevation is NOT set here — derive_region_profile owns it,
// deriving elevation_km from the region's own elev_q (not the caller's
// body_params.elevation_km). Per-cell refinement happens at ChunkContext (D-239).
let region_params = BodyParams {
region_latitude_deg: lat_deg,
..body_params.clone()
};
let profile = derive_region_profile(seed, &region_params, ta, pos, gcpr, &climate);
out.insert(pos, profile);
}
}
out
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::atlas::drainage;
use crate::atlas::heightmap::BodyHeightmap;
use crate::seed::SeedDomain;
fn test_hm() -> BodyHeightmap {
let (w, h) = (64u32, 32u32);
let n = (w * h) as usize;
let data = (0..n)
.map(|i| {
let r = (i / w as usize) as f32 / h as f32;
let c = (i % w as usize) as f32 / w as f32;
(r * 0.6 + c * 0.4).min(1.0)
})
.collect();
BodyHeightmap {
body_id: "test".into(),
width: w,
height: h,
data,
sea_level: 0.3,
}
}
fn test_seed() -> SeedChain {
SeedChain::root(42).derive(SeedDomain::Body, 1)
}
fn test_ta(hm: &BodyHeightmap) -> TerrainAnalysis {
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
TerrainAnalysis::analyze(hm, &dr)
}
#[test]
fn derive_all_regions_covers_full_heightmap() {
let hm = test_hm();
let ta = test_ta(&hm);
let params = BodyParams::default();
let regions = derive_all_regions(test_seed(), &params, &ta, 8);
// Expected: ceil(64/8) × ceil(32/8) = 8 × 4 = 32 regions.
assert_eq!(regions.len(), 32, "region count mismatch");
}
#[test]
fn derive_region_profile_is_deterministic() {
let hm = test_hm();
let ta = test_ta(&hm);
// D-240: no orbit/star fields in BodyParams — only class/atmo/hydro.
let params = BodyParams {
hydrosphere: Some("ocean".into()),
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
..Default::default()
};
let pos = (2, 1);
let climate = ClimateConstants::default();
let p1 = derive_region_profile(test_seed(), &params, &ta, pos, 8, &climate);
let p2 = derive_region_profile(test_seed(), &params, &ta, pos, 8, &climate);
// Equality via serialized fields (no PartialEq on MorphologyZone — compare by name).
assert_eq!(
format!("{:?}", p1.morphology_zone),
format!("{:?}", p2.morphology_zone),
"morphology_zone must be deterministic"
);
assert_eq!(p1.tectonic_class, p2.tectonic_class);
assert_eq!(p1.glaciation_grade, p2.glaciation_grade);
assert_eq!(p1.river_threshold, p2.river_threshold);
assert_eq!(p1.slope_q, p2.slope_q);
assert_eq!(p1.elev_q, p2.elev_q);
}
#[test]
fn volcanic_body_gets_volcanic_tectonic() {
let params = BodyParams {
planet_class: Some("volcanic".into()),
..Default::default()
};
assert_eq!(derive_tectonic_class(&params), TectonicClass::Volcanic);
}
#[test]
fn authored_tectonic_override_wins() {
let params = BodyParams {
planet_class: Some("temperate".into()),
tectonic_activity: Some("volcanic".into()),
..Default::default()
};
assert_eq!(derive_tectonic_class(&params), TectonicClass::Volcanic);
}
#[test]
fn airless_body_gets_no_glaciation() {
// Airless: temperature == None → ice is geology, not climate (D-227).
assert_eq!(
derive_glaciation_grade_from_climate(None, 80),
GlaciationGrade::None
);
}
#[test]
fn frozen_body_with_atmo_gets_heavy_glaciation() {
// −20°C mean-annual + adequate moisture → Heavy glaciation.
assert_eq!(
derive_glaciation_grade_from_climate(Some(-20.0), 50),
GlaciationGrade::Heavy
);
}
#[test]
fn arid_body_has_higher_river_threshold() {
// Arid (low precip) → higher river threshold.
let t_arid = derive_river_threshold(TectonicClass::Stable, PrecipitationClass::Arid);
let t_humid = derive_river_threshold(TectonicClass::Stable, PrecipitationClass::SuperHumid);
assert!(
t_arid > t_humid,
"arid body should have higher river threshold than humid body"
);
}
#[test]
fn tectonic_class_discriminants_pinned() {
// Load-bearing: renumbering breaks the D-010 integer derivation contract.
assert_eq!(TectonicClass::Stable as u8, 0);
assert_eq!(TectonicClass::Active as u8, 1);
assert_eq!(TectonicClass::Volcanic as u8, 2);
assert_eq!(TectonicClass::TidallyForced as u8, 3);
}
#[test]
fn glaciation_grade_discriminants_pinned() {
assert_eq!(GlaciationGrade::None as u8, 0);
assert_eq!(GlaciationGrade::Light as u8, 1);
assert_eq!(GlaciationGrade::Moderate as u8, 2);
assert_eq!(GlaciationGrade::Heavy as u8, 3);
assert_eq!(GlaciationGrade::IceCap as u8, 4);
}
#[test]
fn precipitation_class_discriminants_pinned() {
assert_eq!(PrecipitationClass::Arid as u8, 0);
assert_eq!(PrecipitationClass::SemiArid as u8, 1);
assert_eq!(PrecipitationClass::Temperate as u8, 2);
assert_eq!(PrecipitationClass::Humid as u8, 3);
assert_eq!(PrecipitationClass::SuperHumid as u8, 4);
}
#[test]
fn regions_use_btreemap_order() {
let hm = test_hm();
let ta = test_ta(&hm);
let params = BodyParams::default();
let regions = derive_all_regions(test_seed(), &params, &ta, 8);
// BTreeMap iterates in sorted key order — verify the first key is (0,0).
let first = regions.keys().next().expect("at least one region");
assert_eq!(*first, (0, 0), "first region must be at origin");
}
#[test]
fn river_threshold_clamped_to_range() {
// Even with extreme params, threshold stays in [20, 500].
let t_humid = derive_river_threshold(TectonicClass::Active, PrecipitationClass::SuperHumid);
let t_arid = derive_river_threshold(TectonicClass::Stable, PrecipitationClass::Arid);
assert!(
(20..=500).contains(&t_humid),
"humid threshold {t_humid} out of range"
);
assert!(
(20..=500).contains(&t_arid),
"arid threshold {t_arid} out of range"
);
}
// -----------------------------------------------------------------------
// T-1024 / D-240 climate derivation tests — planet_class envelope model
// -----------------------------------------------------------------------
/// Convenience: derive temperature with a fixed seed (0) — deterministic, but
/// note seed 0 still applies its (constant, non-zero) nudge like any other seed.
fn temp(planet_class: &str, atmosphere: &str, lat: f64, elev_km: f64) -> Option<f32> {
let params = BodyParams {
planet_class: Some(planet_class.into()),
atmosphere: Some(atmosphere.into()),
region_latitude_deg: lat,
elevation_km: elev_km,
..Default::default()
};
derive_temperature_c(&params, &ClimateConstants::default(), 0)
}
#[test]
fn airless_body_has_no_temperature() {
let params = BodyParams {
atmosphere: Some("none".into()),
..Default::default()
};
let climate = ClimateConstants::default();
assert_eq!(
derive_temperature_c(&params, &climate, 0),
None,
"airless body must return None temperature (D-227)"
);
}
#[test]
fn polar_region_is_colder_than_equatorial() {
// seed=0 has nudge; compare equatorial vs polar for same body → ordering holds.
let t_eq = temp("temperate", "breathable", 0.0, 0.0).unwrap();
let t_pol = temp("temperate", "breathable", 90.0, 0.0).unwrap();
assert!(
t_pol < t_eq,
"polar temperature {t_pol}°C must be less than equatorial {t_eq}°C"
);
}
#[test]
fn high_elevation_is_colder() {
let t_low = temp("temperate", "breathable", 0.0, 0.0).unwrap();
let t_high = temp("temperate", "breathable", 0.0, 5.0).unwrap();
// Lapse rate 6.5°C/km × 5 km = 32.5°C; nudge same for same seed.
assert!(
t_high < t_low,
"mountain temperature {t_high}°C must be less than sea level {t_low}°C"
);
let delta = t_low - t_high;
// The clamping to [cold, warm] may reduce the apparent delta at the band edge,
// but 5 km lapse should still register at least 5°C.
assert!(
delta >= 5.0,
"elevation delta {delta}°C too small for 5 km lapse"
);
}
#[test]
fn tropical_body_temperature_plausible() {
// Tropical band is [16, 40]°C. Equatorial, sea-level, seed=0.
let t = temp("tropical", "breathable", 0.0, 0.0).unwrap();
assert!(
(16.0..=40.0).contains(&t),
"tropical equatorial temperature {t}°C out of class band [16, 40]"
);
}
#[test]
fn frozen_body_temperature_plausible() {
// Frozen band is [-90, -25]°C.
let t = temp("frozen", "thin", 45.0, 0.0).unwrap();
assert!(
(-90.0..=-25.0).contains(&t),
"frozen temperature {t}°C out of class band [-90, -25]"
);
}
#[test]
fn temperature_is_deterministic() {
// Same inputs → same output, always.
let params = BodyParams {
atmosphere: Some("thin".into()),
planet_class: Some("arid".into()),
region_latitude_deg: 45.0,
elevation_km: 1.5,
..Default::default()
};
let climate = ClimateConstants::default();
let t1 = derive_temperature_c(&params, &climate, 12345);
let t2 = derive_temperature_c(&params, &climate, 12345);
assert_eq!(t1, t2, "temperature derivation must be deterministic");
}
#[test]
fn different_seeds_produce_different_nudges() {
// Two different body seeds should produce slightly different temperatures
// for the same class/lat/elev.
let params = BodyParams {
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
region_latitude_deg: 30.0,
elevation_km: 0.0,
..Default::default()
};
let climate = ClimateConstants::default();
let t_a = derive_temperature_c(&params, &climate, 1);
let t_b = derive_temperature_c(&params, &climate, 999_999_999);
// Both still within the class band, but not identical.
assert_ne!(t_a, t_b, "different seeds should produce different nudges");
let (cold, warm) = climate.envelope("temperate");
assert!(t_a.unwrap() >= cold && t_a.unwrap() <= warm);
assert!(t_b.unwrap() >= cold && t_b.unwrap() <= warm);
}
/// D-240 class-band consistency guard: every planet_class must derive within
/// its `[cold, warm]` band across a full latitude sweep, all atmospheres,
/// and sea level. This is the key acceptance test for T-1033.
#[test]
fn every_planet_class_derives_within_its_band() {
let climate = ClimateConstants::default();
let planet_classes = [
"frozen",
"ice",
"boreal",
"cold_arid",
"temperate",
"oceanic",
"subtropical",
"warm_ocean",
"tropical",
"arid",
"hot_arid",
"volcanic",
"geothermal",
];
let atmospheres = ["thin", "standard", "breathable", "toxic", "dense"];
// Sweep latitudes 0°–90° in 5° steps; elevation = 0.
let latitudes: Vec<f64> = (0..=90).step_by(5).map(|d| d as f64).collect();
let seeds: [u64; 4] = [0, 1, u64::MAX / 2, u64::MAX];
for class in &planet_classes {
let (cold, warm) = climate.envelope(class);
for atmo in &atmospheres {
for &lat in &latitudes {
// Sweep sea level AND high altitude so the lapse term + clamp
// are exercised jointly against the cold end, not just lat alone.
for elev in [0.0_f64, 6.0] {
// Sweep hydrosphere too, so the maritime-compressed gradient
// path is covered by the band invariant (not just None/1.0).
for hydro in ["none", "ocean", "liquid_water", "rivers", "ice"] {
for seed in seeds {
let params = BodyParams {
planet_class: Some((*class).into()),
atmosphere: Some((*atmo).into()),
hydrosphere: Some(hydro.into()),
region_latitude_deg: lat,
elevation_km: elev,
..Default::default()
};
let t = derive_temperature_c(&params, &climate, seed)
.expect("non-airless body must have temperature");
assert!(
t >= cold && t <= warm,
"class={class} atmo={atmo} hydro={hydro} lat={lat} \
elev={elev} seed={seed}: temperature {t}°C outside \
band [{cold}, {warm}]"
);
}
}
}
}
}
}
}
#[test]
fn maritime_hydrosphere_compresses_the_gradient() {
// D-240: a water-rich world has a SMALLER equator→pole temperature delta
// than a dry world of the same class (maritime moderation).
let climate = ClimateConstants::default();
let mk = |hydro: &str, lat: f64| BodyParams {
planet_class: Some("temperate".into()),
atmosphere: Some("standard".into()),
hydrosphere: Some(hydro.into()),
region_latitude_deg: lat,
elevation_km: 0.0,
..Default::default()
};
// Same seed → only hydrosphere differs.
let delta = |hydro: &str| {
(derive_temperature_c(&mk(hydro, 0.0), &climate, 7).unwrap()
- derive_temperature_c(&mk(hydro, 90.0), &climate, 7).unwrap())
.abs()
};
let ocean_delta = delta("ocean");
let dry_delta = delta("none"); // hydrosphere none ≠ airless (atmosphere is "standard")
assert!(
ocean_delta < dry_delta,
"ocean gradient {ocean_delta}°C must be milder than dry {dry_delta}°C"
);
}
#[test]
fn unknown_planet_class_falls_back_to_temperate_band() {
// An unrecognised class string must not panic; it falls back to temperate.
let climate = ClimateConstants::default();
let (cold, warm) = climate.envelope("temperate");
let params = BodyParams {
planet_class: Some("unknown_alien_class".into()),
atmosphere: Some("breathable".into()),
region_latitude_deg: 0.0,
elevation_km: 0.0,
..Default::default()
};
let t = derive_temperature_c(&params, &climate, 0)
.expect("breathable body must have temperature");
assert!(
t >= cold && t <= warm,
"unknown class temperature {t}°C outside temperate band [{cold}, {warm}]"
);
}
#[test]
fn prefix_classes_derive_within_shifted_band() {
// "cold_weird" is not in the table; prefix rule should shift temperate.
let climate = ClimateConstants::default();
let (base_cold, base_warm) = climate.envelope("temperate");
let (cold, warm) = climate.envelope("cold_weird");
assert!(
(cold - (base_cold - 10.0)).abs() < f32::EPSILON * 10.0,
"cold_ prefix should shift cold_end by -10"
);
assert!(
(warm - (base_warm - 10.0)).abs() < f32::EPSILON * 10.0,
"cold_ prefix should shift warm_end by -10"
);
}
#[test]
fn moisture_q_ocean_breathable_is_high() {
let params = BodyParams {
hydrosphere: Some("ocean".into()),
atmosphere: Some("breathable".into()),
..Default::default()
};
let q = derive_moisture_q(&params);
assert!(q >= 70, "ocean + breathable moisture {q} should be >= 70");
}
#[test]
fn moisture_q_liquid_water_is_high_not_default() {
// T-1034 regression: "liquid_water" is the dominant surface-water vocab in
// systems.db (175 bodies). It must map to the high surface-liquid band, not
// silently fall to the `_ => 30` default like it did before the fix.
let params = BodyParams {
hydrosphere: Some("liquid_water".into()),
atmosphere: Some("standard".into()),
..Default::default()
};
let q = derive_moisture_q(&params);
assert!(
q >= 70,
"liquid_water moisture {q} should be >= 70 (surface-liquid band), not the default 30"
);
}
#[test]
fn moisture_q_airless_is_zero() {
let params = BodyParams {
hydrosphere: Some("none".into()),
atmosphere: Some("none".into()),
..Default::default()
};
let q = derive_moisture_q(&params);
assert_eq!(q, 0, "airless no-hydrosphere body moisture must be 0");
}
#[test]
fn moisture_q_clamped_to_range() {
// All combinations must stay in [0, 100]. Sweep the full canonical
// `bodies.hydrosphere` vocabulary (T-1034) plus an unknown fallback.
let hydros = [
"liquid_water",
"ocean",
"ocean-coastal",
"extensive",
"rivers",
"rivers-lakes",
"moderate",
"ice",
"subsurface_liquid",
"subsurface",
"subsurface_ice",
"minimal",
"trace",
"none",
"unknown",
];
let atmos = [
"none",
"thin",
"standard",
"breathable",
"toxic",
"dense",
"unknown",
];
for h in &hydros {
for a in &atmos {
let params = BodyParams {
hydrosphere: Some(h.to_string()),
atmosphere: Some(a.to_string()),
..Default::default()
};
let q = derive_moisture_q(&params);
assert!(
(0..=100).contains(&q),
"moisture_q {q} out of range for hydro={h} atmo={a}"
);
}
}
}
#[test]
fn climate_constants_envelope_direct_lookup() {
// Direct lookup returns the exact registered band.
let cc = ClimateConstants::default();
let (cold, warm) = cc.envelope("tropical");
assert!(
(cold - 16.0).abs() < f32::EPSILON * 10.0,
"tropical cold_end should be 16°C"
);
assert!(
(warm - 40.0).abs() < f32::EPSILON * 10.0,
"tropical warm_end should be 40°C"
);
}
// -----------------------------------------------------------------------
// T-1025: Climate-derived fields — precipitation, glaciation, vegetation
// -----------------------------------------------------------------------
#[test]
fn precipitation_airless_is_arid() {
// D-239 §2: no atmosphere → no precipitation cycle.
assert_eq!(
derive_precipitation_class_from_climate(None, 80),
PrecipitationClass::Arid
);
}
#[test]
fn precipitation_warm_high_moisture_is_superhumid() {
// Warm + very high moisture → SuperHumid.
assert_eq!(
derive_precipitation_class_from_climate(Some(25.0), 90),
PrecipitationClass::SuperHumid
);
}
#[test]
fn precipitation_warm_low_moisture_is_arid() {
// Warm but very low moisture → Arid.
assert_eq!(
derive_precipitation_class_from_climate(Some(30.0), 5),
PrecipitationClass::Arid
);
}
#[test]
fn precipitation_cold_reduces_class() {
// Cold temperature reduces precipitation class relative to moisture alone.
// moisture_q=40 is mid-range; warm → Temperate, cold → SemiArid.
let warm = derive_precipitation_class_from_climate(Some(15.0), 40);
let cold = derive_precipitation_class_from_climate(Some(-5.0), 40);
assert!(
warm >= cold,
"warm precipitation class ({warm:?}) should be >= cold ({cold:?}) at same moisture"
);
}
#[test]
fn glaciation_airless_is_none() {
// D-239 §2: airless body → no glacial morphology.
assert_eq!(
derive_glaciation_grade_from_climate(None, 50),
GlaciationGrade::None
);
}
#[test]
fn glaciation_dry_is_none() {
// Cold but dry → no glaciation (D-239 §3: snow gated on moisture).
assert_eq!(
derive_glaciation_grade_from_climate(Some(-25.0), 5),
GlaciationGrade::None
);
}
#[test]
fn glaciation_grade_temperature_bands() {
// Verify the temperature-band thresholds produce the expected grades.
// IceCap: temp ≤ −30
assert_eq!(
derive_glaciation_grade_from_climate(Some(-35.0), 50),
GlaciationGrade::IceCap
);
// Heavy: −29 to −15
assert_eq!(
derive_glaciation_grade_from_climate(Some(-20.0), 50),
GlaciationGrade::Heavy
);
// Moderate (fjord gate ≥ 2): −14 to −5
assert_eq!(
derive_glaciation_grade_from_climate(Some(-10.0), 50),
GlaciationGrade::Moderate
);
// Light: −4 to 5
assert_eq!(
derive_glaciation_grade_from_climate(Some(0.0), 50),
GlaciationGrade::Light
);
// None: warm
assert_eq!(
derive_glaciation_grade_from_climate(Some(20.0), 50),
GlaciationGrade::None
);
}
#[test]
fn glaciation_fjord_gate_at_moderate() {
// D-239 §5: fjord requires GlaciationGrade ≥ 2 (Moderate).
// Verify that the boundary temperature produces exactly Moderate.
let grade = derive_glaciation_grade_from_climate(Some(-10.0), 50);
assert!(
grade >= GlaciationGrade::Moderate,
"−10°C should produce ≥ Moderate glaciation for fjord gate"
);
}
// -----------------------------------------------------------------------
// T-1025: VegetationClass — derive_vegetation
// -----------------------------------------------------------------------
#[test]
fn vegetation_airless_is_absent() {
// D-239 §2: airless body → vegetation branch absent.
assert_eq!(
derive_vegetation(None, 50, 20, false),
VegetationClass::Absent
);
}
#[test]
fn vegetation_warm_moist_low_elevation_is_forest() {
// Warm + moist + low elevation → Forest.
assert_eq!(
derive_vegetation(Some(20.0), 60, 10, false),
VegetationClass::Forest
);
}
#[test]
fn vegetation_high_elevation_is_barren() {
// Warm + moist but very high elevation → Barren (above treeline).
assert_eq!(
derive_vegetation(Some(20.0), 60, 90, false),
VegetationClass::Barren
);
}
#[test]
fn vegetation_no_skip_forest_to_barren() {
// D-239 §8: Forest→Scrub→Barren, no skip.
// Scan a wide range of elevations; verify no jump from Forest directly to Barren.
let temp = Some(20.0_f32); // warm enough for forest at low elev
let moisture = 60_i32;
let mut prev: Option<VegetationClass> = None;
for elev_q in (0..=100).step_by(5) {
let v = derive_vegetation(temp, moisture, elev_q, false);
if let Some(p) = prev {
// Absent not reachable here (has atmosphere); skip riparian variants.
let is_base = matches!(
v,
VegetationClass::Forest | VegetationClass::Scrub | VegetationClass::Barren
);
let was_base = matches!(
p,
VegetationClass::Forest | VegetationClass::Scrub | VegetationClass::Barren
);
if is_base && was_base {
// Can only decrease by one step (Forest→Scrub or Scrub→Barren)
// or stay the same. Forest→Barren skip is forbidden.
assert!(
!(p == VegetationClass::Forest && v == VegetationClass::Barren),
"vegetation skipped from Forest to Barren at elev_q={elev_q}"
);
}
}
prev = Some(v);
}
}
#[test]
fn vegetation_riparian_upgrades_scrub_to_riparian_scrub() {
// A scrub-zone region near perennial water → RiparianScrub.
let v = derive_vegetation(Some(20.0), 60, 85, true); // high elev = scrub zone
assert_eq!(v, VegetationClass::RiparianScrub);
}
#[test]
fn vegetation_riparian_upgrades_forest_to_riparian_thicket() {
// A forest-zone region near perennial water → RiparianThicket.
let v = derive_vegetation(Some(20.0), 60, 10, true); // low elev = forest zone
assert_eq!(v, VegetationClass::RiparianThicket);
}
#[test]
fn vegetation_class_discriminants_pinned() {
// Load-bearing: renumbering breaks D-010 integer derivation contract.
assert_eq!(VegetationClass::Absent as u8, 0);
assert_eq!(VegetationClass::Barren as u8, 1);
assert_eq!(VegetationClass::Scrub as u8, 2);
assert_eq!(VegetationClass::Forest as u8, 3);
assert_eq!(VegetationClass::RiparianScrub as u8, 4);
assert_eq!(VegetationClass::RiparianThicket as u8, 5);
}
#[test]
fn vegetation_hyper_arid_is_barren_not_absent() {
// With atmosphere but zero moisture → Barren (not Absent).
assert_eq!(
derive_vegetation(Some(20.0), 0, 10, false),
VegetationClass::Barren
);
}
#[test]
fn precipitation_class_keyed_on_temp_and_moisture() {
// Verify the derivation is actually f(temperature, moisture), not just moisture.
// Same moisture_q=40, cold vs warm → different class.
let warm = derive_precipitation_class_from_climate(Some(20.0), 40);
let cold = derive_precipitation_class_from_climate(Some(-10.0), 40);
// Cold should produce a lower or equal class.
assert!(
(warm as u8) >= (cold as u8),
"warm precip ({warm:?}) should be >= cold ({cold:?}) at moisture_q=40"
);
}
// -----------------------------------------------------------------------
// T-1027: Morphology classifier — frozen 17-zone vocab + 8-family gates
// + seam-matrix compatibility invariant (D-239 §5, §6, §7)
// -----------------------------------------------------------------------
/// Helper to call `derive_morphology_zone` with a complete set of defaults,
/// overriding only the parameters relevant to the test.
fn zone(
tectonic: TectonicClass,
glaciation: GlaciationGrade,
slope_q: i32,
elev_q: i32,
ocean_fraction_q: i32,
moisture_q: i32,
) -> MorphologyZone {
derive_morphology_zone(
tectonic,
glaciation,
slope_q,
elev_q,
ocean_fraction_q,
moisture_q,
)
}
fn zone_defaults() -> MorphologyZone {
zone(TectonicClass::Stable, GlaciationGrade::None, 10, 30, 0, 30)
}
// ── 17-zone vocabulary completeness ──────────────────────────────────────
#[test]
fn morphology_zone_discriminants_pinned() {
// D-239 §6 freeze point: discriminant values must never change.
assert_eq!(MorphologyZone::OpenOcean as u8, 0);
assert_eq!(MorphologyZone::Lake as u8, 1);
assert_eq!(MorphologyZone::TidalFlat as u8, 2);
assert_eq!(MorphologyZone::DuneStrand as u8, 3);
assert_eq!(MorphologyZone::CliffCoast as u8, 4);
assert_eq!(MorphologyZone::Fjord as u8, 5);
assert_eq!(MorphologyZone::Delta as u8, 6);
assert_eq!(MorphologyZone::Estuarine as u8, 7);
assert_eq!(MorphologyZone::AlluvialPlain as u8, 8);
assert_eq!(MorphologyZone::RiverBank as u8, 9);
assert_eq!(MorphologyZone::MeanderReach as u8, 10);
assert_eq!(MorphologyZone::BraidedPlain as u8, 11);
assert_eq!(MorphologyZone::ValleyFloor as u8, 12);
assert_eq!(MorphologyZone::MountainPass as u8, 13);
assert_eq!(MorphologyZone::Alpine as u8, 14);
assert_eq!(MorphologyZone::Volcanic as u8, 15);
assert_eq!(MorphologyZone::Wetland as u8, 16);
}
#[test]
fn morphology_zone_region_scale_emits_16_of_17() {
// Enumerate the zones the region-scale classifier can emit across a
// representative input grid. 16 of the 17 frozen zones are reachable here;
// BraidedPlain is the lone exception — see the assertion comment below.
use std::collections::BTreeSet;
let mut seen: BTreeSet<u8> = BTreeSet::new();
// All possible combinations of key inputs.
let tectonics = [TectonicClass::Stable, TectonicClass::Volcanic];
let glaciations = [
GlaciationGrade::None,
GlaciationGrade::Moderate,
GlaciationGrade::Heavy,
];
for tec in &tectonics {
for gl in &glaciations {
for slope in [0, 5, 10, 20, 40, 55, 70] {
for elev in [0, 5, 8, 15, 20, 30, 40, 50, 60, 75, 90] {
for ocean in [0, 5, 10, 15, 20, 30, 60, 80, 90] {
for moist in [0, 10, 30, 60, 80] {
let z = zone(*tec, *gl, slope, elev, ocean, moist);
seen.insert(z as u8);
}
}
}
}
}
}
// BraidedPlain (discriminant 11) is the ONE frozen zone the region-scale
// classifier never emits: distinguishing it from Delta needs a lithology
// signal (§8 Gravel→braided) that RegionProfile doesn't carry yet, so it is
// deferred to ChunkContext (D-239 §6 implementation note). All other 16 are
// reachable. (Lake IS reachable — ocean_fraction 60–79 → Lake, ≥80 → OpenOcean.)
assert_eq!(
seen.len(),
16,
"expected exactly 16/17 region-reachable zones (BraidedPlain deferred), got: {seen:?}"
);
}
// ── Family hard gates ──────────────────────────────────────────────────
#[test]
fn volcanic_gate_requires_tectonic_volcanic() {
// D-239 §5: LavaField requires tectonic_class == Volcanic.
let non_volcanic = zone(TectonicClass::Stable, GlaciationGrade::None, 10, 30, 0, 30);
assert_ne!(
non_volcanic,
MorphologyZone::Volcanic,
"non-volcanic tectonic must not produce Volcanic zone"
);
let volcanic = zone(
TectonicClass::Volcanic,
GlaciationGrade::None,
30,
30,
5,
30,
);
assert_eq!(
volcanic,
MorphologyZone::Volcanic,
"Volcanic tectonic must produce Volcanic zone"
);
}
#[test]
fn fjord_gate_requires_glaciation_moderate_or_above() {
// D-239 §5: fjord requires GlaciationGrade ≥ 2 (Moderate).
// Coastal + steep but grade 0 → not Fjord.
let no_glaciation = zone(
TectonicClass::Stable,
GlaciationGrade::None,
50, // high slope
25,
30, // coastal
40,
);
assert_ne!(
no_glaciation,
MorphologyZone::Fjord,
"GlaciationGrade::None must not produce Fjord"
);
// GlaciationGrade::Light (grade 1) also must not produce Fjord.
let light_glaciation = zone(
TectonicClass::Stable,
GlaciationGrade::Light,
50,
25,
30,
40,
);
assert_ne!(
light_glaciation,
MorphologyZone::Fjord,
"GlaciationGrade::Light must not produce Fjord"
);
// Moderate (grade 2) + correct slope + coastal → Fjord.
let fjord = zone(
TectonicClass::Stable,
GlaciationGrade::Moderate,
50, // slope_q ≥ 40
25,
30, // ocean_fraction_q ≥ 20
40,
);
assert_eq!(
fjord,
MorphologyZone::Fjord,
"Moderate glaciation should produce Fjord"
);
}
#[test]
fn volcanic_zone_overrides_fjord_gate() {
// D-239 §5: LavaField gate (volcanic tectonic) is tested before FjordWall.
// Even with GlaciationGrade::Heavy + correct slope + coastal,
// volcanic tectonic wins.
let z = zone(
TectonicClass::Volcanic,
GlaciationGrade::Heavy,
50,
25,
30,
40,
);
assert_eq!(
z,
MorphologyZone::Volcanic,
"Volcanic tectonic must override fjord gate (LavaField before FjordWall)"
);
}
#[test]
fn alpine_subclassification_from_high_elevation() {
// §6: Alpine is sub-classification of IncisedGorge family + elev_q ≥ 75.
let alpine = zone(
TectonicClass::Stable,
GlaciationGrade::None,
50, // steep slope
80, // very high elevation
5, // inland
30,
);
assert_eq!(
alpine,
MorphologyZone::Alpine,
"high elev + steep slope → Alpine"
);
// Same slope but lower elevation → MountainPass, not Alpine.
let mountain_pass = zone(
TectonicClass::Stable,
GlaciationGrade::None,
50,
60, // below Alpine threshold
5,
30,
);
assert_eq!(
mountain_pass,
MorphologyZone::MountainPass,
"moderate-high elev + steep slope → MountainPass, not Alpine"
);
}
#[test]
fn wetland_requires_flat_and_moist() {
// §8: Wetland ≤5° flats + high moisture.
let wetland = zone(
TectonicClass::Stable,
GlaciationGrade::None,
3, // slope_q ≤ 5
20,
5,
70, // moisture_q ≥ 60
);
assert_eq!(wetland, MorphologyZone::Wetland, "flat + moist → Wetland");
// Flat but dry → not Wetland.
let not_wetland = zone(
TectonicClass::Stable,
GlaciationGrade::None,
3,
20,
5,
20, // moisture < 60
);
assert_ne!(
not_wetland,
MorphologyZone::Wetland,
"flat + dry must not be Wetland"
);
}
#[test]
fn tidal_flat_requires_low_elevation_and_ocean() {
// §6: TidalFlat sub-classification from coastal + very low elev.
let tidal = zone(
TectonicClass::Stable,
GlaciationGrade::None,
5, // gentle slope
5, // very low elev (< 10)
25, // ocean signal ≥ 20
30,
);
assert_eq!(
tidal,
MorphologyZone::TidalFlat,
"low elev + coastal → TidalFlat"
);
}
#[test]
fn estuarine_requires_delta_plus_ocean() {
// §6: Estuarine sub-classification from Delta family + strong ocean signal.
let estuarine = zone(
TectonicClass::Stable,
GlaciationGrade::None,
3, // very flat
15, // low elevation
35, // ocean_fraction_q ≥ 30 → Estuarine
40,
);
assert_eq!(
estuarine,
MorphologyZone::Estuarine,
"delta + ocean → Estuarine"
);
// Weaker ocean signal stays as Delta.
let delta = zone(
TectonicClass::Stable,
GlaciationGrade::None,
3,
15,
15, // ocean_fraction_q < 30 → Delta
40,
);
assert_eq!(delta, MorphologyZone::Delta, "delta + low ocean → Delta");
}
#[test]
fn alluvial_plain_is_fallback() {
// D-239 §5: AlluvialPlain is the fallback when no other gate fires.
let z = zone_defaults();
assert_eq!(
z,
MorphologyZone::AlluvialPlain,
"default inputs → AlluvialPlain fallback"
);
}
// ── Build-time compatibility-matrix invariant (D-239 §7) ─────────────────
//
// §7: incompatible family pairs cannot be adjacent classifier outputs.
// This test encodes the incompatibility matrix and asserts the classifier's
// gate ordering structurally cannot emit a forbidden adjacency.
//
// Valid sharp seams (cliff↔fjord at glaciation threshold, lithology faults)
// are PERMITTED. The test validates the gate structure, not runtime adjacency.
#[test]
fn compatibility_matrix_meander_volcanic_cannot_be_adjacent() {
// D-239 §7: MeanderReach ↔ Volcanic is a forbidden pair.
// This is structurally impossible because the Volcanic gate (family 1)
// fires for ALL tectonic==Volcanic inputs regardless of other params,
// and MeanderReach requires tectonic != Volcanic.
// Verify: any input producing MeanderReach cannot also produce Volcanic.
let produces_meander_with_stable = zone(
TectonicClass::Stable, // non-volcanic
GlaciationGrade::None,
10,
25,
8, // some water → MeanderReach territory
35,
);
// Doesn't matter if this particular call produces MeanderReach, but
// any call with TectonicClass::Volcanic must produce Volcanic, not MeanderReach.
let volcanic_body_zone = zone(
TectonicClass::Volcanic,
GlaciationGrade::None,
10,
25,
8,
35,
);
assert_eq!(
volcanic_body_zone,
MorphologyZone::Volcanic,
"Volcanic tectonic must always produce Volcanic — never MeanderReach"
);
assert_ne!(
produces_meander_with_stable,
MorphologyZone::Volcanic,
"Stable tectonic cannot produce Volcanic"
);
}
#[test]
fn compatibility_matrix_fjord_requires_glaciation_gate() {
// §7: Fjord and AlluvialPlain cannot be adjacent without a glaciation
// discontinuity. The gate (GlaciationGrade ≥ 2) enforces this:
// inputs just below the fjord gate can only produce non-fjord zones.
let below_gate = zone(
TectonicClass::Stable,
GlaciationGrade::Light, // grade 1, below the fjord gate
50,
25,
30,
40,
);
assert_ne!(
below_gate,
MorphologyZone::Fjord,
"GlaciationGrade::Light is below fjord gate — must not produce Fjord"
);
// One grade above the gate (Moderate = 2) → Fjord.
let above_gate = zone(
TectonicClass::Stable,
GlaciationGrade::Moderate,
50,
25,
30,
40,
);
assert_eq!(
above_gate,
MorphologyZone::Fjord,
"GlaciationGrade::Moderate is at fjord gate — must produce Fjord"
);
}
#[test]
fn compatibility_matrix_volcanic_cannot_neighbour_fjord_directly() {
// §7: Volcanic ↔ Fjord adjacency is structurally prevented.
// Fjord requires non-volcanic tectonic (volcanic gate fires first).
// So any Volcanic zone cannot produce Fjord with the same tectonic.
// The only way they could neighbour is across a tectonic fault —
// which is a valid sharp seam (D-239 §7: permitted).
// This test confirms the gate ordering: volcanic check happens BEFORE fjord.
let fjord_attempt_with_volcanic = zone(
TectonicClass::Volcanic,
GlaciationGrade::Heavy, // would qualify for fjord if not volcanic
50,
25,
30,
40,
);
assert_eq!(
fjord_attempt_with_volcanic,
MorphologyZone::Volcanic,
"Volcanic tectonic must prevent Fjord production (gate order)"
);
}
}