diff --git a/server/src/atlas/body_params_reader.rs b/server/src/atlas/body_params_reader.rs new file mode 100644 index 000000000..bbad763f2 --- /dev/null +++ b/server/src/atlas/body_params_reader.rs @@ -0,0 +1,353 @@ +//! Pre-dispatch reader for body physical parameters (T-1032, D-239 §1). +//! +//! Reads the columns needed to construct a [`BodyParams`] for the RegionProfile +//! carrier layer from `systems.db` at `AnalyzeBody` enqueue time. This keeps +//! the Rayon work item DB-free while supplying the climate/tectonic inputs +//! required by `derive_all_regions`. +//! +//! **Columns queried** (all are nullable in the schema — `BodyParams` fields are +//! `Option`): +//! +//! | Field | Source | +//! |-------|--------| +//! | `hydrosphere` | `bodies.hydrosphere` | +//! | `atmosphere` | `bodies.atmosphere` | +//! | `planet_class` | `bodies.planet_class` | +//! | `orbital_period_days` | `bodies.orbital_period_days` | +//! | `axial_tilt_deg` | `bodies.axial_tilt_deg` | +//! | `spectral_class` | `star_systems.spectral_class` (via `bodies.system_id`) | +//! | `star_type` | `star_systems.star_type` (via `bodies.system_id`) | +//! +//! `tectonic_activity` is **not** in the current schema; `BodyParams.tectonic_activity` +//! is left `None` so the derivation falls back to `planet_class` as documented +//! on the struct. The per-region fields `region_latitude_deg` and `elevation_km` +//! are set by `derive_all_regions` / `derive_region_profile`, not here; they +//! remain at their struct defaults (0.0) from this reader. +//! +//! Read-only `systems.db` access follows the same pattern as +//! [`crate::atlas::source_resolver::BodySourceResolver`] and +//! [`crate::atlas::city_context_reader::CityContextReader`]. + +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use rusqlite::{Connection, OpenFlags}; +use thiserror::Error; + +use crate::atlas::region_profile::BodyParams; + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +/// Errors from reading body physical parameters. +#[derive(Debug, Error)] +pub enum BodyParamsReadError { + #[error("systems.db error: {0}")] + Db(String), + #[error("body {0} not found in bodies table")] + UnknownBody(String), +} + +// --------------------------------------------------------------------------- +// Reader +// --------------------------------------------------------------------------- + +/// Reads body physical parameters from `systems.db` at `AnalyzeBody` dispatch +/// time. Holds a read-only SQLite connection. +/// +/// Analogous to [`crate::atlas::city_context_reader::CityContextReader`] — +/// pre-resolves inputs the Rayon work item needs before it is submitted, +/// keeping the cascade DB-free (D-225 pattern). +pub struct BodyParamsReader { + conn: Arc>, +} + +impl BodyParamsReader { + /// Open a read-only connection to `systems_db`. + pub fn open(systems_db: &Path) -> Result { + let conn = Connection::open_with_flags(systems_db, OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| BodyParamsReadError::Db(e.to_string()))?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + /// Read the physical parameters for `body_id`. + /// + /// Joins `bodies` → `star_systems` (LEFT JOIN, so a body with no system row + /// still returns valid params with `spectral_class` and `star_type` = `None`). + /// + /// Returns `BodyParamsReadError::UnknownBody` if the body is not in the DB. + /// A body that exists but has all-NULL columns still returns `Ok(BodyParams::default())` — + /// every derivation function handles missing fields gracefully. + pub fn read_body_params(&self, body_id: &str) -> Result { + let conn = self + .conn + .lock() + .map_err(|e| BodyParamsReadError::Db(format!("mutex poisoned: {e}")))?; + + // All columns are nullable; query row presence is what signals UnknownBody. + // `tectonic_activity` is absent from the current schema — leave that + // BodyParams field None (derives from planet_class at query time). + let result: rusqlite::Result<( + Option, // b.hydrosphere + Option, // b.atmosphere + Option, // b.planet_class + Option, // b.orbital_period_days + Option, // b.axial_tilt_deg + Option, // s.spectral_class + Option, // s.star_type + )> = conn.query_row( + "SELECT + b.hydrosphere, + b.atmosphere, + b.planet_class, + b.orbital_period_days, + b.axial_tilt_deg, + s.spectral_class, + s.star_type + FROM bodies AS b + LEFT JOIN star_systems AS s ON s.system_id = b.system_id + WHERE b.body_id = ?1", + [body_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + )) + }, + ); + + match result { + Ok(( + hydrosphere, + atmosphere, + planet_class, + orbital_period_days, + axial_tilt_deg, + spectral_class, + star_type, + )) => { + Ok(BodyParams { + hydrosphere, + atmosphere, + planet_class, + orbital_period_days, + axial_tilt_deg, + spectral_class, + star_type, + // tectonic_activity not in schema — leave None. + tectonic_activity: None, + // Per-region fields are set by derive_all_regions / derive_region_profile, + // not at the body level. Leave at struct defaults (0.0). + region_latitude_deg: 0.0, + elevation_km: 0.0, + }) + } + Err(rusqlite::Error::QueryReturnedNoRows) => { + Err(BodyParamsReadError::UnknownBody(body_id.to_string())) + } + Err(e) => Err(BodyParamsReadError::Db(e.to_string())), + } + } +} + +// --------------------------------------------------------------------------- +// Bevy resource wrapper +// --------------------------------------------------------------------------- + +/// Bevy `Resource` wrapper — `Res` in systems. +/// Mirrors [`crate::atlas::city_context_reader::CityContextReaderResource`]; +/// the atlas proxy uses it to read a body's physical params on a cache miss. +#[derive(bevy_ecs::prelude::Resource)] +pub struct BodyParamsReaderResource(pub BodyParamsReader); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU32, Ordering}; + + static SEQ: AtomicU32 = AtomicU32::new(0); + + fn make_test_db( + body_id: &str, + system_id: &str, + hydrosphere: Option<&str>, + atmosphere: Option<&str>, + planet_class: Option<&str>, + orbital_period_days: Option, + axial_tilt_deg: Option, + spectral_class: Option<&str>, + star_type: Option<&str>, + ) -> PathBuf { + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("sr_bpr_{}_{n}.db", std::process::id())); + let _ = std::fs::remove_file(&path); + let conn = Connection::open(&path).expect("create db"); + conn.execute_batch( + "CREATE TABLE star_systems ( + system_id TEXT PRIMARY KEY, + spectral_class TEXT, + star_type TEXT + ); + CREATE TABLE bodies ( + body_id TEXT PRIMARY KEY, + system_id TEXT, + hydrosphere TEXT, + atmosphere TEXT, + planet_class TEXT, + orbital_period_days REAL, + axial_tilt_deg REAL + );", + ) + .expect("create tables"); + + conn.execute( + "INSERT INTO star_systems (system_id, spectral_class, star_type) VALUES (?1, ?2, ?3)", + rusqlite::params![system_id, spectral_class, star_type], + ) + .expect("insert system"); + + conn.execute( + "INSERT INTO bodies (body_id, system_id, hydrosphere, atmosphere, planet_class, orbital_period_days, axial_tilt_deg) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![body_id, system_id, hydrosphere, atmosphere, planet_class, orbital_period_days, axial_tilt_deg], + ) + .expect("insert body"); + + drop(conn); + path + } + + #[test] + fn reads_all_columns_present() { + let db = make_test_db( + "GJ1c", + "GJ-1", + Some("ocean"), + Some("breathable"), + Some("temperate"), + Some(365.25), + Some(23.5), + Some("G"), + Some("main_sequence"), + ); + let reader = BodyParamsReader::open(&db).expect("open"); + let params = reader.read_body_params("GJ1c").expect("read"); + + assert_eq!(params.hydrosphere.as_deref(), Some("ocean")); + assert_eq!(params.atmosphere.as_deref(), Some("breathable")); + assert_eq!(params.planet_class.as_deref(), Some("temperate")); + assert!((params.orbital_period_days.unwrap() - 365.25).abs() < 1e-9); + assert!((params.axial_tilt_deg.unwrap() - 23.5).abs() < 1e-9); + assert_eq!(params.spectral_class.as_deref(), Some("G")); + assert_eq!(params.star_type.as_deref(), Some("main_sequence")); + // Per-region fields always start at 0.0 from the reader. + assert_eq!(params.region_latitude_deg, 0.0); + assert_eq!(params.elevation_km, 0.0); + // tectonic_activity not in schema → None. + assert!(params.tectonic_activity.is_none()); + } + + #[test] + fn handles_all_null_columns() { + let db = make_test_db("GJ2b", "GJ-2", None, None, None, None, None, None, None); + let reader = BodyParamsReader::open(&db).expect("open"); + let params = reader.read_body_params("GJ2b").expect("read"); + + // All nullable columns → all None; struct defaults for per-region fields. + assert!(params.hydrosphere.is_none()); + assert!(params.atmosphere.is_none()); + assert!(params.planet_class.is_none()); + assert!(params.orbital_period_days.is_none()); + assert!(params.axial_tilt_deg.is_none()); + assert!(params.spectral_class.is_none()); + assert!(params.star_type.is_none()); + } + + #[test] + fn unknown_body_returns_error() { + let db = make_test_db("GJ3c", "GJ-3", None, None, None, None, None, None, None); + let reader = BodyParamsReader::open(&db).expect("open"); + let err = reader.read_body_params("ghost").expect_err("should fail"); + assert!(matches!(err, BodyParamsReadError::UnknownBody(_))); + } + + #[test] + fn body_without_system_row_returns_null_stellar_fields() { + // Body has no system_id — LEFT JOIN produces NULL for star_systems columns. + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("sr_bpr_ns_{}_{n}.db", std::process::id())); + let _ = std::fs::remove_file(&path); + let conn = Connection::open(&path).expect("create db"); + conn.execute_batch( + "CREATE TABLE star_systems ( + system_id TEXT PRIMARY KEY, + spectral_class TEXT, + star_type TEXT + ); + CREATE TABLE bodies ( + body_id TEXT PRIMARY KEY, + system_id TEXT, + hydrosphere TEXT, + atmosphere TEXT, + planet_class TEXT, + orbital_period_days REAL, + axial_tilt_deg REAL + );", + ) + .expect("create tables"); + // Insert body with no system_id (no system row exists). + conn.execute( + "INSERT INTO bodies (body_id, system_id, atmosphere) VALUES ('lonely', NULL, 'thin')", + [], + ) + .expect("insert"); + drop(conn); + + let reader = BodyParamsReader::open(&path).expect("open"); + let params = reader.read_body_params("lonely").expect("read"); + assert_eq!(params.atmosphere.as_deref(), Some("thin")); + // No system row → stellar fields NULL from LEFT JOIN. + assert!(params.spectral_class.is_none()); + assert!(params.star_type.is_none()); + } + + #[test] + fn read_is_deterministic() { + let db = make_test_db( + "GJ4d", + "GJ-4", + Some("ice"), + Some("thin"), + Some("frozen"), + Some(200.0), + Some(15.0), + Some("K"), + Some("main_sequence"), + ); + let reader = BodyParamsReader::open(&db).expect("open"); + let p1 = reader.read_body_params("GJ4d").expect("first read"); + let p2 = reader.read_body_params("GJ4d").expect("second read"); + + assert_eq!(p1.hydrosphere, p2.hydrosphere); + assert_eq!(p1.atmosphere, p2.atmosphere); + assert_eq!(p1.planet_class, p2.planet_class); + assert_eq!(p1.orbital_period_days, p2.orbital_period_days); + assert_eq!(p1.axial_tilt_deg, p2.axial_tilt_deg); + assert_eq!(p1.spectral_class, p2.spectral_class); + assert_eq!(p1.star_type, p2.star_type); + } +} diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 5a1f9cb34..9ebba0caa 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; +use crate::atlas::body_params_reader::BodyParamsReader; use crate::atlas::body_world_state::{BodyWorldStateCache, SimTick}; use crate::atlas::cascade::CascadeLayer; use crate::atlas::city_context_reader::CityContextReader; @@ -64,12 +65,20 @@ pub struct AtlasLayerResponse { /// `city_reader` supplies the body's settlements for Layer-3 placement (#955), /// read on a cache miss. `None` (or a read failure) places no cities — the /// cascade still runs Layer 1; the body just gets no settlement placements. +/// +/// `body_params_reader` supplies the body's physical parameters for the +/// RegionProfile carrier layer (T-1032, D-239 §1), read on a cache miss. +/// `None` (or a read failure) passes `body_params: None` to the work item, +/// causing the cascade to stop at `CascadeLayer::Settlement` (pre-T-1032 +/// behaviour). A successful read passes `Some(Box::new(params))`, enabling +/// the full `CascadeLayer::RegionProfile` path. pub fn handle_atlas_request( req: &AtlasLayerRequest, cache: &mut BodyWorldStateCache, queue: &GenerationQueue, resolver: &BodySourceResolver, city_reader: Option<&CityContextReader>, + body_params_reader: Option<&BodyParamsReader>, world_seed: u64, current_tick: SimTick, ) -> AtlasLayerResponse { @@ -124,6 +133,24 @@ pub fn handle_atlas_request( } None => (Vec::new(), None), }; + // Pre-resolve body physical params so the Rayon work item stays + // DB-free (D-225 pattern). Read failures are non-fatal: log and + // fall back to None (cascade stops at Settlement, pre-T-1032 + // behaviour, rather than aborting the entire analysis). + let body_params = match body_params_reader { + Some(reader) => reader + .read_body_params(&req.body_id) + .map(|p| Some(Box::new(p))) + .unwrap_or_else(|e| { + tracing::warn!( + body_id = %req.body_id, + error = %e, + "body_params read failed; region layer skipped" + ); + None + }), + None => None, + }; queue.submit( GenWorkItem::AnalyzeBody { body_id: req.body_id.clone(), @@ -132,7 +159,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 + body_params, }, GenPriority::Immediate, ); @@ -250,7 +277,16 @@ mod tests { let (_db, resolver) = empty_resolver(); let queue = GenerationQueue::with_threads(1); - let resp = handle_atlas_request(&req("GJ1c"), &mut cache, &queue, &resolver, None, 42, 1); + let resp = handle_atlas_request( + &req("GJ1c"), + &mut cache, + &queue, + &resolver, + None, + None, + 42, + 1, + ); assert_eq!(resp.status, AtlasLayerStatus::Ready); assert_eq!(resp.layer1.expect("layer1").body_id, "GJ1c"); } @@ -261,7 +297,16 @@ mod tests { let (_db, resolver) = resolver_with_body("GJ1c"); let queue = GenerationQueue::with_threads(1); - let resp = handle_atlas_request(&req("GJ1c"), &mut cache, &queue, &resolver, None, 42, 1); + let resp = handle_atlas_request( + &req("GJ1c"), + &mut cache, + &queue, + &resolver, + None, + None, + 42, + 1, + ); assert_eq!(resp.status, AtlasLayerStatus::Pending); assert!(resp.layer1.is_none()); @@ -282,7 +327,154 @@ mod tests { let (_db, resolver) = empty_resolver(); let queue = GenerationQueue::with_threads(1); - let resp = handle_atlas_request(&req("ghost"), &mut cache, &queue, &resolver, None, 42, 1); + let resp = handle_atlas_request( + &req("ghost"), + &mut cache, + &queue, + &resolver, + None, + None, + 42, + 1, + ); assert_eq!(resp.status, AtlasLayerStatus::NotFound); } + + /// Build a DB with the columns needed by both `BodySourceResolver` and + /// `BodyParamsReader` for the same body, plus a tiny heightmap root. + /// + /// Returns (db_path, resolver, body_params_reader, _root_kept_alive). + fn resolver_and_params_reader( + body_id: &str, + ) -> ( + PathBuf, + BodySourceResolver, + crate::atlas::body_params_reader::BodyParamsReader, + PathBuf, // root dir — must stay alive for the test duration + ) { + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let db = std::env::temp_dir().join(format!("sr_proxybp_{}_{n}.db", std::process::id())); + let _ = std::fs::remove_file(&db); + let conn = Connection::open(&db).unwrap(); + + conn.execute_batch( + "CREATE TABLE star_systems ( + system_id TEXT PRIMARY KEY, + spectral_class TEXT, + star_type TEXT + ); + CREATE TABLE bodies ( + body_id TEXT PRIMARY KEY, + system_id TEXT, + terrain_reference TEXT, + hydrosphere TEXT, + atmosphere TEXT, + planet_class TEXT, + orbital_period_days REAL, + axial_tilt_deg REAL + );", + ) + .unwrap(); + + conn.execute( + "INSERT INTO star_systems (system_id, spectral_class, star_type) VALUES ('GJ-1', 'G', 'main_sequence')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO bodies (body_id, system_id, terrain_reference, hydrosphere, atmosphere, planet_class, orbital_period_days, axial_tilt_deg) + VALUES (?1, 'GJ-1', ?2, 'ocean', 'breathable', 'temperate', 365.25, 23.5)", + rusqlite::params![body_id, REL], + ) + .unwrap(); + drop(conn); + + let root = std::env::temp_dir().join(format!("sr_proxybproot_{}_{n}", std::process::id())); + write_tiny_heightmap(&root.join(REL)); + + let resolver = BodySourceResolver::open(&db, vec![root.clone()]).unwrap(); + let params_reader = crate::atlas::body_params_reader::BodyParamsReader::open(&db).unwrap(); + + (db, resolver, params_reader, root) + } + + /// With body_params_reader wired, a cache miss enqueues an AnalyzeBody that + /// completes with populated `regions` (RegionProfile layer ran). + #[test] + fn body_params_reader_wired_produces_populated_regions() { + let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); + let (_db, resolver, params_reader, _root) = resolver_and_params_reader("GJ1c"); + let queue = GenerationQueue::with_threads(1); + + let resp = handle_atlas_request( + &req("GJ1c"), + &mut cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 1, + ); + assert_eq!(resp.status, AtlasLayerStatus::Pending); + + // Wait for the Rayon work item to complete. + std::thread::sleep(Duration::from_millis(300)); + let completions = queue.drain_completions(); + let body_state = completions + .into_iter() + .find_map(|c| { + if let GenCompletion::BodyAnalyzed { body_id, state } = c { + if body_id == "GJ1c" { + return Some(state); + } + } + None + }) + .expect("AnalyzeBody must complete for GJ1c"); + + assert!( + !body_state.regions.is_empty(), + "regions must be populated when body_params_reader is wired (T-1032 dispatch path)" + ); + } + + /// Without body_params_reader (None), regions is empty — pre-T-1032 behaviour. + #[test] + fn no_body_params_reader_leaves_regions_empty() { + let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); + let (_db, resolver) = resolver_with_body("GJ1c"); + let queue = GenerationQueue::with_threads(1); + + let resp = handle_atlas_request( + &req("GJ1c"), + &mut cache, + &queue, + &resolver, + None, + None, // no body_params_reader + 42, + 1, + ); + assert_eq!(resp.status, AtlasLayerStatus::Pending); + + std::thread::sleep(Duration::from_millis(300)); + let completions = queue.drain_completions(); + let body_state = completions + .into_iter() + .find_map(|c| { + if let GenCompletion::BodyAnalyzed { body_id, state } = c { + if body_id == "GJ1c" { + return Some(state); + } + } + None + }) + .expect("AnalyzeBody must complete for GJ1c"); + + assert!( + body_state.regions.is_empty(), + "regions must remain empty when no body_params_reader is wired" + ); + } } diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index bbfe3a7b7..fe195e0dd 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -5,6 +5,7 @@ pub mod attractor_matching; pub mod block_irregularity; +pub mod body_params_reader; pub mod body_world_state; pub mod cascade; pub mod city_context_reader; diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index 4ac4e7a31..b6c4648e1 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -12,6 +12,7 @@ use bevy_ecs::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; use crate::atlas::attractor_matching::CityPlacement; +use crate::atlas::body_params_reader::BodyParamsReaderResource; use crate::atlas::body_world_state::{BodyWorldStateCache, CACHE_CAPACITY}; use crate::atlas::city_context_reader::{ context_from_read_set, CityContextReaderResource, CityEconomicReadSet, @@ -50,6 +51,7 @@ fn serve_atlas_requests( queue: Res, resolver: Option>, city_reader: Option>, + body_params_reader: Option>, rng: Option>, time: Option>, ) { @@ -59,12 +61,20 @@ fn serve_atlas_requests( let world_seed = rng.as_ref().map(|r| r.seed()).unwrap_or(0); let tick = time.as_ref().map(|t| t.tick).unwrap_or(0); let reader = city_reader.as_ref().map(|r| &r.0); + let params_reader = body_params_reader.as_ref().map(|r| &r.0); let pending: Vec<_> = requests.0.drain(..).collect(); for req in pending { let resp = match resolver.as_ref() { - Some(r) => { - handle_atlas_request(&req, &mut cache, &queue, &r.0, reader, world_seed, tick) - } + Some(r) => handle_atlas_request( + &req, + &mut cache, + &queue, + &r.0, + reader, + params_reader, + world_seed, + tick, + ), None => AtlasLayerResponse { body_id: req.body_id.clone(), status: AtlasLayerStatus::Error("no body source resolver".to_string()), diff --git a/server/src/main.rs b/server/src/main.rs index 6d89a9def..783d374ea 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -215,6 +215,24 @@ fn main() { ), } + // Body physical params reader for RegionProfile carrier layer (T-1032, D-239 §1): + // reads hydrosphere / atmosphere / planet_class / orbital_period_days / + // axial_tilt_deg / spectral_class / star_type on a cache miss so the Rayon + // cascade work item stays DB-free (D-225 pattern). + match settled_reach_server::atlas::body_params_reader::BodyParamsReader::open(&systems_db_path) + { + Ok(reader) => { + tracing::info!("Body params reader opened: {:?}", systems_db_path); + app.insert_resource( + settled_reach_server::atlas::body_params_reader::BodyParamsReaderResource(reader), + ); + } + Err(e) => tracing::warn!( + "Body params reader unavailable ({}). RegionProfile layer will be skipped.", + e + ), + } + // Initialize SQLite settings store (#627). // Path: alongside save files in the server's working directory. let settings_path = std::path::PathBuf::from("settings.db");