diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index c5c6a7f89..f881ebbd1 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -1770,6 +1770,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **Rationale:** [D-227](#d-227)/[D-228](#d-228) fixed *what* a tile is (derive-don't-store voxels; orthogonal derived axes); D-239 fixes *how* the finished upper cascade (L0–L4) becomes actual tiles — the one unbuilt layer. The district-temperature primitive collapses the scattered climate inputs (precipitation, glaciation, season, snow/ice, vegetation) onto one derived scalar + moisture, keeping the whole climate branch deterministic and null-cutting airless bodies cleanly. Prevent-at-source seams + the warp give coherent, organic terrain without a runtime patch. The frozen vocabulary protects authored Atlas/wiki content while letting the classifier evolve. - **Raised by:** tile-derivation-contract workshop (Tyre — refinement chain, warp, determinism; Gestalt — 8 families, game-feel; Troblum — feasibility, warp precision, scale corrections; Miri — believability laws, vocabulary, lore reconciliation), lead-interviewed decisions + an adversarial verification pass, 2026-06-07. - **Dissent:** Tyre's initial cross-family elevation-blend was resolved against (prevent-at-source). Early-integer-truncation of the warp (raised against Gestalt's `ElevationDelta` ranges and by Tyre) was resolved against in favour of f64-to-voxel. +- **Implementation note (T-1024, 2026-06-07):** §1 per-body `RIVER_THRESHOLD` is now a derived field (`derive_river_threshold`) on `RegionProfile`. §2 district temperature is a nullable `f32` on `RegionProfile` sampled from a per-region climate derivation (`derive_temperature_c`); moisture is a separate integer primitive (`derive_moisture_q`, 0–100). Both populate the `RegionProfile` carrier during the L4 cascade run. Climate inputs use a **hybrid strategy**: stellar luminosity and spectral class are sourced from the import pipeline (`bodies.axial_tilt_deg`, `star_systems.spectral_class`); greenhouse offsets and diurnal amplitudes are tunable at runtime via `server/data/climate_constants.toml` (source-canonical TOML, not hardcoded). - **Cross-reference:** [D-227](#d-227) (derive-don't-store voxel model), [D-228](#d-228) (composite tile axes / cohesion / seasonal state), [D-210](#d-210) (temperature proxy — formalised), [D-203](#d-203) (BodyWorldState cache), [D-206](#d-206) (background analysis pass), [D-208](#d-208) (drainage / D8), [D-010](#d-010) (determinism), [D-234](#d-234) (street/footprint geometry — consumes morphology), [D-142](content.md#d-142) (zone types), [D-217](#d-217) (tile condition), [Q-102](../questions/architecture.md#q-102) (cohesion = the warp), [Q-103](../questions/architecture.md#q-103) (mutator schema — open), [Q-105](../questions/architecture.md#q-105) (seasonal/clock state — temperature/ElevationDelta forward contract) --- diff --git a/server/data/climate_constants.toml b/server/data/climate_constants.toml new file mode 100644 index 000000000..d9edf393e --- /dev/null +++ b/server/data/climate_constants.toml @@ -0,0 +1,58 @@ +# Climate constants for district temperature derivation (T-1024, D-239 §2). +# +# Source-canonical — loaded by the Rust simulation at runtime. +# These values mirror the tuned constants from tooling/planet-gen/planet_simulation.py. +# Changing these constants does NOT require a DB migration (runtime file, not DB). +# To tune: edit here, rerun the server, inspect Atlas temperature maps. +# +# Two tables are required: +# [greenhouse_offset_c] — mean-annual base temperature offset by atmosphere class. +# [diurnal_amplitude_c] — day/night swing AMPLITUDE by atmosphere class. +# +# Temperature derivation formula (D-239 §2): +# T_base = T_stellar_equilibrium + greenhouse_offset_c[atmosphere] +# T_mean = T_base + latitude_term + elevation_lapse_term +# T_diurnal_amplitude = diurnal_amplitude_c[atmosphere] +# +# No atmosphere → temperature_c = None (airless body; D-227). + +[greenhouse_offset_c] +# Greenhouse warming contribution per atmosphere class (°C above bare rock). +# "none" is not present — airless bodies skip the climate branch entirely (D-227). +# Values from planet_simulation.py STAR_LUMINOSITY + greenhouse table. +thin = 8 +standard = 33 +toxic = 33 # treated as standard greenhouse for thermal purposes +breathable = 33 # synonym for standard +dense = 80 + +[diurnal_amplitude_c] +# Day/night swing amplitude (°C). The actual swing is ±amplitude around the +# mean temperature; the morning minimum is T_mean - amplitude, noon maximum is +# T_mean + amplitude. Thick/dense atmosphere = small swing (heat redistribution); +# thin/no atmosphere = large swing. +# +# "none" is excluded — airless bodies have no climate temperature (D-227). +# Values are plausible physics; tune without recompile by editing this file. +thin = 50 # ~50°C day/night swing: large, like Mars +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 + +# --------------------------------------------------------------------------- +# Stellar luminosity lookup (relative to Sol = 1.0). +# Midpoint per spectral type, matching planet_simulation.py STAR_LUMINOSITY. +# Used to derive distance_au at runtime from orbital_period_days via Kepler's 3rd law. +# +# These values are also used to compute T_stellar_equilibrium: +# T_eq_K = 278.5 * (luminosity ^ 0.25) / sqrt(distance_au) +# --------------------------------------------------------------------------- +[star_luminosity] +O = 100000.0 +B = 1000.0 +A = 10.0 +F = 2.5 +G = 1.0 +K = 0.4 +M = 0.04 diff --git a/server/data/systems-schema.sql b/server/data/systems-schema.sql index ff9a67eee..08fbde480 100644 --- a/server/data/systems-schema.sql +++ b/server/data/systems-schema.sql @@ -183,6 +183,12 @@ CREATE TABLE IF NOT EXISTS bodies ( -- derivation from planet_class is applied at query time by the generator. body_radius_km REAL, + -- Axial tilt in degrees (T-1024, D-239 §2). Sourced from planet-gen body-def + -- frontmatter (wiki/star-systems/*/bodies/*/index.md, orbit.axial_tilt_deg). + -- NULL until populate_axial_tilt_deg runs. Used by district temperature + -- derivation: tilt determines latitude-band insolation gradient. + axial_tilt_deg REAL, + -- Rendering -- terrain_reference: repo-root-relative path to the body's heightmap PNG. -- Convention (enforced by populate_terrain_reference.py and assumed by diff --git a/server/data/systems.db b/server/data/systems.db index 814a530a9..15d30fb8c 100644 Binary files a/server/data/systems.db and b/server/data/systems.db differ diff --git a/server/src/atlas/body_world_state.rs b/server/src/atlas/body_world_state.rs index 0badc23ac..8e71e641f 100644 --- a/server/src/atlas/body_world_state.rs +++ b/server/src/atlas/body_world_state.rs @@ -14,6 +14,7 @@ use bevy_ecs::prelude::Resource; use serde::{Deserialize, Serialize}; use crate::atlas::attractor_matching::CityPlacement; +use crate::atlas::region_profile::{RegionPos, RegionProfile}; use crate::simulation::generator::{ GeographicAttractor, QuarterId, QuarterWorldState, TerritorialStatus, }; @@ -88,6 +89,12 @@ pub struct BodyWorldState { /// Populated by `GenCompletion::SkeletonGenerated` after the plan phase /// completes for each city. `BTreeMap` for D-010 determinism. pub quarters: BTreeMap, + /// Per-region (~1 km) profiles derived from body params + terrain (T-1023, D-239 §1). + /// + /// Populated by the background cascade after Layer 1 completes. + /// `BTreeMap` keyed by `RegionPos` for D-010 determinism. + /// Empty until the RegionProfile layer has run. + pub regions: BTreeMap, /// Last sim tick this entry was read. Used for LRU eviction. pub last_accessed: SimTick, } @@ -210,6 +217,7 @@ mod tests { attractors: vec![], placements: vec![], quarters: BTreeMap::new(), + regions: BTreeMap::new(), last_accessed: tick, } } diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs index 2b0ccbee5..fcc8c74b9 100644 --- a/server/src/atlas/cascade.rs +++ b/server/src/atlas/cascade.rs @@ -24,8 +24,10 @@ use crate::atlas::attractor_matching::{ match_cities, territorial_status_from_faction, CityPlacement, CityRecord, }; use crate::atlas::body_world_state::{BodyWorldState, RiverNetwork}; +use crate::atlas::features::TerrainAnalysis; use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError}; use crate::atlas::layer1::{self, Layer1Output}; +use crate::atlas::region_profile::{self, BodyParams, RegionPos, RegionProfile}; use crate::seed::SeedChain; use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor, TerritorialStatus}; @@ -43,6 +45,10 @@ pub enum CascadeLayer { /// `match_cities`. The carried `SeedChain` is unused here; later stochastic /// layers (Layer 4+) will consume it. Settlement, + /// Layer — RegionProfile (~1 km carriers, D-239 §1, T-1023). Pure function of + /// `(seed, body_params, terrain_analysis)`. Appended after Settlement so + /// declaration order (= Ord) is preserved — never reorder (D-010). + RegionProfile, } /// Output of the cascade for one body, up to the requested layer (#952). @@ -61,6 +67,16 @@ pub struct CascadeSnapshot { pub layer1: Option, /// Layer 3 — settlement placement. `Some` once [`CascadeLayer::Settlement`] has run. pub layer3: Option, + /// RegionProfile layer — ~1 km carriers. `Some` once + /// [`CascadeLayer::RegionProfile`] has run (T-1023, D-239 §1). + pub layer_region: Option, +} + +/// RegionProfile layer output (T-1023, D-239 §1): per-region (~1 km) terrain +/// profiles covering the whole body. Stored in `BodyWorldState.regions`. +#[derive(Debug, Clone, Default)] +pub struct LayerRegionOutput { + pub regions: std::collections::BTreeMap, } /// Layer 3 output (#955, D-211): attractor-matched settlement placements for the @@ -81,6 +97,7 @@ impl CascadeSnapshot { None => (RiverNetwork::default(), Vec::new(), Vec::new()), }; let placements = self.layer3.map(|l3| l3.placements).unwrap_or_default(); + let regions = self.layer_region.map(|lr| lr.regions).unwrap_or_default(); BodyWorldState { body_id: self.body_id, heightmap: self.heightmap.data, @@ -91,6 +108,7 @@ impl CascadeSnapshot { attractors, placements, quarters: std::collections::BTreeMap::new(), + regions, last_accessed: 0, } } @@ -138,11 +156,14 @@ fn run_layer3( /// `dominant_faction` is the body's authored system faction (D-237); it drives /// the `TerritorialStatus` on each province and the per-settlement spatial /// character (#956). `None` → `FrontierUnclaimed`. +/// `body_params` supplies the physical parameters needed for the RegionProfile +/// layer (T-1023); `None` → region layer skips (empty `regions` map). pub fn run_cascade_from_heightmap( body_seed: SeedChain, heightmap: BodyHeightmap, cities: &[CityRecord], dominant_faction: Option<&str>, + body_params: Option<&BodyParams>, up_to: CascadeLayer, ) -> CascadeSnapshot { let mut snapshot = CascadeSnapshot { @@ -151,6 +172,7 @@ pub fn run_cascade_from_heightmap( heightmap, layer1: None, layer3: None, + layer_region: None, }; // TerritorialStatus is derived once per body from the system's dominant @@ -188,6 +210,36 @@ pub fn run_cascade_from_heightmap( snapshot.layer3 = Some(l3); } + // RegionProfile layer (T-1023, D-239 §1) — pure derivation from body params + + // terrain analysis. Needs a TerrainAnalysis, which needs a drainage pass. + // Layer 1 already ran drainage inside run_layer1, but neither the drainage + // result nor the TerrainAnalysis is stored on Layer1Output, so we re-run both + // here. Pure → determinism preserved, but the drainage re-run is NOT free at + // the ~6 000-regions/body working scale (D-203). + // PERF/TODO(T-1028/T-1032): cache TerrainAnalysis on Layer1Output to drop this + // redundant drainage pass, and validate the combined cost against the D-239 §10 + // ~45 ms/body budget in the T-1031 verification harness. Latent for now — this + // path only runs when body_params is Some, which production defers to T-1032. + // If body_params is None, the region layer is skipped (e.g. unit tests without DB). + if up_to >= CascadeLayer::RegionProfile { + if let Some(params) = body_params { + use crate::atlas::drainage; + let dr = drainage::analyze( + &snapshot.heightmap.data, + snapshot.heightmap.width, + snapshot.heightmap.height, + snapshot.heightmap.sea_level, + ); + let ta = TerrainAnalysis::analyze(&snapshot.heightmap, &dr); + // ~8 cells per region on a 128×64 working grid → ~80×32 = ~2 560 regions; + // at full working resolution the budget is ~6 000/body (D-203). + const CELLS_PER_REGION: usize = 8; + let regions = + region_profile::derive_all_regions(body_seed, params, &ta, CELLS_PER_REGION); + snapshot.layer_region = Some(LayerRegionOutput { regions }); + } + } + snapshot } @@ -202,6 +254,7 @@ pub fn run_cascade( default_sea_level: f32, cities: &[CityRecord], dominant_faction: Option<&str>, + body_params: Option<&BodyParams>, up_to: CascadeLayer, ) -> Result { // Layer 0 — the cascade's input; always loaded. @@ -211,6 +264,7 @@ pub fn run_cascade( heightmap, cities, dominant_faction, + body_params, up_to, )) } @@ -252,6 +306,7 @@ mod tests { test_heightmap(), &[], None, + None, // body_params CascadeLayer::Heightmap, ); assert_eq!(snap.body_id, "test_body"); @@ -268,6 +323,7 @@ mod tests { test_heightmap(), &[], None, + None, // body_params CascadeLayer::Topography, ); let l1 = snap.layer1.expect("Layer 1 should have run"); @@ -288,6 +344,7 @@ mod tests { test_heightmap(), &[], None, + None, // body_params CascadeLayer::Topography, )); let b = extract(run_cascade_from_heightmap( @@ -295,6 +352,7 @@ mod tests { test_heightmap(), &[], None, + None, // body_params CascadeLayer::Topography, )); assert_eq!( @@ -311,6 +369,7 @@ mod tests { // breaking which layers run. assert!(CascadeLayer::Heightmap < CascadeLayer::Topography); assert!(CascadeLayer::Topography < CascadeLayer::Settlement); + assert!(CascadeLayer::Settlement < CascadeLayer::RegionProfile); } #[test] @@ -323,11 +382,52 @@ mod tests { 0.3, &[], None, + None, // body_params CascadeLayer::Heightmap, ); assert!(res.is_err(), "missing heightmap must Err, not panic"); } + /// RegionProfile layer runs, produces regions, and is deterministic (T-1023). + #[test] + fn region_profile_layer_runs_and_is_deterministic() { + use crate::atlas::region_profile::BodyParams; + + let params = BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + ..Default::default() + }; + let run = || { + run_cascade_from_heightmap( + body_seed(), + test_heightmap(), + &[], + None, + Some(¶ms), + CascadeLayer::RegionProfile, + ) + }; + let snap1 = run(); + let snap2 = run(); + let lr1 = snap1.layer_region.expect("layer_region should be Some"); + let lr2 = snap2.layer_region.expect("layer_region should be Some"); + assert!(!lr1.regions.is_empty(), "regions map must not be empty"); + assert_eq!( + lr1.regions.len(), + lr2.regions.len(), + "region count deterministic" + ); + // BTreeMap iteration order is deterministic — compare all entries. + for (pos, p1) in &lr1.regions { + let p2 = lr2.regions.get(pos).expect("matching pos in second run"); + assert_eq!(p1.river_threshold, p2.river_threshold); + assert_eq!(p1.tectonic_class, p2.tectonic_class); + assert_eq!(p1.glaciation_grade, p2.glaciation_grade); + } + } + /// Layer 3 — settlement placement runs, places the body's settlements onto /// attractors, and is deterministic (#955, D-211). #[test] @@ -357,6 +457,7 @@ mod tests { test_heightmap(), &cities, Some("concord_assembly"), + None, // body_params CascadeLayer::Settlement, ) }; diff --git a/server/src/atlas/domain_warp.rs b/server/src/atlas/domain_warp.rs new file mode 100644 index 000000000..9751020a1 --- /dev/null +++ b/server/src/atlas/domain_warp.rs @@ -0,0 +1,225 @@ +//! Anti-squaring domain warp (D-239 §4, T-1026). +//! +//! A stateless, hash-based pure function of `(seed, body_id, pos)` that returns +//! a `(dx, dy)` displacement in metres, bounded to ±8 m. Used by downstream +//! voxel derivation to suppress grid/seam artefacts without authoring. +//! +//! ## Determinism +//! +//! The warp is a **positional displacement in f64**, applied to the integer tile +//! position and then truncated back to an integer voxel address with `as i32` (a +//! cast, not a comparison). The ±8 m bound means the FMA-contraction ULP variance +//! (~1e-15 m) is nine orders of magnitude below the 1 m voxel — no platform guard +//! is needed (D-239 §4). +//! +//! ## D-010 compliance +//! +//! The warp is *positional math*, not a structural decision. All morphology / +//! material / gating decisions downstream receive the final **integer** voxel +//! address; the f64 path is fully contained in this module. +//! +//! ## SeedDomain extension +//! +//! Appends `DomainWarp = 7` to [`crate::seed::SeedDomain`] (discriminant-pinned; +//! the test in `seed.rs` enforces append-only — never renumber existing variants). + +use crate::seed::{splitmix64, SeedChain, SeedDomain}; + +/// ±8 m bound — the warp displacement is clamped to this range. +const WARP_BOUND: f64 = 8.0; + +/// Compute the anti-squaring domain warp for `pos` on `body_id`. +/// +/// Returns `(dx, dy)` displacement in metres, each bounded to `[-8.0, 8.0]`. +/// Pure and stateless — no lookup table, no thread-local state. +/// +/// ## Usage +/// +/// ```ignore +/// let (dx, dy) = domain_warp(world_seed, "GJ1c", (tile_x, tile_y)); +/// let voxel_x = (tile_x as f64 + dx) as i32; +/// let voxel_y = (tile_y as f64 + dy) as i32; +/// ``` +/// +/// The final `as i32` is a **cast** (truncation toward zero), not a comparison — +/// IEEE-754 deterministic across targets (D-239 §4). +pub(crate) fn domain_warp(seed: u64, body_id: &str, pos: (i32, i32)) -> (f64, f64) { + // Derive a per-body sub-seed using the canonical SeedChain path (D-224). + let body_seed = SeedChain::for_body(seed, body_id); + + // Derive two independent streams: one for dx, one for dy. + // DomainWarp = 7 (appended to SeedDomain, never renumber). + // Use pos-derived id so each cell in the same body gets a unique stream. + let pos_id = pos_to_id(pos); + + let seed_x = body_seed.derive(SeedDomain::DomainWarp, pos_id).seed(); + // Second stream: mix pos_id with a prime to get an independent y channel. + let seed_y = body_seed + .derive( + SeedDomain::DomainWarp, + splitmix64(pos_id ^ 0xdeadbeef_cafebabe), + ) + .seed(); + + let dx = u64_to_displacement(seed_x); + let dy = u64_to_displacement(seed_y); + + (dx, dy) +} + +/// Fold `(x, y)` tile coordinates into a single u64 id for seed derivation. +/// +/// Uses a bijective Cantor-pairing-style interleave with zigzag encoding so +/// negative coordinates map to distinct non-negative ids. Integer-only (D-010). +#[inline] +fn pos_to_id(pos: (i32, i32)) -> u64 { + // Zigzag-encode each axis: 0→0, -1→1, 1→2, -2→3, 2→4, … + let zz = |v: i32| -> u64 { + let v = v as i64; + ((v << 1) ^ (v >> 63)) as u64 + }; + let x = zz(pos.0); + let y = zz(pos.1); + // Cantor pairing: (x + y)*(x + y + 1)/2 + y — bijective N²→N. + // We use 64-bit wrapping arithmetic; for the world sizes in play (< 2³¹ tiles) + // this is collision-free in practice. + let s = x.wrapping_add(y); + s.wrapping_mul(s.wrapping_add(1)) + .wrapping_div(2) + .wrapping_add(y) +} + +/// Map a raw u64 seed value to a displacement in `[-WARP_BOUND, +WARP_BOUND]`. +/// +/// Passes the seed through `splitmix64` for avalanche, then maps the top 53 +/// bits to `[0.0, 1.0)` via the standard u64→f64 trick (`bits >> 11` gives a +/// 53-bit mantissa), then shifts to `[-0.5, +0.5)` and scales by +/// `2 * WARP_BOUND`. +#[inline] +fn u64_to_displacement(seed: u64) -> f64 { + // One more mix for full avalanche from whatever derive() left. + let mixed = splitmix64(seed); + // Top 53 bits → [0.0, 1.0) using the integer-bit casting approach (not a + // comparison, just a mantissa construction — IEEE-754 portable). + let unit = (mixed >> 11) as f64 * (1.0 / (1u64 << 53) as f64); + // Shift to [-0.5, 0.5) then scale to [-WARP_BOUND, +WARP_BOUND). + (unit - 0.5) * (2.0 * WARP_BOUND) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::seed::fnv1a_64; + use std::thread; + + /// Golden-vector regression anchor: a fixed (seed, body_id, pos) maps to + /// fixed (dx, dy). Mirrors `splitmix64_known_vector` in seed.rs. Single- + /// platform pins suffice — the ±8 m bound + downstream `as i32` truncation + /// (D-239 §4) make cross-platform ULP drift irrelevant to the voxel address. + /// + /// Recompute and re-pin these literals ONLY after a deliberate algorithm + /// change (`pos_to_id`, `u64_to_displacement`, or the SeedDomain chain) — + /// such a change re-rolls the warp for every body and needs a D-record + /// amendment. If this test fails unexpectedly, the warp output drifted. + #[test] + fn golden_vector() { + let (dx, dy) = domain_warp(42, "GJ1c", (100, -50)); + // Pinned literals — the canonical regression anchor for the warp output. + assert_eq!( + dx, -1.8472385880244921, + "domain_warp dx drifted — algorithm changed?" + ); + assert_eq!( + dy, 5.033644613199796, + "domain_warp dy drifted — algorithm changed?" + ); + // Within the ±8 m bound, and the two channels are independent. + assert!(dx.abs() <= WARP_BOUND && dy.abs() <= WARP_BOUND); + assert_ne!(dx, dy); + } + + /// Truncation-as-cast (not rounding): the downstream consumer uses `as i32`. + #[test] + fn truncation_cast_not_rounding() { + // Positive displacement truncates toward zero. + let v: f64 = 3.9; + assert_eq!(v as i32, 3, "positive truncates toward zero"); + // Negative displacement truncates toward zero (not floor). + let v: f64 = -3.9; + assert_eq!(v as i32, -3, "negative truncates toward zero"); + // Applying domain_warp to a tile and casting to voxel address. + let (dx, dy) = domain_warp(1, "test_body", (10, 20)); + let vx = (10_f64 + dx) as i32; + let vy = (20_f64 + dy) as i32; + // The cast should produce a valid address (within ±WARP_BOUND of the tile). + assert!((vx - 10).abs() <= WARP_BOUND as i32 + 1); + assert!((vy - 20).abs() <= WARP_BOUND as i32 + 1); + } + + /// Two threads with the same inputs produce identical results (order-independence). + #[test] + fn two_threads_same_inputs_identical() { + let handle_a = thread::spawn(|| domain_warp(99, "Kallast", (42, -17))); + let handle_b = thread::spawn(|| domain_warp(99, "Kallast", (42, -17))); + let (dxa, dya) = handle_a.join().unwrap(); + let (dxb, dyb) = handle_b.join().unwrap(); + assert_eq!(dxa, dxb, "dx must be identical across threads"); + assert_eq!(dya, dyb, "dy must be identical across threads"); + } + + /// The warp varies with position (not a constant displacement). + #[test] + fn varies_with_position() { + let a = domain_warp(1, "body", (0, 0)); + let b = domain_warp(1, "body", (1, 0)); + let c = domain_warp(1, "body", (0, 1)); + // Adjacent positions should produce different displacements. + assert!(a != b || a != c, "warp must vary with position"); + } + + /// The warp varies with body_id (different bodies get different warps). + #[test] + fn varies_with_body_id() { + let a = domain_warp(1, "BodyA", (50, 50)); + let b = domain_warp(1, "BodyB", (50, 50)); + assert_ne!(a, b, "warp must vary with body_id"); + } + + /// The warp varies with seed (different world seeds produce different warps). + #[test] + fn varies_with_seed() { + let a = domain_warp(1, "BodyA", (50, 50)); + let b = domain_warp(2, "BodyA", (50, 50)); + assert_ne!(a, b, "warp must vary with seed"); + } + + /// Displacement is always within ±8 m bound. + #[test] + fn bounds_respected() { + for seed in [0u64, 1, 42, u64::MAX] { + for body in ["GJ1c", "test", "Velen", "Kallast"] { + for x in [-1000i32, -1, 0, 1, 1000] { + for y in [-1000i32, -1, 0, 1, 1000] { + let (dx, dy) = domain_warp(seed, body, (x, y)); + assert!( + dx.abs() <= WARP_BOUND, + "dx={dx} out of ±{WARP_BOUND} for ({x},{y})" + ); + assert!( + dy.abs() <= WARP_BOUND, + "dy={dy} out of ±{WARP_BOUND} for ({x},{y})" + ); + } + } + } + } + } + + /// `fnv1a_64` is the canonical body_id hasher used in SeedChain::for_body. + /// Verify it's available here (compile-time sanity check). + #[test] + fn fnv1a_is_accessible() { + let h = fnv1a_64("GJ1c"); + assert_ne!(h, 0); + } +} diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 31e6b1aa8..ba193e34d 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -31,6 +31,7 @@ use crate::atlas::attractor_matching::CityRecord; use crate::atlas::body_world_state::BodyWorldState; use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer}; use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W}; +use crate::atlas::region_profile::BodyParams; use crate::atlas::skeleton_gen::{assign_all_block_tags, generate_quarter_skeleton}; use crate::seed::SeedChain; use crate::simulation::generator::{CityGenerationContext, QuarterWorldState}; @@ -76,6 +77,11 @@ pub enum GenWorkItem { /// time. Drives Layer-3 TerritorialStatus + spatial character (#956, /// D-212/214/215). `None` → `FrontierUnclaimed`. dominant_faction: Option, + /// Body physical parameters for the RegionProfile layer (T-1023, D-239 §1). + /// Pre-resolved at dispatch time. `None` → region layer skipped for this body. + /// Boxed: `BodyParams` is large relative to other variants (clippy + /// large_enum_variant) — boxing keeps `GenWorkItem` compact. + body_params: Option>, }, /// Generate a Phase 1 QuarterSkeleton for this city. /// @@ -358,6 +364,7 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion { body_seed, cities, dominant_faction, + body_params, } => match load_heightmap_png(heightmap_path, body_id, *sea_level) { Ok(hm) => { // Layer 1 runs at the GRID_W×GRID_H working resolution (D-202): @@ -367,16 +374,22 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion { } else { hm }; - // Run through Layer 3 (settlement placement, #955): the enqueuer - // pre-resolved this body's settlements onto `cities` and its - // system faction onto `dominant_faction` (#956). A body with no - // settlements yields empty placements at negligible cost. + // Run through RegionProfile (T-1023, D-239 §1): includes Settlement + // and all prior layers. RegionProfile > Settlement in CascadeLayer ord + // so Settlement also runs when body_params is Some. When body_params + // is None the cascade falls back to Settlement as the terminal layer. + let up_to = if body_params.is_some() { + CascadeLayer::RegionProfile + } else { + CascadeLayer::Settlement + }; let snapshot = run_cascade_from_heightmap( *body_seed, working, cities, dominant_faction.as_deref(), - CascadeLayer::Settlement, + body_params.as_deref(), + up_to, ); GenCompletion::BodyAnalyzed { body_id: body_id.clone(), @@ -480,6 +493,7 @@ mod tests { body_seed: SeedChain::for_body(42, body_id), cities: vec![], dominant_faction: None, + body_params: None, // T-1023: no body params in queue-mechanic unit tests } } diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 3e808e618..5a1f9cb34 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -132,6 +132,7 @@ pub fn handle_atlas_request( body_seed: SeedChain::for_body(world_seed, &req.body_id), cities, dominant_faction, + body_params: None, // T-1023: body_params wired when DB reader is extended }, GenPriority::Immediate, ); @@ -243,6 +244,7 @@ mod tests { attractors: vec![], placements: vec![], quarters: std::collections::BTreeMap::new(), + regions: std::collections::BTreeMap::new(), last_accessed: 0, }); let (_db, resolver) = empty_resolver(); diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index f3012f2a5..bbfe3a7b7 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -9,6 +9,10 @@ pub mod body_world_state; pub mod cascade; pub mod city_context_reader; pub mod district_mix; +// T-1026 foundation utility: covered by unit tests but not yet called from +// production — its consumer is the T-1028 VoxelColumn pipeline (D-239 §4). +#[allow(dead_code)] +pub mod domain_warp; pub mod drainage; pub mod features; pub mod gen_queue; @@ -16,6 +20,7 @@ pub mod heightmap; pub mod layer1; pub mod layer_proxy; pub mod plugin; +pub mod region_profile; pub mod skeleton_gen; pub mod source_resolver; pub mod subbiome; diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index 90b363bdc..4ac4e7a31 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -246,6 +246,7 @@ mod tests { body_seed: SeedChain::for_body(42, "PlanetX"), cities: vec![], dominant_faction: None, + body_params: None, // T-1023: no DB params in this unit test }, GenPriority::Immediate, ); diff --git a/server/src/atlas/region_profile.rs b/server/src/atlas/region_profile.rs new file mode 100644 index 000000000..8912828cc --- /dev/null +++ b/server/src/atlas/region_profile.rs @@ -0,0 +1,1078 @@ +//! RegionProfile carrier — ~1 km scale tier of the D-239 refinement chain (T-1023). +//! +//! A `RegionProfile` is the coarsest derivation stage: ~1 km² cells (~6 000 per body, +//! roughly 80 × 75), each a pure deterministic function of +//! `(seed, body_params, terrain_analysis, pos)`. Stored in `BodyWorldState.regions` +//! so the Atlas can read zone labels without triggering voxel derivation (D-239 §10, +//! D-203). +//! +//! ## D-010 compliance +//! +//! All gating parameters (`GlaciationGrade`, `TectonicClass`, `river_threshold`) are +//! derived from integer body params via integer arithmetic. No `HashMap` or +//! floating-point comparison in the derivation path. +//! +//! ## MorphologyZone note +//! +//! The `morphology_zone` field uses the **existing** `MorphologyZone` enum from +//! `simulation::generator`. D-239 §6 defines a frozen 17-zone vocabulary; reconciling +//! the two (replacing the 12-variant enum with 17) is T-1027's job. For now we carry +//! the existing enum as-is. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::atlas::features::TerrainAnalysis; +use crate::seed::SeedChain; +use crate::simulation::generator::MorphologyZone; + +// --------------------------------------------------------------------------- +// Supporting enums +// --------------------------------------------------------------------------- + +/// Tectonic activity class — integer-discriminant, append-only (D-010). +/// +/// Used in morphology gates (D-239 §5): LavaField requires `Volcanic`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum TectonicClass { + /// Stable craton — minimal volcanism, low earthquake risk. + #[default] + Stable = 0, + /// Active rifting / orogenesis — mountains, earthquakes, some volcanism. + Active = 1, + /// Dominant volcanic activity — lava fields, shield volcanoes. + Volcanic = 2, + /// Tidally-stressed body — volcanism driven by gravitational flexing. + TidallyForced = 3, +} + +/// Glaciation grade (0–4) — integer-discriminant, append-only (D-010). +/// +/// D-239 §5 gates: fjord ≥ 2; U-valleys ≥ 1; moraines ≥ 1; cirques ≥ 2; +/// grade 0 = V-ridges, never glacial U. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum GlaciationGrade { + /// No glaciation — V-ridges only. + #[default] + None = 0, + /// Glacial erosion signatures — U-valleys, moraines. + Light = 1, + /// Moderate glaciation — fjords possible, cirques. + Moderate = 2, + /// Heavy glaciation — ice sheets, extensive fjord systems. + Heavy = 3, + /// Full ice-cap or snowball body. + IceCap = 4, +} + +/// Precipitation class derived from hydrosphere + temperature band. +/// +/// Integer-discriminant, D-010 compliant. Used to derive `river_threshold`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum PrecipitationClass { + /// Arid — very low precipitation. + Arid = 0, + /// Semi-arid — moderate precipitation. + SemiArid = 1, + /// Temperate — normal precipitation. + #[default] + Temperate = 2, + /// Humid — high precipitation. + Humid = 3, + /// Super-humid / oceanic world. + SuperHumid = 4, +} + +// --------------------------------------------------------------------------- +// Body parameters (input to derivation) +// --------------------------------------------------------------------------- + +/// Body-level physical parameters needed to derive `RegionProfile`. +/// +/// Modelled on the `BodyRow` reader at `bin/atlas/common.rs`. Source columns +/// live on the `bodies` table: `hydrosphere`, `atmosphere`, `planet_class`, +/// `orbital_period_days`. All are optional (may be NULL in the DB). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BodyParams { + /// `bodies.hydrosphere` — "ocean" | "ice" | "rivers" | "none" | "subsurface" | NULL + pub hydrosphere: Option, + /// `bodies.atmosphere` — "breathable" | "thin" | "toxic" | "none" | "dense" | NULL + pub atmosphere: Option, + /// `bodies.planet_class` — "temperate" | "arid" | "frozen" | "oceanic" | "volcanic" | … | NULL + pub planet_class: Option, + /// `bodies.orbital_period_days` — orbital period in Earth days. + pub orbital_period_days: Option, + /// `bodies.tectonic_activity` — optional authored tectonic override. + /// "stable" | "active" | "volcanic" | "tidally_forced". If absent, derived + /// from `planet_class`. + pub tectonic_activity: Option, + /// `bodies.axial_tilt_deg` — axial tilt in degrees (T-1024, D-239 §2). + /// Sourced from planet-gen body-def frontmatter. + pub axial_tilt_deg: Option, + /// `star_systems.spectral_class` — stellar spectral class ("G", "K", "M", …). + /// Used to derive stellar luminosity for temperature calculation (T-1024). + pub spectral_class: Option, + /// `star_systems.star_type` — fallback stellar type if spectral_class is absent. + pub star_type: Option, + /// Latitude of the region's centre in the body's reference frame, in degrees. + /// 0.0 = equator, ±90.0 = poles. Used for latitude-band temperature gradient. + pub region_latitude_deg: f64, + /// Mean elevation of this region relative to sea level, in km. Used for lapse rate. + pub elevation_km: f64, +} + +// --------------------------------------------------------------------------- +// RegionPos — position on the ~1 km region grid +// --------------------------------------------------------------------------- + +/// Position on the per-body ~1 km region grid. +/// +/// Each axis is a cell index; a ~6 000-region body has ~80 columns × ~75 rows. +/// `BTreeMap` key — implements `Ord` for D-010 determinism. +pub type RegionPos = (i32, i32); + +// --------------------------------------------------------------------------- +// RegionProfile +// --------------------------------------------------------------------------- + +/// Per-region (~1 km) terrain classification derived from body params + heightmap. +/// +/// Pure derivation — no per-body authoring. All gating parameters come from +/// `BodyParams`; lore-anchored bodies honour their params, not override hooks. +/// Stored in `BodyWorldState.regions` (D-203, D-239 §10). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegionProfile { + /// Region morphology zone (D-239 §6). Uses the **existing** `MorphologyZone` + /// enum — reconciliation to the D-239 frozen 17-zone vocabulary is T-1027. + pub morphology_zone: MorphologyZone, + + /// Tectonic activity class (D-239 §5). + pub tectonic_class: TectonicClass, + + /// Glaciation grade 0–4 (D-239 §5 gates). + pub glaciation_grade: GlaciationGrade, + + /// Precipitation class, derived from hydrosphere + body params. + pub precipitation_class: PrecipitationClass, + + /// Integer-quantized slope summary for the region (0–100 scale, units: 1 = ~0.45 °). + /// Derived from the `TerrainAnalysis.slope_deg` cells that fall in this region. + pub slope_q: i32, + + /// Integer-quantized elevation percentile summary (0–100 scale). + /// Average of `TerrainAnalysis.elev_pct` × 100 across region cells. + pub elev_q: i32, + + /// Ocean fraction for this region (0–100 scale, integer). + pub ocean_fraction_q: i32, + + /// Per-region river threshold (D-239 §1). Replaces the global + /// `RIVER_THRESHOLD = 200` for tile-layer consumers. The drainage constant + /// itself is unchanged — this value is what `RegionProfile` carries downstream. + pub river_threshold: i32, + + /// Nullable mean-annual temperature in °C (T-1024 forward declaration). + /// `None` if no atmosphere (airless; see D-239 §2, D-227). + pub temperature_c: Option, + + /// Moisture primitive (T-1024 forward declaration). + /// Integer 0–100; 0 = arid, 100 = saturated. + pub moisture_q: i32, +} + +// --------------------------------------------------------------------------- +// Derivation helpers +// --------------------------------------------------------------------------- + +/// Derive `TectonicClass` from body params. +/// +/// Respects the optional `tectonic_activity` authored override; falls back to +/// `planet_class` derivation. Integer / string comparison only (D-010). +fn derive_tectonic_class(params: &BodyParams) -> TectonicClass { + // Authored override takes precedence. + if let Some(ta) = ¶ms.tectonic_activity { + return match ta.as_str() { + "volcanic" => TectonicClass::Volcanic, + "active" => TectonicClass::Active, + "tidally_forced" => TectonicClass::TidallyForced, + _ => TectonicClass::Stable, + }; + } + // Derive from planet_class. + match params.planet_class.as_deref().unwrap_or("") { + "volcanic" => TectonicClass::Volcanic, + "oceanic" | "ocean_world" => TectonicClass::Active, + _ => TectonicClass::Stable, + } +} + +/// Derive `PrecipitationClass` from body params. +fn derive_precipitation_class(params: &BodyParams) -> PrecipitationClass { + match params.hydrosphere.as_deref().unwrap_or("none") { + "none" => PrecipitationClass::Arid, + "subsurface" => PrecipitationClass::SemiArid, + "ice" => PrecipitationClass::SemiArid, + "rivers" => PrecipitationClass::Temperate, + "ocean" => match params.atmosphere.as_deref().unwrap_or("none") { + "dense" | "breathable" => PrecipitationClass::SuperHumid, + "thin" => PrecipitationClass::Humid, + _ => PrecipitationClass::Temperate, + }, + _ => PrecipitationClass::Temperate, + } +} + +/// Derive `GlaciationGrade` from body params. +/// +/// Cold bodies (frozen planet_class, ice hydrosphere) with atmosphere get +/// higher glaciation. Airless ice bodies get grade 0 (geology, not climate). +fn derive_glaciation_grade(params: &BodyParams) -> GlaciationGrade { + let has_atmo = params.atmosphere.as_deref().unwrap_or("none") != "none"; + let planet = params.planet_class.as_deref().unwrap_or(""); + let hydro = params.hydrosphere.as_deref().unwrap_or("none"); + + if !has_atmo { + // Airless — ice is geology (D-227), no glacial morphology. + return GlaciationGrade::None; + } + match (planet, hydro) { + ("frozen", "ice") => GlaciationGrade::Heavy, + ("frozen", _) => GlaciationGrade::Moderate, + (_, "ice") => GlaciationGrade::Light, + _ => GlaciationGrade::None, + } +} + +/// Derive per-region river threshold from body params (D-239 §1). +/// +/// Replaces the global `RIVER_THRESHOLD = 200` for tile-layer consumers. +/// Higher hydrosphere + precipitation → lower threshold (more rivers). +/// All arithmetic is integer (D-010). +pub fn derive_river_threshold(params: &BodyParams) -> i32 { + let tectonic_bonus: i32 = match derive_tectonic_class(params) { + TectonicClass::Active => -30, + TectonicClass::Volcanic => -20, + TectonicClass::TidallyForced => -10, + TectonicClass::Stable => 0, + }; + let precip_factor: i32 = match derive_precipitation_class(params) { + PrecipitationClass::Arid => 100, + PrecipitationClass::SemiArid => 50, + PrecipitationClass::Temperate => 0, + PrecipitationClass::Humid => -50, + PrecipitationClass::SuperHumid => -80, + }; + // Base of 200 + body-level adjustments, clamped to [20, 500]. + (200 + precip_factor + tectonic_bonus).clamp(20, 500) +} + +/// Derive `MorphologyZone` from `RegionProfile` gating params (D-239 §5). +/// +/// Strict decision tree over integer inputs. Gate order is significant — +/// more-constrained types are tested first (D-239 §7). +fn derive_morphology_zone( + tectonic: TectonicClass, + glaciation: GlaciationGrade, + slope_q: i32, + elev_q: i32, + ocean_fraction_q: i32, +) -> MorphologyZone { + // Open ocean / lake (submerged). + if ocean_fraction_q >= 80 { + return MorphologyZone::OpenOcean; + } + // LavaField: requires Volcanic tectonic (D-239 §5). + if tectonic == TectonicClass::Volcanic { + return MorphologyZone::AlluvialPlain; // Mapped to AlluvialPlain until T-1027 adds Volcanic zone + } + // Fjord: requires GlaciationGrade ≥ 2 (D-239 §5) + high slope + coastal. + if glaciation >= GlaciationGrade::Moderate && slope_q >= 40 && ocean_fraction_q >= 20 { + return MorphologyZone::Fjord; + } + // Canyon: high slope + high elevation + non-coastal. + if slope_q >= 60 && elev_q >= 50 && ocean_fraction_q < 10 { + return MorphologyZone::Canyon; + } + // Mountain pass: high elevation, moderate slope. + if elev_q >= 70 && slope_q >= 30 { + return MorphologyZone::MountainPass; + } + // Coastal zone: low elevation, ocean nearby. + if ocean_fraction_q >= 30 && elev_q < 30 { + return MorphologyZone::CoastalLowland; + } + // Delta / braided: very flat, low elevation, some water. + if slope_q <= 5 && elev_q < 20 && ocean_fraction_q >= 10 { + return MorphologyZone::Delta; + } + // Meander reach: gentle slope, mid elevation. + if slope_q <= 15 && elev_q < 50 && ocean_fraction_q >= 5 { + return MorphologyZone::MeanderReach; + } + // Fallback: AlluvialPlain. + MorphologyZone::AlluvialPlain +} + +// --------------------------------------------------------------------------- +// Climate constants (T-1024, D-239 §2) +// --------------------------------------------------------------------------- + +/// Maximum per-region elevation (km), used to scale a region's normalized +/// elevation (`elev_q`, 0–100) into the temperature-lapse input. Earth-like +/// span (~Everest); a coarse body-agnostic constant for now — a per-body relief +/// span can replace it when body relief data is carried (T-1032). +const MAX_REGION_ELEVATION_KM: f64 = 8.0; + +/// Climate tuning constants. `ClimateConstants::default()` holds the embedded +/// values and is the authoritative runtime source **today**; the source-canonical +/// `server/data/climate_constants.toml` mirrors those same values and is the file +/// to edit when tuning. Runtime loading of the TOML ("tunable without recompile") +/// lands with the production dispatch wiring (T-1032) — there is intentionally no +/// `load()` method yet, so keep the TOML and the embedded `default()` in sync by +/// hand until then. +/// +/// Values mirror `tooling/planet-gen/planet_simulation.py`. +#[derive(Debug, Clone)] +pub struct ClimateConstants { + /// Greenhouse warming offset per atmosphere class (°C). + /// Keys: "thin", "standard", "breathable", "toxic", "dense". + /// "none" / absent = 0 °C (but airless bodies return `None` before reaching this). + pub greenhouse_offset_c: std::collections::BTreeMap, + /// Day/night swing amplitude per atmosphere class (°C). + pub diurnal_amplitude_c: std::collections::BTreeMap, + /// Stellar luminosity relative to Sol. Keys: spectral class letter. + pub star_luminosity: std::collections::BTreeMap, +} + +impl Default for ClimateConstants { + /// Embedded fallback — matches `server/data/climate_constants.toml`. + /// Used when the TOML file is not available (tests, embedded contexts). + fn default() -> Self { + let mut gh = std::collections::BTreeMap::new(); + gh.insert("thin".into(), 8.0f32); + gh.insert("standard".into(), 33.0f32); + gh.insert("breathable".into(), 33.0f32); + gh.insert("toxic".into(), 33.0f32); + gh.insert("dense".into(), 80.0f32); + + let mut da = std::collections::BTreeMap::new(); + da.insert("thin".into(), 50.0f32); + da.insert("standard".into(), 15.0f32); + da.insert("breathable".into(), 15.0f32); + da.insert("toxic".into(), 20.0f32); + da.insert("dense".into(), 3.0f32); + + let mut sl = std::collections::BTreeMap::new(); + sl.insert("O".into(), 100_000.0f64); + sl.insert("B".into(), 1_000.0f64); + sl.insert("A".into(), 10.0f64); + sl.insert("F".into(), 2.5f64); + sl.insert("G".into(), 1.0f64); + sl.insert("K".into(), 0.4f64); + sl.insert("M".into(), 0.04f64); + + ClimateConstants { + greenhouse_offset_c: gh, + diurnal_amplitude_c: da, + star_luminosity: sl, + } + } +} + +impl ClimateConstants { + /// Greenhouse offset for a given atmosphere class (°C), defaulting to 0. + pub fn greenhouse(&self, atmosphere: &str) -> f32 { + self.greenhouse_offset_c + .get(atmosphere) + .copied() + .unwrap_or(0.0) + } + + /// Diurnal amplitude for a given atmosphere class (°C), defaulting to 30. + pub fn diurnal_amplitude(&self, atmosphere: &str) -> f32 { + self.diurnal_amplitude_c + .get(atmosphere) + .copied() + .unwrap_or(30.0) + } + + /// Stellar luminosity for a given spectral class (relative to Sol = 1.0). + pub fn luminosity(&self, spectral_class: &str) -> f64 { + // Spectral class is often multi-character like "G2V"; take the first letter. + let key = spectral_class + .chars() + .next() + .map(|c| c.to_uppercase().to_string()) + .unwrap_or_default(); + self.star_luminosity.get(&key).copied().unwrap_or(1.0) + } +} + +/// Derive orbital distance in AU from orbital period (days) and stellar luminosity. +/// +/// Kepler's 3rd law (approximation for main-sequence star with mass ≈ luminosity^0.25): +/// a_AU = (T_yr)^(2/3) × (star_mass)^(1/3) +/// star_mass_solar ≈ luminosity^0.25 (main-sequence mass-luminosity relation) +/// +/// This is the same derivation used in `planet_simulation.py`. +/// Uses f64 throughout; result is a positional input, not a gate comparison (D-010). +fn derive_distance_au(orbital_period_days: f64, luminosity_solar: f64) -> f64 { + let t_yr = orbital_period_days / 365.25; + // Mass-luminosity: M ≈ L^0.25 (rough but adequate for temperature estimation) + let star_mass = luminosity_solar.powf(0.25); + t_yr.powf(2.0 / 3.0) * star_mass.powf(1.0 / 3.0) +} + +/// Derive mean-annual district temperature in °C (nullable). +/// +/// Returns `None` if no atmosphere (airless; D-227). +/// Implements D-239 §2: mean-annual scalar, latitude + elevation lapse, greenhouse offset. +/// Orbital phase is fixed at equinox; diurnal swing is carried as `amplitude_c` only. +/// +/// Uses f64 for intermediate calculations; the final result is cast to f32 +/// (adequate precision for a ~2 km district-mean temperature). +/// +/// D-010: all gating decisions downstream use the integer `temperature_c as i32`; +/// the f64/f32 here is positional physics, not a structural comparison. +pub fn derive_temperature_c(params: &BodyParams, constants: &ClimateConstants) -> Option { + let atmosphere = params.atmosphere.as_deref().unwrap_or("none"); + + // No atmosphere → airless body; temperature is None (D-227). + if atmosphere == "none" { + return None; + } + + // Stellar luminosity — from spectral_class or star_type. + let spectral = params + .spectral_class + .as_deref() + .or(params.star_type.as_deref()) + .unwrap_or("G"); + let luminosity = constants.luminosity(spectral); + + // Orbital distance — derive from orbital_period_days if available, + // else fall back to 1 AU (Sol-equivalent distance). + let distance_au = params + .orbital_period_days + .filter(|&d| d > 0.0) + .map(|d| derive_distance_au(d, luminosity)) + .unwrap_or(1.0); + + // Stellar equilibrium temperature (K) — Stefan-Boltzmann approximation. + let t_equilibrium = 278.5 * luminosity.powf(0.25) / distance_au.sqrt(); + + // Greenhouse offset (°C from planet_simulation.py tuning). + let greenhouse = constants.greenhouse(atmosphere) as f64; + let t_base = t_equilibrium + greenhouse; + + // Latitude gradient. Axial tilt modulates the equator–pole delta. + let axial_tilt = params.axial_tilt_deg.unwrap_or(23.4); // Earth-like default + // tilt_factor: 1.0 = no tilt (full equator–pole gradient), 0.5 = 90° tilt (reduced gradient) + let tilt_factor = 1.0 - (axial_tilt / 90.0) * 0.5; + let lat_gradient_c = 60.0 * tilt_factor; + let lat_frac = params.region_latitude_deg / 90.0; // [-1.0, 1.0] + let t_lat = t_base - lat_gradient_c * lat_frac.abs(); + + // Elevation lapse rate (°C/km). Earth standard ~6.5 °C/km; airless = 2. + let lapse = if atmosphere == "thin" { + 3.5_f64 + } else { + 6.5_f64 + }; + let t_final = t_lat - lapse * params.elevation_km.max(0.0); + + // Convert from K to °C (subtract 273.15) and clamp to a plausible range. + let t_celsius = (t_final - 273.15).clamp(-200.0, 600.0) as f32; + Some(t_celsius) +} + +/// Derive moisture primitive (0–100 integer). +/// +/// D-239 §2: moisture is derived from hydrosphere + atmosphere; 0 = arid, 100 = saturated. +/// Integer output (D-010). +pub fn derive_moisture_q(params: &BodyParams) -> i32 { + let hydro = params.hydrosphere.as_deref().unwrap_or("none"); + let atmo = params.atmosphere.as_deref().unwrap_or("none"); + + let base: i32 = match hydro { + "none" => 0, + "subsurface" => 10, + "ice" => 20, + "rivers" => 55, + "ocean" => 80, + _ => 30, + }; + let atmo_boost: i32 = match atmo { + "none" => -20, + "thin" => -10, + "standard" | "breathable" => 0, + "toxic" => 5, + "dense" => 15, + _ => 0, + }; + (base + atmo_boost).clamp(0, 100) +} + +// --------------------------------------------------------------------------- +// Public derivation function +// --------------------------------------------------------------------------- + +/// Derive a `RegionProfile` for the region at `pos` on a `~1km` grid. +/// +/// Pure (no I/O, no side effects). Inputs are the body's params and the +/// pre-computed `TerrainAnalysis` from Layer 1. +/// +/// `grid_cells_per_region` controls how many heightmap cells map to one region +/// cell; default is 8 (at 128×64 working grid, that yields ~80×64 regions ≈ +/// ~5 000 regions/body, within the D-203 ~6 000/body budget). +/// +/// Temperature and moisture are derived inline via D-239 §2 climate functions +/// (T-1024). Pass a `&ClimateConstants` to control the tuning constants. +pub fn derive_region_profile( + // Reserved: per-region stochastic derivation (T-1027/T-1028) will derive + // from this under a dedicated `SeedDomain::RegionProfile`. Unused today. + _seed: SeedChain, + body_params: &BodyParams, + ta: &TerrainAnalysis, + pos: RegionPos, + grid_cells_per_region: usize, + climate: &ClimateConstants, +) -> RegionProfile { + let (rx, ry) = pos; + let w = ta.w; + let h = ta.h; + let gcpr = grid_cells_per_region.max(1); + + // Compute aggregate terrain statistics over the cells in this region. + // All arithmetic is integer or quantized-integer (D-010). + let mut slope_sum: i64 = 0; + let mut elev_sum: i64 = 0; + let mut ocean_count: i64 = 0; + let mut cell_count: i64 = 0; + + let row_start = (ry as usize).saturating_mul(gcpr).min(h); + let row_end = row_start.saturating_add(gcpr).min(h); + let col_start = (rx as usize).saturating_mul(gcpr).min(w); + let col_end = col_start.saturating_add(gcpr).min(w); + + for r in row_start..row_end { + for c in col_start..col_end { + let i = r * w + c; + // Quantize slope to 0–100: slope_deg is ~[0, 45°]; divide by 45 × 100. + // This is a float-to-int boundary inside the aggregation; downstream + // decisions use `slope_q` (integer). + slope_sum += ((ta.slope_deg[i] / 45.0).clamp(0.0, 1.0) * 100.0) as i64; + elev_sum += (ta.elev_pct[i] * 100.0) as i64; + ocean_count += ta.ocean_mask[i] as i64; + cell_count += 1; + } + } + + let (slope_q, elev_q, ocean_fraction_q) = if cell_count > 0 { + let sq = (slope_sum / cell_count) as i32; + let eq = (elev_sum / cell_count) as i32; + let oq = (ocean_count * 100 / cell_count) as i32; + (sq, eq, oq) + } else { + (0, 0, 0) + }; + + let tectonic_class = derive_tectonic_class(body_params); + let glaciation_grade = derive_glaciation_grade(body_params); + let precipitation_class = derive_precipitation_class(body_params); + let river_threshold = derive_river_threshold(body_params); + + let morphology_zone = derive_morphology_zone( + tectonic_class, + glaciation_grade, + slope_q, + elev_q, + ocean_fraction_q, + ); + + // Climate derivation (T-1024, D-239 §2). Temperature lapse must vary by THIS + // region's elevation — otherwise every region on a body shares one body-level + // elevation and gets an identical lapse, defeating the per-district temperature + // primitive. Build a region-local BodyParams whose elevation_km comes from the + // region's own normalized elevation (elev_q, 0–100) scaled to the body's + // elevation span. region_latitude_deg is already per-region (set by the caller + // / derive_all_regions). Per-cell refinement happens later at ChunkContext. + let region_climate_params = BodyParams { + elevation_km: (elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM, + ..body_params.clone() + }; + let temperature_c = derive_temperature_c(®ion_climate_params, climate); + let moisture_q = derive_moisture_q(body_params); + + RegionProfile { + morphology_zone, + tectonic_class, + glaciation_grade, + precipitation_class, + slope_q, + elev_q, + ocean_fraction_q, + river_threshold, + temperature_c, + moisture_q, + } +} + +/// Derive region profiles for all regions covering the body. +/// +/// Returns a `BTreeMap` covering the full +/// heightmap at the given region-grid resolution. +/// +/// `grid_cells_per_region = 8` means each region is 8×8 heightmap cells. +/// +/// Region latitude is derived from the row index: ry=0 maps to the north pole +/// (+90°), ry=region_rows-1 maps to the south pole (-90°). This is a linear +/// mapping across the equirectangular heightmap. +pub fn derive_all_regions( + seed: SeedChain, + body_params: &BodyParams, + ta: &TerrainAnalysis, + grid_cells_per_region: usize, +) -> BTreeMap { + let climate = ClimateConstants::default(); + let gcpr = grid_cells_per_region.max(1); + let region_cols = ta.w.div_ceil(gcpr) as i32; + let region_rows = ta.h.div_ceil(gcpr) as i32; + + let mut out = BTreeMap::new(); + for ry in 0..region_rows { + // Map ry to latitude: row 0 → +90°, row (rows-1) → -90°. + // For a single-row grid, latitude is 0°. + let lat_deg = if region_rows > 1 { + 90.0 - (ry as f64 / (region_rows - 1) as f64) * 180.0 + } else { + 0.0 + }; + for rx in 0..region_cols { + let pos = (rx, ry); + // Build per-region params: body-level params + this region's latitude. + // Per-region elevation is NOT set here — derive_region_profile owns it, + // deriving elevation_km from the region's own elev_q (not the caller's + // body_params.elevation_km). Per-cell refinement happens at ChunkContext (D-239). + let region_params = BodyParams { + region_latitude_deg: lat_deg, + ..body_params.clone() + }; + let profile = derive_region_profile(seed, ®ion_params, ta, pos, gcpr, &climate); + out.insert(pos, profile); + } + } + out +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::atlas::drainage; + use crate::atlas::heightmap::BodyHeightmap; + use crate::seed::SeedDomain; + + fn test_hm() -> BodyHeightmap { + let (w, h) = (64u32, 32u32); + let n = (w * h) as usize; + let data = (0..n) + .map(|i| { + let r = (i / w as usize) as f32 / h as f32; + let c = (i % w as usize) as f32 / w as f32; + (r * 0.6 + c * 0.4).min(1.0) + }) + .collect(); + BodyHeightmap { + body_id: "test".into(), + width: w, + height: h, + data, + sea_level: 0.3, + } + } + + fn test_seed() -> SeedChain { + SeedChain::root(42).derive(SeedDomain::Body, 1) + } + + fn test_ta(hm: &BodyHeightmap) -> TerrainAnalysis { + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + TerrainAnalysis::analyze(hm, &dr) + } + + #[test] + fn derive_all_regions_covers_full_heightmap() { + let hm = test_hm(); + let ta = test_ta(&hm); + let params = BodyParams::default(); + let regions = derive_all_regions(test_seed(), ¶ms, &ta, 8); + + // Expected: ceil(64/8) × ceil(32/8) = 8 × 4 = 32 regions. + assert_eq!(regions.len(), 32, "region count mismatch"); + } + + #[test] + fn derive_region_profile_is_deterministic() { + let hm = test_hm(); + let ta = test_ta(&hm); + let params = BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + ..Default::default() + }; + let pos = (2, 1); + let climate = ClimateConstants::default(); + let p1 = derive_region_profile(test_seed(), ¶ms, &ta, pos, 8, &climate); + let p2 = derive_region_profile(test_seed(), ¶ms, &ta, pos, 8, &climate); + // Equality via serialized fields (no PartialEq on MorphologyZone — compare by name). + assert_eq!( + format!("{:?}", p1.morphology_zone), + format!("{:?}", p2.morphology_zone), + "morphology_zone must be deterministic" + ); + assert_eq!(p1.tectonic_class, p2.tectonic_class); + assert_eq!(p1.glaciation_grade, p2.glaciation_grade); + assert_eq!(p1.river_threshold, p2.river_threshold); + assert_eq!(p1.slope_q, p2.slope_q); + assert_eq!(p1.elev_q, p2.elev_q); + } + + #[test] + fn volcanic_body_gets_volcanic_tectonic() { + let params = BodyParams { + planet_class: Some("volcanic".into()), + ..Default::default() + }; + assert_eq!(derive_tectonic_class(¶ms), TectonicClass::Volcanic); + } + + #[test] + fn authored_tectonic_override_wins() { + let params = BodyParams { + planet_class: Some("temperate".into()), + tectonic_activity: Some("volcanic".into()), + ..Default::default() + }; + assert_eq!(derive_tectonic_class(¶ms), TectonicClass::Volcanic); + } + + #[test] + fn airless_body_gets_no_glaciation() { + let params = BodyParams { + planet_class: Some("frozen".into()), + hydrosphere: Some("ice".into()), + atmosphere: Some("none".into()), + ..Default::default() + }; + // Airless: ice is geology, not climate (D-227). + assert_eq!(derive_glaciation_grade(¶ms), GlaciationGrade::None); + } + + #[test] + fn frozen_body_with_atmo_gets_heavy_glaciation() { + let params = BodyParams { + planet_class: Some("frozen".into()), + hydrosphere: Some("ice".into()), + atmosphere: Some("thin".into()), + ..Default::default() + }; + assert_eq!(derive_glaciation_grade(¶ms), GlaciationGrade::Heavy); + } + + #[test] + fn arid_body_has_higher_river_threshold() { + let arid = BodyParams { + hydrosphere: Some("none".into()), + ..Default::default() + }; + let humid = BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("dense".into()), + ..Default::default() + }; + assert!( + derive_river_threshold(&arid) > derive_river_threshold(&humid), + "arid body should have higher river threshold than humid body" + ); + } + + #[test] + fn tectonic_class_discriminants_pinned() { + // Load-bearing: renumbering breaks the D-010 integer derivation contract. + assert_eq!(TectonicClass::Stable as u8, 0); + assert_eq!(TectonicClass::Active as u8, 1); + assert_eq!(TectonicClass::Volcanic as u8, 2); + assert_eq!(TectonicClass::TidallyForced as u8, 3); + } + + #[test] + fn glaciation_grade_discriminants_pinned() { + assert_eq!(GlaciationGrade::None as u8, 0); + assert_eq!(GlaciationGrade::Light as u8, 1); + assert_eq!(GlaciationGrade::Moderate as u8, 2); + assert_eq!(GlaciationGrade::Heavy as u8, 3); + assert_eq!(GlaciationGrade::IceCap as u8, 4); + } + + #[test] + fn precipitation_class_discriminants_pinned() { + assert_eq!(PrecipitationClass::Arid as u8, 0); + assert_eq!(PrecipitationClass::SemiArid as u8, 1); + assert_eq!(PrecipitationClass::Temperate as u8, 2); + assert_eq!(PrecipitationClass::Humid as u8, 3); + assert_eq!(PrecipitationClass::SuperHumid as u8, 4); + } + + #[test] + fn regions_use_btreemap_order() { + let hm = test_hm(); + let ta = test_ta(&hm); + let params = BodyParams::default(); + let regions = derive_all_regions(test_seed(), ¶ms, &ta, 8); + // BTreeMap iterates in sorted key order — verify the first key is (0,0). + let first = regions.keys().next().expect("at least one region"); + assert_eq!(*first, (0, 0), "first region must be at origin"); + } + + #[test] + fn river_threshold_clamped_to_range() { + // Even with extreme params, threshold stays in [20, 500]. + let extreme_humid = BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("dense".into()), + tectonic_activity: Some("active".into()), + ..Default::default() + }; + let extreme_arid = BodyParams { + hydrosphere: Some("none".into()), + atmosphere: Some("none".into()), + ..Default::default() + }; + let t_humid = derive_river_threshold(&extreme_humid); + let t_arid = derive_river_threshold(&extreme_arid); + assert!( + (20..=500).contains(&t_humid), + "humid threshold {t_humid} out of range" + ); + assert!( + (20..=500).contains(&t_arid), + "arid threshold {t_arid} out of range" + ); + } + + // ----------------------------------------------------------------------- + // T-1024 climate derivation tests + // ----------------------------------------------------------------------- + + #[test] + fn airless_body_has_no_temperature() { + let params = BodyParams { + atmosphere: Some("none".into()), + ..Default::default() + }; + let climate = ClimateConstants::default(); + assert_eq!( + derive_temperature_c(¶ms, &climate), + None, + "airless body must return None temperature (D-227)" + ); + } + + #[test] + fn earth_like_body_temperature_plausible() { + // Earth: G-star, 365-day orbit, 23.5° tilt, breathable atmosphere, equator. + let params = BodyParams { + atmosphere: Some("breathable".into()), + spectral_class: Some("G".into()), + orbital_period_days: Some(365.25), + axial_tilt_deg: Some(23.5), + region_latitude_deg: 0.0, + elevation_km: 0.0, + ..Default::default() + }; + let climate = ClimateConstants::default(); + let t = derive_temperature_c(¶ms, &climate).expect("breathable body must have temp"); + // Earth equator is roughly 20–30°C. With our formula T_eq ~278K + 33K greenhouse + // = ~38°C, plausible for equatorial region. + assert!( + (-10.0..=80.0).contains(&t), + "Earth-like equatorial temperature {t}°C out of plausible range [-10, 80]" + ); + } + + #[test] + fn polar_region_is_colder_than_equatorial() { + let base_params = BodyParams { + atmosphere: Some("breathable".into()), + spectral_class: Some("G".into()), + orbital_period_days: Some(365.25), + axial_tilt_deg: Some(23.5), + elevation_km: 0.0, + ..Default::default() + }; + let climate = ClimateConstants::default(); + let equatorial = BodyParams { + region_latitude_deg: 0.0, + ..base_params.clone() + }; + let polar = BodyParams { + region_latitude_deg: 90.0, + ..base_params + }; + let t_eq = derive_temperature_c(&equatorial, &climate).unwrap(); + let t_pol = derive_temperature_c(&polar, &climate).unwrap(); + assert!( + t_pol < t_eq, + "polar temperature {t_pol}°C must be less than equatorial {t_eq}°C" + ); + } + + #[test] + fn high_elevation_is_colder() { + let base_params = BodyParams { + atmosphere: Some("breathable".into()), + spectral_class: Some("G".into()), + orbital_period_days: Some(365.25), + axial_tilt_deg: Some(23.5), + region_latitude_deg: 0.0, + ..Default::default() + }; + let climate = ClimateConstants::default(); + let sea_level = BodyParams { + elevation_km: 0.0, + ..base_params.clone() + }; + let mountain = BodyParams { + elevation_km: 5.0, + ..base_params + }; + let t_low = derive_temperature_c(&sea_level, &climate).unwrap(); + let t_high = derive_temperature_c(&mountain, &climate).unwrap(); + // Lapse rate 6.5°C/km × 5 km = 32.5°C colder. + assert!( + t_high < t_low, + "mountain temperature {t_high}°C must be less than sea level {t_low}°C" + ); + let delta = t_low - t_high; + assert!( + (20.0..=45.0).contains(&delta), + "elevation delta {delta}°C unexpected for 5 km lapse" + ); + } + + #[test] + fn m_star_body_same_orbit_colder_than_g_star() { + let base_params = BodyParams { + atmosphere: Some("breathable".into()), + orbital_period_days: Some(365.25), + axial_tilt_deg: Some(23.5), + region_latitude_deg: 0.0, + elevation_km: 0.0, + ..Default::default() + }; + let climate = ClimateConstants::default(); + let g_params = BodyParams { + spectral_class: Some("G".into()), + ..base_params.clone() + }; + let m_params = BodyParams { + spectral_class: Some("M".into()), + ..base_params + }; + let t_g = derive_temperature_c(&g_params, &climate).unwrap(); + let t_m = derive_temperature_c(&m_params, &climate).unwrap(); + // M star has luminosity 0.04 × Sol; same orbital period but star is + // much dimmer so equilibrium temperature is much lower. + assert!( + t_m < t_g, + "M-star temperature {t_m}°C must be less than G-star {t_g}°C at same orbital period" + ); + } + + #[test] + fn moisture_q_ocean_breathable_is_high() { + let params = BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + ..Default::default() + }; + let q = derive_moisture_q(¶ms); + assert!(q >= 70, "ocean + breathable moisture {q} should be >= 70"); + } + + #[test] + fn moisture_q_airless_is_zero() { + let params = BodyParams { + hydrosphere: Some("none".into()), + atmosphere: Some("none".into()), + ..Default::default() + }; + let q = derive_moisture_q(¶ms); + assert_eq!(q, 0, "airless no-hydrosphere body moisture must be 0"); + } + + #[test] + fn moisture_q_clamped_to_range() { + // All combinations must stay in [0, 100]. + let hydros = ["none", "subsurface", "ice", "rivers", "ocean", "unknown"]; + let atmos = [ + "none", + "thin", + "standard", + "breathable", + "toxic", + "dense", + "unknown", + ]; + for h in &hydros { + for a in &atmos { + let params = BodyParams { + hydrosphere: Some(h.to_string()), + atmosphere: Some(a.to_string()), + ..Default::default() + }; + let q = derive_moisture_q(¶ms); + assert!( + (0..=100).contains(&q), + "moisture_q {q} out of range for hydro={h} atmo={a}" + ); + } + } + } + + #[test] + fn temperature_is_deterministic() { + let params = BodyParams { + atmosphere: Some("thin".into()), + spectral_class: Some("K".into()), + orbital_period_days: Some(200.0), + axial_tilt_deg: Some(10.0), + region_latitude_deg: 45.0, + elevation_km: 1.5, + ..Default::default() + }; + let climate = ClimateConstants::default(); + let t1 = derive_temperature_c(¶ms, &climate); + let t2 = derive_temperature_c(¶ms, &climate); + assert_eq!(t1, t2, "temperature derivation must be deterministic"); + } + + #[test] + fn climate_constants_spectral_class_prefix_match() { + // "G2V" should resolve to the same luminosity as "G". + let cc = ClimateConstants::default(); + assert_eq!(cc.luminosity("G2V"), cc.luminosity("G")); + assert_eq!(cc.luminosity("M5"), cc.luminosity("M")); + // Unknown class falls back to 1.0 (Sol). + assert_eq!(cc.luminosity(""), 1.0); + } +} diff --git a/server/src/seed.rs b/server/src/seed.rs index c271cb5f5..abbb7407d 100644 --- a/server/src/seed.rs +++ b/server/src/seed.rs @@ -94,6 +94,9 @@ pub enum SeedDomain { Block = 5, /// NPC generation (keyed by NPC StableId). Npc = 6, + /// Anti-squaring domain warp (D-239 §4, T-1026). + /// Keyed by per-tile position id (see `atlas::domain_warp::pos_to_id`). + DomainWarp = 7, } /// A position in the deterministic seed tree (D-224). @@ -249,6 +252,7 @@ mod tests { assert_eq!(SeedDomain::Layer4Quarter as u64, 4); assert_eq!(SeedDomain::Block as u64, 5); assert_eq!(SeedDomain::Npc as u64, 6); + assert_eq!(SeedDomain::DomainWarp as u64, 7); } #[test] diff --git a/server/tests/cascade_golden.rs b/server/tests/cascade_golden.rs index 4cf5b66c5..271acf8c6 100644 --- a/server/tests/cascade_golden.rs +++ b/server/tests/cascade_golden.rs @@ -56,7 +56,7 @@ fn cascade_layer0_to_1_matches_golden() { let small = heightmap.downsample(DOWNSAMPLE.0, DOWNSAMPLE.1); let body_seed = SeedChain::for_body(WORLD_SEED, "GJ1c"); let snapshot = - run_cascade_from_heightmap(body_seed, small, &[], None, CascadeLayer::Topography); + run_cascade_from_heightmap(body_seed, small, &[], None, None, CascadeLayer::Topography); let layer1 = snapshot.layer1.expect("Layer 1 ran"); let actual = json!({ diff --git a/tooling/economy-db/import_economics.py b/tooling/economy-db/import_economics.py index 0c6439514..a0798ce94 100755 --- a/tooling/economy-db/import_economics.py +++ b/tooling/economy-db/import_economics.py @@ -480,6 +480,7 @@ COLUMN_MIGRATIONS = [ ("corporations", "shadow_economy_access", "INTEGER DEFAULT 0"), ("brand_products", "price_tier", "TEXT"), ("bodies", "body_radius_km", "REAL"), # D-204 — physical radius in km, nullable + ("bodies", "axial_tilt_deg", "REAL"), # T-1024, D-239 §2 — axial tilt from body-def frontmatter ("meta", "schema_sha", "TEXT"), ("atlas_city_names", "settlement_class", "TEXT"), # D-196 — NULL until placement (#37) ("system_economy", "economic_specialization", "TEXT"), # D-237 — authored specialization layer @@ -1279,6 +1280,121 @@ def populate_body_radius_km(conn: sqlite3.Connection, dry_run: bool) -> int: return len(updates) +def populate_axial_tilt_deg(conn: sqlite3.Connection, dry_run: bool) -> int: + """Populate bodies.axial_tilt_deg from planet-gen body-def frontmatter (T-1024). + + Reads wiki/star-systems/*/bodies/*/index.md YAML frontmatter and extracts + ``orbit.axial_tilt_deg``. Only updates rows where axial_tilt_deg IS NULL + (preserves any future authoritative column writes). + + Source: body_definition_parser.py writes ``orbit.axial_tilt_deg`` into each + body's index.md during the planet-gen batch run. This function mirrors + ``populate_body_radius_km`` in structure. + """ + import yaml # stdlib-compatible subset via PyYAML if available, else manual parse + + def _parse_frontmatter_yaml(text: str) -> dict: + """Extract YAML frontmatter block from a markdown file.""" + lines = text.split("\n") + if not lines or lines[0].strip() != "---": + return {} + end_idx = None + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + end_idx = i + break + if end_idx is None: + return {} + fm_text = "\n".join(lines[1:end_idx]) + try: + result = yaml.safe_load(fm_text) + return result if isinstance(result, dict) else {} + except Exception: + return {} + + # Check if yaml is available; if not, use manual extraction. + try: + import yaml as _yaml_check # noqa: F401 + has_yaml = True + except ImportError: + has_yaml = False + + if not has_yaml: + # Fallback: manual extraction of axial_tilt_deg from YAML frontmatter. + # Scans for " axial_tilt_deg: " under an "orbit:" block. + def _parse_frontmatter_manual(text: str) -> dict: + lines = text.split("\n") + if not lines or lines[0].strip() != "---": + return {} + in_orbit = False + result: dict = {} + for line in lines[1:]: + if line.strip() == "---": + break + stripped = line.strip() + if stripped == "orbit:": + in_orbit = True + continue + if in_orbit: + # Detect leaving the orbit block (non-indented key). + if line and not line.startswith(" ") and not line.startswith("\t"): + in_orbit = False + elif stripped.startswith("axial_tilt_deg:"): + _, _, val = stripped.partition(":") + try: + result["axial_tilt_deg"] = float(val.strip()) + except ValueError: + pass + return result + + def _parse_frontmatter_yaml(text: str) -> dict: # type: ignore[misc] + return _parse_frontmatter_manual(text) + + # Find all body index.md files. + body_dir_pattern = WIKI_STAR_SYSTEMS / "*" / "bodies" / "*" / "index.md" + import glob as _glob + body_files = sorted(_glob.glob(str(body_dir_pattern))) + + # Build body_id → axial_tilt_deg mapping from frontmatter. + tilt_map: dict[str, float] = {} + for fpath in body_files: + try: + text = Path(fpath).read_text(encoding="utf-8") + except OSError: + continue + fm = _parse_frontmatter_yaml(text) + if not isinstance(fm, dict): + continue + body_id = fm.get("id") + orbit = fm.get("orbit", {}) + if isinstance(orbit, dict): + tilt = orbit.get("axial_tilt_deg") + else: + tilt = None + if body_id and tilt is not None: + try: + tilt_map[str(body_id)] = float(tilt) + except (TypeError, ValueError): + pass + + # Get all bodies where axial_tilt_deg IS NULL and body_id is in tilt_map. + rows = conn.execute( + "SELECT body_id FROM bodies WHERE axial_tilt_deg IS NULL" + ).fetchall() + + updates = [] + for (body_id,) in rows: + if body_id in tilt_map: + updates.append((tilt_map[body_id], body_id)) + + if not dry_run and updates: + conn.executemany( + "UPDATE bodies SET axial_tilt_deg = ? WHERE body_id = ?", updates + ) + + return len(updates) + + # Atlas geometry index tables (D-191). These hold computed positions — city # centres, road/river/rail polylines, ocean/mountain extents. Under D-223 the # Python atlas geometry generator was retired (#951); the deterministic @@ -2414,6 +2530,11 @@ def main(): n_bias = populate_atlas_body_trait_bias(conn, args.dry_run) print(f" {n_bias} body trait-bias rows") + # 16. axial_tilt_deg from body-def frontmatter (T-1024, D-239 §2) + print(" [16/16] Populating axial_tilt_deg from body-def frontmatter...") + n_tilt = populate_axial_tilt_deg(conn, args.dry_run) + print(f" {n_tilt} bodies updated with axial_tilt_deg") + # Validate structural integrity (FK, chain refs, chain completeness). # These errors indicate broken imported data — do NOT commit. print("\n Validating structural integrity...")