Files
settled-reach/server/src/atlas/region_profile.rs
T
jpmschweitzerandClaude Opus 4.8 bb5069aa13 feat(simulation): district moisture spatial gradient (T-1080)
moisture_q was a single body constant (derive_moisture_q took only &BodyParams →
80 for every one of 2048 ocean-world districts), so vegetation/terrain/ecotones
were uniform — "nothing to fuzz" (the T-1080 believability bug).

- district_profile.rs: derive_moisture_q is now a body ceiling (hydrosphere+atmosphere)
  × per-district gradient — latitude (equator wet → pole dry), elevation (orographic/
  rain-shadow), continentality (100 − ocean_fraction_q). Integer/D-010, no new plumbing
  (elev_q/ocean_fraction_q/latitude_deg already reach build_district_profile). Mirrors
  D-240's temperature model. Coefficients in ClimateConstants + climate_constants.toml
  [moisture_gradient] (provisional; Q-123 calibrates). Tuned so a wet body stays mostly
  green with drier patches and a frozen body clamps mostly barren. + gradient unit test.
- region_profile.rs: region-tier moisture gets the region-latitude gradient (elevation/
  continentality are district-scale).
- Propagates to precipitation_class / vegetation_class / morphology_zone (all read
  moisture_q). D-239 §2 amended. Believability golden regenerated.

Probe (Arbour @ yolo): moisture_q distinct 1 → 49 (spread 0 → 48); 5/7 → 6/7 criteria.
Edict (frozen): moisture 0..20 ×21 — gradient within its low ceiling. Remaining fail
(vegetation-present) is the metric dividing by all-sampled incl. water on a 2/3-ocean
world — a Q-123 calibration concern, not a climate defect.

clippy --all-targets -D warnings clean; 1580 lib tests + harnesses pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:25:18 +02:00

