diff --git a/server/data/climate_constants.toml b/server/data/climate_constants.toml index 808c0bee9..864eed01d 100644 --- a/server/data/climate_constants.toml +++ b/server/data/climate_constants.toml @@ -65,3 +65,22 @@ standard = 15 # ~15°C: Earth-like moderate swing toxic = 20 # intermediate: thick but maybe less redistribution breathable = 15 # synonym for standard dense = 3 # ~3°C: Venus-like near-uniform temperature + +# --------------------------------------------------------------------------- +[hydrosphere_maritime] +# Maritime moderation (D-240): scales the equator→pole temperature gradient by +# hydrosphere. 1.0 = full gradient (dry world swings the whole class band); +# < 1.0 compresses toward the band midpoint (water-rich worlds are milder at +# both poles and equator). Keys = actual bodies.hydrosphere vocabulary in +# systems.db. "minimal"/"trace"/"none"/absent default to 1.0 in code. +liquid_water = 0.6 # large surface liquid — strongly moderated +ocean = 0.6 +"ocean-coastal" = 0.6 +extensive = 0.6 +rivers = 0.8 # partial surface water — mild moderation +"rivers-lakes" = 0.8 +moderate = 0.8 +ice = 0.85 # frozen surface — slight moderation +subsurface_liquid = 0.9 +subsurface = 0.95 # buried water — minimal surface effect +subsurface_ice = 0.95 diff --git a/server/src/atlas/region_profile.rs b/server/src/atlas/region_profile.rs index b21e3d0f0..a2befac71 100644 --- a/server/src/atlas/region_profile.rs +++ b/server/src/atlas/region_profile.rs @@ -634,6 +634,11 @@ pub struct ClimateConstants { pub greenhouse_offset_c: std::collections::BTreeMap, /// Day/night swing amplitude per atmosphere class (°C). pub diurnal_amplitude_c: std::collections::BTreeMap, + /// Maritime-moderation factor per `hydrosphere` (D-240): scales the + /// equator→pole gradient. `1.0` = full gradient (dry world swings the whole + /// band); `< 1.0` compresses toward the band midpoint (water-rich worlds are + /// milder at both ends). Absent/unknown = `1.0`. + pub hydrosphere_maritime: std::collections::BTreeMap, } impl Default for ClimateConstants { @@ -669,10 +674,30 @@ impl Default for ClimateConstants { da.insert("toxic".into(), 20.0f32); da.insert("dense".into(), 3.0f32); + // Maritime moderation: water-rich worlds compress the equator–pole gradient. + // Keys are the actual `bodies.hydrosphere` vocabulary in systems.db. + let mut hm = std::collections::BTreeMap::new(); + // Large surface liquid — strong moderation. + hm.insert("liquid_water".into(), 0.6f32); + hm.insert("ocean".into(), 0.6f32); + hm.insert("ocean-coastal".into(), 0.6f32); + hm.insert("extensive".into(), 0.6f32); + // Partial surface water — mild moderation. + hm.insert("rivers".into(), 0.8f32); + hm.insert("rivers-lakes".into(), 0.8f32); + hm.insert("moderate".into(), 0.8f32); + // Frozen / subsurface — slight moderation. + hm.insert("ice".into(), 0.85f32); + hm.insert("subsurface_liquid".into(), 0.9f32); + hm.insert("subsurface".into(), 0.95f32); + hm.insert("subsurface_ice".into(), 0.95f32); + // "minimal" / "trace" / "none" / NULL → 1.0 (full gradient) via fallback. + ClimateConstants { planet_class_temperature: pct, greenhouse_offset_c: gh, diurnal_amplitude_c: da, + hydrosphere_maritime: hm, } } } @@ -713,6 +738,16 @@ impl ClimateConstants { .unwrap_or(0.0) } + /// Maritime-moderation factor for a given `hydrosphere` (D-240): `1.0` = full + /// equator→pole gradient (dry/unknown); `< 1.0` compresses toward the band + /// midpoint (ocean ≈ 0.6 → milder at both ends). + pub fn maritime_factor(&self, hydrosphere: &str) -> f32 { + self.hydrosphere_maritime + .get(hydrosphere) + .copied() + .unwrap_or(1.0) + } + /// Diurnal amplitude for a given atmosphere class (°C), defaulting to 30. pub fn diurnal_amplitude(&self, atmosphere: &str) -> f32 { self.diurnal_amplitude_c @@ -763,9 +798,16 @@ pub fn derive_temperature_c( let (cold, warm) = constants.envelope(planet_class); let band_width = warm - cold; - // Step 2: latitude lerp — equator → warm end, pole → cold end. + // Step 2: latitude lerp across the maritime-moderated band (D-240). Water-rich + // worlds compress the equator→pole gradient toward the band midpoint — an ocean + // world is milder at both ends; a dry world swings the full band. + let hydrosphere = 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 = (params.region_latitude_deg.abs() as f32 / 90.0).clamp(0.0, 1.0); - let t_lat = warm - band_width * lat_frac; + // 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); @@ -1339,6 +1381,33 @@ mod tests { } } + #[test] + fn maritime_hydrosphere_compresses_the_gradient() { + // D-240: a water-rich world has a SMALLER equator→pole temperature delta + // than a dry world of the same class (maritime moderation). + let climate = ClimateConstants::default(); + let mk = |hydro: &str, lat: f64| BodyParams { + planet_class: Some("temperate".into()), + atmosphere: Some("standard".into()), + hydrosphere: Some(hydro.into()), + region_latitude_deg: lat, + elevation_km: 0.0, + ..Default::default() + }; + // Same seed → only hydrosphere differs. + let delta = |hydro: &str| { + (derive_temperature_c(&mk(hydro, 0.0), &climate, 7).unwrap() + - derive_temperature_c(&mk(hydro, 90.0), &climate, 7).unwrap()) + .abs() + }; + let ocean_delta = delta("ocean"); + let dry_delta = delta("none"); // hydrosphere none ≠ airless (atmosphere is "standard") + assert!( + ocean_delta < dry_delta, + "ocean gradient {ocean_delta}°C must be milder than dry {dry_delta}°C" + ); + } + #[test] fn unknown_planet_class_falls_back_to_temperate_band() { // An unrecognised class string must not panic; it falls back to temperate.