Files
settled-reach/server/src/atlas/region_profile.rs
T
jpmschweitzerandClaude Opus 4.8 961855f276 feat(simulation): climate-derived fields + frozen 17-zone morphology (T-1025, T-1027)
T-1025 (D-239 §2) — climate-derived fields, all from the temperature(+moisture)
primitive:
- precipitation_class + glaciation_grade re-keyed to (temperature_c, moisture_q)
  inputs (were keyed off raw body params); river_threshold signature follows.
- New VegetationClass (Absent/Barren/Scrub/Forest/RiparianScrub/RiparianThicket)
  + derive_vegetation: treeline bands x elevation, structural Forest->Scrub->Barren
  no-skip, riparian 1-3 tile band; airless (temp None) -> Absent.
- 21 tests.

T-1027 (D-239 §5/§6/§7) — morphology classifier:
- FROZEN 17-zone MorphologyZone enum exactly per D-239 §6 (OpenOcean..Wetland,
  repr(u8) pinned). Removed legacy Sea/CoastalLowland/Island/Canyon/Unknown;
  all consumers (skeleton_gen street_topology) remapped to nearest D-239 zone.
- 8-family gated decision tree (LavaField..AlluvialPlain fallback) over integer
  inputs; hard gates: Fjord>=GlaciationGrade2, LavaField=Volcanic tectonic,
  lithology slope/form laws (§8). 4 sub-classification zones (TidalFlat,
  Estuarine, Alpine, Wetland) derived from family + elevation/water.
- Build-time compatibility-matrix invariant tests (§7): MeanderReach<->Volcanic
  and Volcanic<->Fjord forbidden adjacencies structurally prevented by gate order.
- 13 tests incl 17-variant completeness + discriminant pinning.

D-010 integer discipline throughout. cargo test 1534 pass, clippy -D warnings
clean, fmt clean.

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

