feat(simulation): region climate tier — baseline + district modulation + edge fuzz (T-1078)
Introduces the region (~205 km) climate tier per D-243 §3/§4, above the DistrictProfile carrier. New region_profile.rs: RegionProfile/RegionClock, derive_region_baseline_c (latitude lerp + greenhouse, D-240 class-envelope, no orbit/star inputs), and region_baseline_at_district — the D-243 §4 edge-fuzz warp-perturbed bilinear blend of surrounding region baselines so the ~205 km grid is invisible in the output. Splits district temperature (D-243 §3): derive_district_temperature_c now modulates the region baseline with elevation lapse only, replacing the from-scratch per-district derivation. build_district_profile takes an Option<f32> region baseline; None preserves the legacy single-phase path (existing call sites unchanged). derive_cover (voxel.rs) consumes the post-refactor value with no signature change. Region clock (season/weather) scaffolded; transient tick callbacks deferred to Q-105 per the two-phase convention. 14 region_profile + 5 district modulation tests added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,882 @@
|
||||
//! 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 `district_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.district_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.district_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 {
|
||||
district_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);
|
||||
|
||||
// Moisture at the region tier is body-scale (same as body_params).
|
||||
let moisture_q = crate::atlas::district_profile::derive_moisture_q(body_params);
|
||||
|
||||
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 {
|
||||
district_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()),
|
||||
district_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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user