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>
2190 lines
87 KiB
Rust
2190 lines
87 KiB
Rust
//! 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) = ¶ms.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(®ion_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, ®ion_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(), ¶ms, &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(), ¶ms, &ta, pos, 8, &climate);
|
||
let p2 = derive_region_profile(test_seed(), ¶ms, &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(¶ms), 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(¶ms), 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(), ¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &climate, 12345);
|
||
let t2 = derive_temperature_c(¶ms, &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(¶ms, &climate, 1);
|
||
let t_b = derive_temperature_c(¶ms, &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(¶ms, &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(¶ms, &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(¶ms);
|
||
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(¶ms);
|
||
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(¶ms);
|
||
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(¶ms);
|
||
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)"
|
||
);
|
||
}
|
||
}
|