//! 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); } }