887 lines
36 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.
//! Region climate stack — the ~205 km top hard block (D-243 §3 / §4, T-1078).
//!
//! A [`RegionProfile`] holds the **region-level climate context**: latitude-driven
//! temperature baseline, weather state, seasonal clock structure. Every district and
//! tile inside the region inherits these values, then applies its own modulation
//! (elevation lapse + slope aspect at the district tier; freeze/snow scatter at
//! the chunk/voxel tier).
//!
//! ## The climate three-level stack (D-243 §3)
//!
//! ```text
//! Region (~205 km) — climate context: baseline temperature, weather state, season
//! ↓ modulation: elevation lapse + slope aspect
//! District (2 km) — local temperature = baseline + lapse + aspect
//! ↓ scatter: freeze/snow (D-239 §3)
//! Chunk/Voxel (64 m / 1 m) — cover scatter
//! ```
//!
//! ## Edge fuzz (D-243 §4)
//!
//! Climate does not change on a line. A tile's climate value is a **continuous,
//! warp-perturbed bilinear blend of surrounding region baselines** — the same
//! meta-rule as [D-239 §4]'s domain warp for terrain, but for the climate scalar
//! field. [`region_baseline_at_district`] implements this: it looks up the four
//! surrounding region baselines and blends them with a noise-displaced bilinear
//! interpolation.
//!
//! ## Q-105 deferral
//!
//! The `RegionClock` structure is scaffolded here (season + weather state) to
//! unblock Q-105's transient clock callbacks. The mean-state derivation and the
//! edge-fuzz blend are fully implemented; the **transient clock-phase tick
//! callbacks** (seasonal frost form/melt, crop-cycle cadence, recompute schedule)
//! are deferred to Q-105. `RegionClock` carries field-level Q-105 annotations.
//!
//! ## D-010 compliance
//!
//! All structural gating decisions use integer arithmetic. `f64` is used only for
//! positional computations (bilinear weights, warp displacement) that are then
//! quantised before any structural decision is made.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::atlas::district_profile::{BodyParams, ClimateConstants};
use crate::atlas::scale::{self, DistrictPos, RegionPos};
use crate::seed::{splitmix64, SeedChain};
// ---------------------------------------------------------------------------
// SeasonPhase — the region's current seasonal position
// ---------------------------------------------------------------------------
/// Broad seasonal phase for a region's clock (Q-105 forward contract).
///
/// The discriminants are **pinned and append-only** (D-010). The tick callbacks
/// that advance through these phases are deferred to Q-105 — today only the
/// struct is scaffolded.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[repr(u8)]
pub enum SeasonPhase {
/// Warmest quarter of the year — long days, peak vegetation.
#[default]
Summer = 0,
/// Cooling quarter — harvest, leaf-fall, falling precipitation.
Autumn = 1,
/// Coldest quarter of the year — short days, peak freeze extent.
Winter = 2,
/// Warming quarter — melt, sowing, rising temperatures.
Spring = 3,
}
// ---------------------------------------------------------------------------
// WeatherState — the region's weather snapshot
// ---------------------------------------------------------------------------
/// Coarse weather state for a region (Q-105 forward contract).
///
/// The **mean-state** used today is `Clear` (no active weather event). Q-105
/// will provide the tick that advances through these states based on the
/// region's climate class and seasonal phase.
///
/// Integer-discriminant, append-only (D-010).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[repr(u8)]
pub enum WeatherState {
/// Clear sky — no active weather event.
#[default]
Clear = 0,
/// Overcast — cloud cover, diffuse light, reduced heat gain.
Overcast = 1,
/// Rain — precipitation in liquid form (temperature ≥ 0 °C).
Rain = 2,
/// Snow — precipitation in solid form (temperature < 0 °C).
Snow = 3,
/// Blizzard — heavy snow + high wind; severe passability impact.
Blizzard = 4,
/// Dust storm — driven by arid conditions + wind.
DustStorm = 5,
}
// ---------------------------------------------------------------------------
// RegionClock — scaffolded seasonal/weather clock (Q-105 forward contract)
// ---------------------------------------------------------------------------
/// Region-level time state: the seasonal clock and active weather.
///
/// Today only the **mean-state struct is scaffolded** (season = Summer,
/// weather = Clear, all clock fields at their neutral values). The tick
/// callbacks that drive seasonal transitions and weather events are
/// **deferred to Q-105**.
///
/// Consumers needing the mean-state (static seasonal derivation) read this
/// struct directly. Q-105 will extend `RegionClock` with a tick-phase callback
/// and the `recompute_schedule` logic — the base fields here are load-bearing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegionClock {
/// Current broad seasonal phase (Q-105: ticked by the region clock).
pub season: SeasonPhase,
/// Active weather event for the region (Q-105: ticked by the weather cycle).
pub weather: WeatherState,
/// Mean-annual temperature baseline at the region centre (°C), `None` for
/// airless bodies. This is the **pre-edge-fuzz** region-level baseline
/// derived from `planet_class` + latitude + greenhouse nudge (D-243 §3).
///
/// District temperatures are derived by modulating this value with elevation
/// lapse and slope aspect — see [`district_profile::derive_district_temperature_c`].
pub mean_temp_c: Option<f32>,
}
impl Default for RegionClock {
fn default() -> Self {
Self {
season: SeasonPhase::Summer,
weather: WeatherState::Clear,
mean_temp_c: None,
}
}
}
// ---------------------------------------------------------------------------
// RegionProfile — the region climate carrier
// ---------------------------------------------------------------------------
/// Per-region (~205 km) climate context (D-243 §3, T-1078).
///
/// Derived once per region; every district inside inherits it and applies
/// its own modulation (elevation lapse, slope aspect). The edge-fuzz blend
/// ([`region_baseline_at_district`]) ensures the ~205 km grid is invisible in
/// the output — temperatures grade smoothly and raggedly across region boundaries.
///
/// **Derivation inputs:** `(seed, body_params, region_pos)`. Pure and
/// deterministic — no I/O, no side effects (D-010 / D-227).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegionProfile {
/// Region position (region-units) — the key in `BodyWorldState.regions`.
pub pos: RegionPos,
/// Climate context for this region: baseline temperature, season, weather.
pub clock: RegionClock,
/// Latitude of the region centre in the body's reference frame (degrees).
/// 0.0 = equator, ±90.0 = poles. Derived from `region_pos`.
pub latitude_deg: f32,
/// Region-level moisture primitive (0–100), inherited from body params.
/// Moisture modulation is body-scale for now; per-district refinement lives
/// in `DistrictProfile.moisture_q`.
pub moisture_q: i32,
}
// ---------------------------------------------------------------------------
// Region baseline derivation
// ---------------------------------------------------------------------------
/// Derive the region-level mean-annual temperature baseline in °C.
///
/// This is **step (a)** of the D-239 §2 / D-243 §3 split:
/// - Latitude lerp across the maritime-moderated band
/// - Atmosphere greenhouse nudge
/// - Seed nudge (deterministic ±~3 °C per-body variety)
/// - Clamp to [cold, warm] class band (hard invariant, D-240)
///
/// **Not included here:** elevation lapse and slope aspect. Those are
/// district-level modulations applied in
/// [`crate::atlas::district_profile::derive_district_temperature_c`].
///
/// ## Inputs
///
/// - `body_params` — `planet_class`, `atmosphere`, `hydrosphere`, and the
/// **region centre latitude** in `latitude_deg` (repurposed for
/// the region's central latitude — the field name is historical).
/// - `constants` — tunable climate constants (D-240 table).
/// - `body_seed` — the body-scoped seed for the deterministic nudge (D-010).
///
/// ## Returns
///
/// `None` if the body is airless (`atmosphere == "none"`). Otherwise the
/// region baseline temperature in °C, clamped to the class band.
///
/// ## D-010 compliance
///
/// All structural gating uses integer arithmetic. f32 is used only for the
/// positional physics (latitude lerp, greenhouse fraction) — downstream
/// gating casts to `i32` before comparison.
pub fn derive_region_baseline_c(
body_params: &BodyParams,
constants: &ClimateConstants,
body_seed: u64,
) -> Option<f32> {
let atmosphere = body_params.atmosphere.as_deref().unwrap_or("none");
// No atmosphere → airless body; baseline is None.
if atmosphere == "none" {
return None;
}
// Step 1: planet_class → (cold, warm) envelope (D-240).
let planet_class = body_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.
let hydrosphere = body_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 = (body_params.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: seed nudge — deterministic ±~3 °C per-body variety (D-010, D-240).
// Uses the same splitmix64 mixing as the original derive_temperature_c so the
// body-level nudge character is preserved across the refactor.
let nudge = {
let h = body_seed
.wrapping_add(0x9e37_79b9_7f4a_7c15)
.wrapping_mul(0x6c62_272e_07bb_0142);
let unit = (h as i64 as f64 / i64::MAX as f64) as f32;
unit * 3.0_f32
};
let t_nudged = t_atmo + nudge;
// Step 5: clamp to [cold, warm] — class band is a hard invariant (D-240).
// Elevation lapse and slope aspect are NOT applied here — those are district
// modulations (D-243 §3; applied in derive_district_temperature_c).
Some(t_nudged.clamp(cold, warm))
}
// ---------------------------------------------------------------------------
// RegionProfile builder
// ---------------------------------------------------------------------------
/// Compute the latitude of a region centre from its grid position.
///
/// The region grid is equirectangular. Row 0 sits at the north pole; the
/// equator is in the middle. Returns degrees: +90.0 = north pole, −90.0 =
/// south pole.
///
/// If no body radius is available (tiny test bodies), the region lat is 0.0.
pub fn region_centre_latitude_deg(region_pos: RegionPos, body_radius_km: Option<f64>) -> f64 {
let Some(r_km) = body_radius_km else {
return 0.0;
};
if r_km <= 0.0 {
return 0.0;
}
// The meridian spans πR km. Each region is REGION_M metres tall.
// region_y = 0 maps to the north pole (lat +90), rising y → south.
let meridian_m = std::f64::consts::PI * r_km * 1_000.0;
let region_centre_y_m = (region_pos.1 as f64 + 0.5) * scale::REGION_M as f64;
// Clamp: lat_frac in [0, 1]; 0 = N pole (+90°), 1 = S pole (−90°).
let lat_frac = (region_centre_y_m / meridian_m).clamp(0.0, 1.0);
90.0 - lat_frac * 180.0
}
/// Build a [`RegionProfile`] for the region at `region_pos`.
///
/// Pure and deterministic: `(seed, body_params, region_pos)` → `RegionProfile`.
///
/// `body_params.latitude_deg` is **overridden** to the region centre's
/// latitude internally — callers do not need to pre-set it.
pub fn build_region_profile(
seed: SeedChain,
body_params: &BodyParams,
constants: &ClimateConstants,
region_pos: RegionPos,
) -> RegionProfile {
let lat_deg = region_centre_latitude_deg(region_pos, body_params.body_radius_km);
// Build region-local params: override latitude to the region centre.
let region_params = BodyParams {
latitude_deg: lat_deg,
// Elevation at region level is sea level (baseline only; no lapse here).
elevation_km: 0.0,
..body_params.clone()
};
let body_seed = seed.seed();
let baseline_temp_c = derive_region_baseline_c(&region_params, constants, body_seed);
// Region-tier moisture = body ceiling × region-latitude gradient (T-1080). The
// elevation + continentality components are district-scale, so at the region tier
// elevation is sea level (0) and continentality is neutral (ocean_fraction 100);
// the district derivation adds those on top.
let moisture_q =
crate::atlas::district_profile::derive_moisture_q(&region_params, 0, 100, constants);
RegionProfile {
pos: region_pos,
clock: RegionClock {
season: SeasonPhase::Summer,
weather: WeatherState::Clear,
mean_temp_c: baseline_temp_c,
},
latitude_deg: lat_deg as f32,
moisture_q,
}
}
// ---------------------------------------------------------------------------
// Edge-fuzz blend — D-243 §4
// ---------------------------------------------------------------------------
/// Region-baseline temperature at a **district position**, using the D-243 §4
/// edge-fuzz blend.
///
/// Climate does not change on a line. This function blends the four surrounding
/// region baselines (bilinear across region centres), displaced by a
/// noise-warp field so the blend boundary is ragged rather than a straight
/// gradient. The ~205 km region grid is therefore **invisible** in the output —
/// temperatures grade smoothly and organically across boundaries.
///
/// ## Algorithm
///
/// 1. Compute the district's fractional position within the region grid,
/// displaced by a per-district warp (keyed on `(world_seed, body_id, district_pos)`).
/// 2. Identify the four surrounding region positions.
/// 3. Derive (or look up from the optional `region_cache`) the baseline
/// temperature for each region.
/// 4. Bilinear-blend the four baselines by the displaced fractional weights.
///
/// ## D-010 compliance
///
/// All warp and blend arithmetic is f64 positional math. The blended baseline
/// returned here is f32 and is used only as input to the district modulation
/// (elevation lapse, slope aspect) in
/// [`crate::atlas::district_profile::derive_district_temperature_c`].
/// No structural gate is applied to this value — gates happen downstream on
/// integer casts of the final district temperature.
///
/// ## Parameters
///
/// - `world_seed` — master world seed (the edge-fuzz warp is keyed to it).
/// - `body_id` — body identifier for warp domain separation.
/// - `district_pos` — the district being derived.
/// - `body_params` — body physical parameters (planet_class, atmosphere, etc.).
/// - `constants` — climate constants.
/// - `seed` — body SeedChain (for per-region baseline derivation).
/// - `region_cache` — optional pre-computed region baselines; if a region is
/// missing it is derived on the fly.
pub fn region_baseline_at_district(
world_seed: u64,
body_id: &str,
district_pos: DistrictPos,
body_params: &BodyParams,
constants: &ClimateConstants,
seed: SeedChain,
region_cache: Option<&BTreeMap<RegionPos, RegionProfile>>,
) -> Option<f32> {
// Atmosphere gate: airless bodies have no temperature baseline.
let atmosphere = body_params.atmosphere.as_deref().unwrap_or("none");
if atmosphere == "none" {
return None;
}
// ── Step 1: Fractional district position in the region grid ─────────────
// A district at `(dx, dy)` lies at fractional position
// fx = (dx mod DISTRICTS_PER_REGION + 0.5) / DISTRICTS_PER_REGION
// fy = (dy mod DISTRICTS_PER_REGION + 0.5) / DISTRICTS_PER_REGION
// in region space, where (0,0) is the region corner.
let dpir = scale::DISTRICTS_PER_REGION as f64;
// The district's offset within its region (0.0..1.0 each axis).
let local_fx = {
let rem = district_pos.0.rem_euclid(scale::DISTRICTS_PER_REGION) as f64;
(rem + 0.5) / dpir
};
let local_fy = {
let rem = district_pos.1.rem_euclid(scale::DISTRICTS_PER_REGION) as f64;
(rem + 0.5) / dpir
};
// ── Step 2: Edge-fuzz warp displacement ─────────────────────────────────
// A per-district noise warp displaces the sampling point so the blend
// boundary is ragged. The warp is keyed on (world_seed, body_id,
// district_pos) and is bounded to ±CLIMATE_WARP_FRAC of the region extent
// (not ±8 m — climate warp is fractional region units, not metres).
//
// Implementation: derive two hash values from the district position and
// map them to [-WARP_FRAC, +WARP_FRAC].
//
// D-010: the warp arithmetic is f64 positional math; the downstream blend
// weights are not used in any structural comparison.
const CLIMATE_WARP_FRAC: f64 = 0.25; // ±25% of a region's width/height.
let (warp_dx, warp_dy) = climate_edge_warp(world_seed, body_id, district_pos);
let warped_fx = (local_fx + warp_dx * CLIMATE_WARP_FRAC).clamp(0.0, 1.0);
let warped_fy = (local_fy + warp_dy * CLIMATE_WARP_FRAC).clamp(0.0, 1.0);
// ── Step 3: Identify four surrounding region positions ───────────────────
// The district's parent region.
let base_region = scale::district_to_region(district_pos);
// Determine which quadrant of the region the district falls in (after warp):
// if warped_fx > 0.5 the district is in the eastern half → blend with +X
// neighbour; else blend with −X neighbour. Same for Y.
let (neighbour_dx, blend_tx) = if warped_fx >= 0.5 {
(1i32, (warped_fx - 0.5) * 2.0) // 0.0 at centre → 1.0 at +X edge
} else {
(-1i32, (0.5 - warped_fx) * 2.0) // 0.0 at centre → 1.0 at −X edge
};
let (neighbour_dy, blend_ty) = if warped_fy >= 0.5 {
(1i32, (warped_fy - 0.5) * 2.0)
} else {
(-1i32, (0.5 - warped_fy) * 2.0)
};
// Four corner regions: (base, x-neighbour, y-neighbour, xy-neighbour).
let r00 = base_region;
let r10 = (base_region.0 + neighbour_dx, base_region.1);
let r01 = (base_region.0, base_region.1 + neighbour_dy);
let r11 = (base_region.0 + neighbour_dx, base_region.1 + neighbour_dy);
// ── Step 4: Fetch or derive the four region baselines ───────────────────
let baseline_for = |rpos: RegionPos| -> Option<f32> {
// Try the cache first.
if let Some(cache) = region_cache {
if let Some(rp) = cache.get(&rpos) {
return rp.clock.mean_temp_c;
}
}
// Derive on the fly (test path / cache miss).
let lat = region_centre_latitude_deg(rpos, body_params.body_radius_km);
let r_params = BodyParams {
latitude_deg: lat,
elevation_km: 0.0,
..body_params.clone()
};
derive_region_baseline_c(&r_params, constants, seed.seed())
};
let b00 = baseline_for(r00)?;
let b10 = baseline_for(r10)?;
let b01 = baseline_for(r01)?;
let b11 = baseline_for(r11)?;
// ── Step 5: Bilinear blend ───────────────────────────────────────────────
// Standard bilinear: tx blends X pairs, ty blends the Y result.
let tx = blend_tx as f32;
let ty = blend_ty as f32;
let top = b00 + (b10 - b00) * tx;
let bot = b01 + (b11 - b01) * tx;
Some(top + (bot - top) * ty)
}
/// Compute the climate edge-fuzz warp displacement for a district.
///
/// Returns `(warp_dx, warp_dy)` each in `[-1.0, +1.0]`, intended to be scaled
/// by `CLIMATE_WARP_FRAC` by the caller. Keyed on
/// `(world_seed, body_id, district_pos)` — same domain-separation conventions
/// as [`crate::atlas::domain_warp`], but using a distinct hash path so the
/// climate warp is never correlated with the terrain warp.
///
/// ## D-010 compliance
///
/// Pure integer hash, f64 mapping. No structural comparison.
fn climate_edge_warp(world_seed: u64, body_id: &str, district_pos: DistrictPos) -> (f64, f64) {
// Hash body_id into a u64 using FNV-1a (canonical per D-224).
let body_hash = crate::seed::fnv1a_64(body_id);
let base = world_seed
.wrapping_add(body_hash)
.wrapping_add(0x1234_5678_9abc_def0);
// Per-district position hash (zigzag + Cantor pairing — same as domain_warp).
let zz = |v: i32| -> u64 {
let v = v as i64;
((v << 1) ^ (v >> 63)) as u64
};
let x = zz(district_pos.0);
let y = zz(district_pos.1);
let s = x.wrapping_add(y);
let pos_id = s
.wrapping_mul(s.wrapping_add(1))
.wrapping_div(2)
.wrapping_add(y);
// Two independent streams: one for dx, one for dy.
let seed_x = splitmix64(base.wrapping_add(pos_id));
let seed_y = splitmix64(base.wrapping_add(pos_id).wrapping_add(0xdeadbeef_cafebabe));
let u64_to_unit = |h: u64| -> f64 {
let unit = (h >> 11) as f64 * (1.0 / (1u64 << 53) as f64);
(unit - 0.5) * 2.0 // [-1.0, +1.0)
};
(u64_to_unit(seed_x), u64_to_unit(seed_y))
}
// ---------------------------------------------------------------------------
// Region cache builder (for batch derivation)
// ---------------------------------------------------------------------------
/// Derive [`RegionProfile`]s for all regions that cover the set of districts
/// supplied, returning a `BTreeMap<RegionPos, RegionProfile>`.
///
/// Used when processing a batch of districts: call this first to populate the
/// region cache, then pass the cache into [`region_baseline_at_district`].
///
/// Pure and deterministic. Does not de-duplicate within this call — the caller
/// provides the unique set of region positions.
pub fn derive_regions_for_body(
seed: SeedChain,
body_params: &BodyParams,
constants: &ClimateConstants,
region_positions: impl IntoIterator<Item = RegionPos>,
) -> BTreeMap<RegionPos, RegionProfile> {
let mut out = BTreeMap::new();
for rpos in region_positions {
let profile = build_region_profile(seed, body_params, constants, rpos);
out.insert(rpos, profile);
}
out
}
// ---------------------------------------------------------------------------
// SeedDomain extension: RegionClimate = 11
// ---------------------------------------------------------------------------
//
// A new SeedDomain variant `RegionClimate = 11` is registered in
// `crate::seed::SeedDomain` to enable region-scoped seed derivation without
// colliding with other domains (D-224 domain separation). Because `SeedDomain`
// is in seed.rs (not this file), the variant is added there.
//
// This module uses the `seed.seed()` output for region baseline derivation
// (the same approach as `derive_temperature_c` uses `body_seed`), which is
// equivalent to calling `.derive(SeedDomain::Body, body_id_hash)` at the
// parent level and then reading the raw seed. No additional derivation level
// is needed unless per-region RNG streams are required (deferred to Q-105).
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::atlas::district_profile::{BodyParams, ClimateConstants};
use crate::seed::{SeedChain, SeedDomain};
fn body_seed() -> SeedChain {
SeedChain::root(42).derive(SeedDomain::Body, 1)
}
fn earth_params() -> BodyParams {
BodyParams {
hydrosphere: Some("ocean".into()),
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
body_radius_km: Some(6371.0),
..Default::default()
}
}
// ── RegionProfile builder ────────────────────────────────────────────────
#[test]
fn build_region_profile_is_deterministic() {
let params = earth_params();
let constants = ClimateConstants::default();
let a = build_region_profile(body_seed(), &params, &constants, (5, 10));
let b = build_region_profile(body_seed(), &params, &constants, (5, 10));
assert_eq!(
a.clock.mean_temp_c, b.clock.mean_temp_c,
"region baseline must be deterministic"
);
assert_eq!(a.latitude_deg, b.latitude_deg);
assert_eq!(a.moisture_q, b.moisture_q);
}
#[test]
fn airless_body_has_no_region_baseline() {
let params = BodyParams {
atmosphere: Some("none".into()),
planet_class: Some("frozen".into()),
body_radius_km: Some(1500.0),
..Default::default()
};
let constants = ClimateConstants::default();
let rp = build_region_profile(body_seed(), &params, &constants, (0, 0));
assert_eq!(
rp.clock.mean_temp_c, None,
"airless body must have None region baseline"
);
}
#[test]
fn equatorial_region_is_warmer_than_polar() {
// With a body radius, north-pole region vs equatorial region.
let params = earth_params();
let constants = ClimateConstants::default();
// Region (0, 0) is near the north pole; (97, 15) is roughly equatorial.
let polar = build_region_profile(body_seed(), &params, &constants, (0, 0));
let equatorial = build_region_profile(body_seed(), &params, &constants, (97, 15));
match (polar.clock.mean_temp_c, equatorial.clock.mean_temp_c) {
(Some(p), Some(e)) => assert!(
p < e,
"polar baseline {p}°C must be colder than equatorial {e}°C"
),
_ => panic!("breathable body must have a temperature"),
}
}
#[test]
fn frozen_body_region_within_class_band() {
let params = BodyParams {
atmosphere: Some("thin".into()),
planet_class: Some("frozen".into()),
body_radius_km: Some(2000.0),
..Default::default()
};
let constants = ClimateConstants::default();
let (cold, warm) = constants.envelope("frozen");
for ry in [0i32, 5, 10] {
let rp = build_region_profile(body_seed(), &params, &constants, (0, ry));
let t = rp
.clock
.mean_temp_c
.expect("non-airless body must have temperature");
assert!(
t >= cold && t <= warm,
"frozen body region ({}, {ry}) baseline {t}°C outside band [{cold}, {warm}]",
0
);
}
}
#[test]
fn region_clock_default_state() {
let params = earth_params();
let constants = ClimateConstants::default();
let rp = build_region_profile(body_seed(), &params, &constants, (10, 10));
// Q-105 is deferred: mean state is Summer + Clear.
assert_eq!(rp.clock.season, SeasonPhase::Summer);
assert_eq!(rp.clock.weather, WeatherState::Clear);
}
// ── Baseline derivation ──────────────────────────────────────────────────
#[test]
fn region_baseline_no_elevation_lapse() {
// The region baseline must NOT include elevation lapse — that is a
// district-level modulation. Two regions at the same latitude but
// different positions should produce the same baseline if seed is the
// same (latitude is the only varying input here).
let constants = ClimateConstants::default();
let params = BodyParams {
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
latitude_deg: 30.0,
elevation_km: 5.0, // This must be ignored by the region baseline
..Default::default()
};
// Explicitly pass elevation_km = 5.0; the baseline function overrides
// it to 0.0 internally.
let t_high_elev = derive_region_baseline_c(&params, &constants, 42);
let params_low = BodyParams {
elevation_km: 0.0,
..params.clone()
};
let t_low_elev = derive_region_baseline_c(&params_low, &constants, 42);
// Both must be equal — elevation is NOT a region-level input.
assert_eq!(
t_high_elev, t_low_elev,
"region baseline must be independent of elevation_km (lapse is district-level)"
);
}
// ── Edge-fuzz blend ──────────────────────────────────────────────────────
#[test]
fn edge_fuzz_is_deterministic() {
let params = earth_params();
let constants = ClimateConstants::default();
let a = region_baseline_at_district(
42,
"TestBody",
(50, 75),
&params,
&constants,
body_seed(),
None,
);
let b = region_baseline_at_district(
42,
"TestBody",
(50, 75),
&params,
&constants,
body_seed(),
None,
);
assert_eq!(a, b, "edge-fuzz blend must be deterministic");
}
#[test]
fn edge_fuzz_within_class_band() {
let params = earth_params();
let constants = ClimateConstants::default();
let (cold, warm) = constants.envelope("temperate");
// Check several district positions.
for dx in [0i32, 50, 99, 100, 150] {
for dy in [0i32, 25, 50, 75] {
let t = region_baseline_at_district(
42,
"TestBody",
(dx, dy),
&params,
&constants,
body_seed(),
None,
)
.expect("breathable body must have a baseline");
assert!(
t >= cold && t <= warm,
"edge-fuzz baseline at ({dx},{dy}): {t}°C outside [{cold}, {warm}]"
);
}
}
}
#[test]
fn edge_fuzz_varies_with_body_id() {
let params = earth_params();
let constants = ClimateConstants::default();
let a = region_baseline_at_district(
42,
"BodyA",
(50, 50),
&params,
&constants,
body_seed(),
None,
);
let b = region_baseline_at_district(
42,
"BodyB",
(50, 50),
&params,
&constants,
body_seed(),
None,
);
// Same region pos but different body_id → different warp → different blend.
// Not guaranteed to differ (could accidentally hit same blend), but should
// for these inputs.
assert_ne!(
a, b,
"edge-fuzz blend should vary with body_id (different warp)"
);
}
#[test]
fn edge_fuzz_airless_returns_none() {
let params = BodyParams {
atmosphere: Some("none".into()),
planet_class: Some("frozen".into()),
..Default::default()
};
let constants = ClimateConstants::default();
let t = region_baseline_at_district(
42,
"AirlessBody",
(10, 10),
&params,
&constants,
body_seed(),
None,
);
assert_eq!(t, None, "airless body must return None from edge-fuzz");
}
// ── SeasonPhase / WeatherState discriminant pin ──────────────────────────
#[test]
fn season_phase_discriminants_pinned() {
// Append-only (D-010): renaming breaks serialised state.
assert_eq!(SeasonPhase::Summer as u8, 0);
assert_eq!(SeasonPhase::Autumn as u8, 1);
assert_eq!(SeasonPhase::Winter as u8, 2);
assert_eq!(SeasonPhase::Spring as u8, 3);
}
#[test]
fn weather_state_discriminants_pinned() {
assert_eq!(WeatherState::Clear as u8, 0);
assert_eq!(WeatherState::Overcast as u8, 1);
assert_eq!(WeatherState::Rain as u8, 2);
assert_eq!(WeatherState::Snow as u8, 3);
assert_eq!(WeatherState::Blizzard as u8, 4);
assert_eq!(WeatherState::DustStorm as u8, 5);
}
// ── Region cache batch builder ───────────────────────────────────────────
#[test]
fn derive_regions_for_body_covers_all_positions() {
let params = earth_params();
let constants = ClimateConstants::default();
let positions: Vec<RegionPos> = vec![(0, 0), (1, 0), (0, 1), (5, 5)];
let cache = derive_regions_for_body(body_seed(), &params, &constants, positions.clone());
assert_eq!(
cache.len(),
positions.len(),
"all positions must be present"
);
for pos in &positions {
assert!(
cache.contains_key(pos),
"region {pos:?} must be in the cache"
);
}
}
#[test]
fn edge_fuzz_cache_hit_matches_derived() {
// region_baseline_at_district with a cache should produce the same
// result as without (within the bilinear blend — the same regions are
// sampled in both paths).
let params = earth_params();
let constants = ClimateConstants::default();
let district_pos = (55i32, 20i32);
let base_region = scale::district_to_region(district_pos);
let positions: Vec<RegionPos> = vec![
base_region,
(base_region.0 + 1, base_region.1),
(base_region.0 - 1, base_region.1),
(base_region.0, base_region.1 + 1),
(base_region.0, base_region.1 - 1),
(base_region.0 + 1, base_region.1 + 1),
(base_region.0 - 1, base_region.1 + 1),
(base_region.0 + 1, base_region.1 - 1),
(base_region.0 - 1, base_region.1 - 1),
];
let cache = derive_regions_for_body(body_seed(), &params, &constants, positions);
let t_cached = region_baseline_at_district(
42,
"TestBody",
district_pos,
&params,
&constants,
body_seed(),
Some(&cache),
);
let t_derived = region_baseline_at_district(
42,
"TestBody",
district_pos,
&params,
&constants,
body_seed(),
None,
);
// Both paths should produce the same result (both use the same seed
// for on-demand derivation when the cache derives with the same seed).
assert_eq!(
t_cached, t_derived,
"cache hit and on-demand derivation must agree"
);
}
}