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>
887 lines
36 KiB
Rust
887 lines
36 KiB
Rust
//! 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(®ion_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(®ion_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(), ¶ms, &constants, (5, 10));
|
||
let b = build_region_profile(body_seed(), ¶ms, &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(), ¶ms, &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(), ¶ms, &constants, (0, 0));
|
||
let equatorial = build_region_profile(body_seed(), ¶ms, &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(), ¶ms, &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(), ¶ms, &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(¶ms, &constants, 42);
|
||
let params_low = BodyParams {
|
||
elevation_km: 0.0,
|
||
..params.clone()
|
||
};
|
||
let t_low_elev = derive_region_baseline_c(¶ms_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),
|
||
¶ms,
|
||
&constants,
|
||
body_seed(),
|
||
None,
|
||
);
|
||
let b = region_baseline_at_district(
|
||
42,
|
||
"TestBody",
|
||
(50, 75),
|
||
¶ms,
|
||
&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),
|
||
¶ms,
|
||
&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),
|
||
¶ms,
|
||
&constants,
|
||
body_seed(),
|
||
None,
|
||
);
|
||
let b = region_baseline_at_district(
|
||
42,
|
||
"BodyB",
|
||
(50, 50),
|
||
¶ms,
|
||
&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),
|
||
¶ms,
|
||
&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(), ¶ms, &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(), ¶ms, &constants, positions);
|
||
let t_cached = region_baseline_at_district(
|
||
42,
|
||
"TestBody",
|
||
district_pos,
|
||
¶ms,
|
||
&constants,
|
||
body_seed(),
|
||
Some(&cache),
|
||
);
|
||
let t_derived = region_baseline_at_district(
|
||
42,
|
||
"TestBody",
|
||
district_pos,
|
||
¶ms,
|
||
&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"
|
||
);
|
||
}
|
||
}
|