//! 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, } 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 { 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 { 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>, ) -> Option { // 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 { // 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`. /// /// 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, ) -> BTreeMap { 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 = 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 = 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" ); } }