1962 lines
76 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`,
/// `orbital_period_days`. All are optional (may be NULL in the DB).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BodyParams {
/// `bodies.hydrosphere` — "ocean" | "ice" | "rivers" | "none" | "subsurface" | NULL
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.orbital_period_days` — orbital period in Earth days.
pub orbital_period_days: Option<f64>,
/// `bodies.tectonic_activity` — optional authored tectonic override.
/// "stable" | "active" | "volcanic" | "tidally_forced". If absent, derived
/// from `planet_class`.
pub tectonic_activity: Option<String>,
/// `bodies.axial_tilt_deg` — axial tilt in degrees (T-1024, D-239 §2).
/// Sourced from planet-gen body-def frontmatter.
pub axial_tilt_deg: Option<f64>,
/// `star_systems.spectral_class` — stellar spectral class ("G", "K", "M", …).
/// Used to derive stellar luminosity for temperature calculation (T-1024).
pub spectral_class: Option<String>,
/// `star_systems.star_type` — fallback stellar type if spectral_class is absent.
pub star_type: 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;
// Hyper-arid or extreme cold → Barren regardless of elevation.
// These are the absolute limits; no vegetation survives.
if moisture_q < 5 || temp_i < -50 {
return if near_perennial_water && moisture_q >= 5 {
VegetationClass::RiparianScrub // water creates a micro-oasis even in arid extremes
} 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:
/// - Alpine: IncisedGorge family + elev_q ≥ ALPINE_ELEV_THRESHOLD
/// - Wetland: AlluvialPlain/Delta family + slope_q ≤ 5 + moisture ≥ 60 (§8 Wetland ≤5°)
/// - TidalFlat: AlluvialPlain/CliffCoast family + ocean_fraction_q ≥ TIDAL_OCEAN_MIN + very low elev
/// - Estuarine: Delta family + ocean signal (brackish tidal zone at river mouth)
///
/// D-010: all gates are integer comparisons. No float arithmetic in this function.
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.
///
/// Values mirror `tooling/planet-gen/planet_simulation.py`.
#[derive(Debug, Clone)]
pub struct ClimateConstants {
/// Greenhouse warming offset per atmosphere class (°C).
/// Keys: "thin", "standard", "breathable", "toxic", "dense".
/// "none" / absent = 0 °C (but 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>,
/// Stellar luminosity relative to Sol. Keys: spectral class letter.
pub star_luminosity: std::collections::BTreeMap<String, f64>,
}
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 gh = std::collections::BTreeMap::new();
gh.insert("thin".into(), 8.0f32);
gh.insert("standard".into(), 33.0f32);
gh.insert("breathable".into(), 33.0f32);
gh.insert("toxic".into(), 33.0f32);
gh.insert("dense".into(), 80.0f32);
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);
let mut sl = std::collections::BTreeMap::new();
sl.insert("O".into(), 100_000.0f64);
sl.insert("B".into(), 1_000.0f64);
sl.insert("A".into(), 10.0f64);
sl.insert("F".into(), 2.5f64);
sl.insert("G".into(), 1.0f64);
sl.insert("K".into(), 0.4f64);
sl.insert("M".into(), 0.04f64);
ClimateConstants {
greenhouse_offset_c: gh,
diurnal_amplitude_c: da,
star_luminosity: sl,
}
}
}
impl ClimateConstants {
/// Greenhouse offset for a given atmosphere class (°C), defaulting to 0.
pub fn greenhouse(&self, atmosphere: &str) -> f32 {
self.greenhouse_offset_c
.get(atmosphere)
.copied()
.unwrap_or(0.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)
}
/// Stellar luminosity for a given spectral class (relative to Sol = 1.0).
pub fn luminosity(&self, spectral_class: &str) -> f64 {
// Spectral class is often multi-character like "G2V"; take the first letter.
let key = spectral_class
.chars()
.next()
.map(|c| c.to_uppercase().to_string())
.unwrap_or_default();
self.star_luminosity.get(&key).copied().unwrap_or(1.0)
}
}
/// Derive orbital distance in AU from orbital period (days) and stellar luminosity.
///
/// Kepler's 3rd law (approximation for main-sequence star with mass ≈ luminosity^0.25):
/// a_AU = (T_yr)^(2/3) × (star_mass)^(1/3)
/// star_mass_solar ≈ luminosity^0.25 (main-sequence mass-luminosity relation)
///
/// This is the same derivation used in `planet_simulation.py`.
/// Uses f64 throughout; result is a positional input, not a gate comparison (D-010).
fn derive_distance_au(orbital_period_days: f64, luminosity_solar: f64) -> f64 {
let t_yr = orbital_period_days / 365.25;
// Mass-luminosity: M ≈ L^0.25 (rough but adequate for temperature estimation)
let star_mass = luminosity_solar.powf(0.25);
t_yr.powf(2.0 / 3.0) * star_mass.powf(1.0 / 3.0)
}
/// Derive mean-annual district temperature in °C (nullable).
///
/// Returns `None` if no atmosphere (airless; D-227).
/// Implements D-239 §2: mean-annual scalar, latitude + elevation lapse, greenhouse offset.
/// Orbital phase is fixed at equinox; diurnal swing is carried as `amplitude_c` only.
///
/// Uses f64 for intermediate calculations; the final result is cast to f32
/// (adequate precision for a ~2 km district-mean temperature).
///
/// D-010: all gating decisions downstream use the integer `temperature_c as i32`;
/// the f64/f32 here is positional physics, not a structural comparison.
pub fn derive_temperature_c(params: &BodyParams, constants: &ClimateConstants) -> 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;
}
// Stellar luminosity — from spectral_class or star_type.
let spectral = params
.spectral_class
.as_deref()
.or(params.star_type.as_deref())
.unwrap_or("G");
let luminosity = constants.luminosity(spectral);
// Orbital distance — derive from orbital_period_days if available,
// else fall back to 1 AU (Sol-equivalent distance).
let distance_au = params
.orbital_period_days
.filter(|&d| d > 0.0)
.map(|d| derive_distance_au(d, luminosity))
.unwrap_or(1.0);
// Stellar equilibrium temperature (K) — Stefan-Boltzmann approximation.
let t_equilibrium = 278.5 * luminosity.powf(0.25) / distance_au.sqrt();
// Greenhouse offset (°C from planet_simulation.py tuning).
let greenhouse = constants.greenhouse(atmosphere) as f64;
let t_base = t_equilibrium + greenhouse;
// Latitude gradient. Axial tilt modulates the equator–pole delta.
let axial_tilt = params.axial_tilt_deg.unwrap_or(23.4); // Earth-like default
// tilt_factor: 1.0 = no tilt (full equator–pole gradient), 0.5 = 90° tilt (reduced gradient)
let tilt_factor = 1.0 - (axial_tilt / 90.0) * 0.5;
let lat_gradient_c = 60.0 * tilt_factor;
let lat_frac = params.region_latitude_deg / 90.0; // [-1.0, 1.0]
let t_lat = t_base - lat_gradient_c * lat_frac.abs();
// Elevation lapse rate (°C/km). Earth standard ~6.5 °C/km; airless = 2.
let lapse = if atmosphere == "thin" {
3.5_f64
} else {
6.5_f64
};
let t_final = t_lat - lapse * params.elevation_km.max(0.0);
// Convert from K to °C (subtract 273.15) and clamp to a plausible range.
let t_celsius = (t_final - 273.15).clamp(-200.0, 600.0) as f32;
Some(t_celsius)
}
/// Derive moisture primitive (0–100 integer).
///
/// D-239 §2: moisture is derived from hydrosphere + atmosphere; 0 = arid, 100 = saturated.
/// Integer output (D-010).
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 {
"none" => 0,
"subsurface" => 10,
"ice" => 20,
"rivers" => 55,
"ocean" => 80,
_ => 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 climate functions
/// (T-1024). Pass a `&ClimateConstants` to control the tuning constants.
pub fn derive_region_profile(
// Reserved: per-region stochastic derivation (T-1027/T-1028) will derive
// from this under a dedicated `SeedDomain::RegionProfile`. Unused today.
_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()
};
let temperature_c = derive_temperature_c(&region_climate_params, climate);
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);
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 climate derivation tests
// -----------------------------------------------------------------------
#[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),
None,
"airless body must return None temperature (D-227)"
);
}
#[test]
fn earth_like_body_temperature_plausible() {
// Earth: G-star, 365-day orbit, 23.5° tilt, breathable atmosphere, equator.
let params = BodyParams {
atmosphere: Some("breathable".into()),
spectral_class: Some("G".into()),
orbital_period_days: Some(365.25),
axial_tilt_deg: Some(23.5),
region_latitude_deg: 0.0,
elevation_km: 0.0,
..Default::default()
};
let climate = ClimateConstants::default();
let t = derive_temperature_c(&params, &climate).expect("breathable body must have temp");
// Earth equator is roughly 20–30°C. With our formula T_eq ~278K + 33K greenhouse
// = ~38°C, plausible for equatorial region.
assert!(
(-10.0..=80.0).contains(&t),
"Earth-like equatorial temperature {t}°C out of plausible range [-10, 80]"
);
}
#[test]
fn polar_region_is_colder_than_equatorial() {
let base_params = BodyParams {
atmosphere: Some("breathable".into()),
spectral_class: Some("G".into()),
orbital_period_days: Some(365.25),
axial_tilt_deg: Some(23.5),
elevation_km: 0.0,
..Default::default()
};
let climate = ClimateConstants::default();
let equatorial = BodyParams {
region_latitude_deg: 0.0,
..base_params.clone()
};
let polar = BodyParams {
region_latitude_deg: 90.0,
..base_params
};
let t_eq = derive_temperature_c(&equatorial, &climate).unwrap();
let t_pol = derive_temperature_c(&polar, &climate).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 base_params = BodyParams {
atmosphere: Some("breathable".into()),
spectral_class: Some("G".into()),
orbital_period_days: Some(365.25),
axial_tilt_deg: Some(23.5),
region_latitude_deg: 0.0,
..Default::default()
};
let climate = ClimateConstants::default();
let sea_level = BodyParams {
elevation_km: 0.0,
..base_params.clone()
};
let mountain = BodyParams {
elevation_km: 5.0,
..base_params
};
let t_low = derive_temperature_c(&sea_level, &climate).unwrap();
let t_high = derive_temperature_c(&mountain, &climate).unwrap();
// Lapse rate 6.5°C/km × 5 km = 32.5°C colder.
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;
assert!(
(20.0..=45.0).contains(&delta),
"elevation delta {delta}°C unexpected for 5 km lapse"
);
}
#[test]
fn m_star_body_same_orbit_colder_than_g_star() {
let base_params = BodyParams {
atmosphere: Some("breathable".into()),
orbital_period_days: Some(365.25),
axial_tilt_deg: Some(23.5),
region_latitude_deg: 0.0,
elevation_km: 0.0,
..Default::default()
};
let climate = ClimateConstants::default();
let g_params = BodyParams {
spectral_class: Some("G".into()),
..base_params.clone()
};
let m_params = BodyParams {
spectral_class: Some("M".into()),
..base_params
};
let t_g = derive_temperature_c(&g_params, &climate).unwrap();
let t_m = derive_temperature_c(&m_params, &climate).unwrap();
// M star has luminosity 0.04 × Sol; same orbital period but star is
// much dimmer so equilibrium temperature is much lower.
assert!(
t_m < t_g,
"M-star temperature {t_m}°C must be less than G-star {t_g}°C at same orbital period"
);
}
#[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_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].
let hydros = ["none", "subsurface", "ice", "rivers", "ocean", "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 temperature_is_deterministic() {
let params = BodyParams {
atmosphere: Some("thin".into()),
spectral_class: Some("K".into()),
orbital_period_days: Some(200.0),
axial_tilt_deg: Some(10.0),
region_latitude_deg: 45.0,
elevation_km: 1.5,
..Default::default()
};
let climate = ClimateConstants::default();
let t1 = derive_temperature_c(&params, &climate);
let t2 = derive_temperature_c(&params, &climate);
assert_eq!(t1, t2, "temperature derivation must be deterministic");
}
#[test]
fn climate_constants_spectral_class_prefix_match() {
// "G2V" should resolve to the same luminosity as "G".
let cc = ClimateConstants::default();
assert_eq!(cc.luminosity("G2V"), cc.luminosity("G"));
assert_eq!(cc.luminosity("M5"), cc.luminosity("M"));
// Unknown class falls back to 1.0 (Sol).
assert_eq!(cc.luminosity(""), 1.0);
}
// -----------------------------------------------------------------------
// 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_has_exactly_17_variants() {
// Enumerate all reachable zones through the classifier across a
// representative grid of inputs to verify all 17 are reachable.
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 is not yet emitted by classifier (it's a future ChunkContext
// sub-classification at finer scale); all others must be reachable.
// Assert at least 16 of 17 zones are reachable via the region-scale classifier.
assert!(
seen.len() >= 16,
"only {}/17 zones reachable by classifier: {seen:?}",
seen.len()
);
}
// ── 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)"
);
}
}