//! Location → culture resolution (D-128). //! //! Single canonical lookup: `location_id → CultureTag`. //! Culture is implicit in the starting location — Van Maanen's Star start = //! Van Maanen's Star culture. All downstream pipelines (voice, NPC blueprint, //! apartment generator, visual grammar) call through here, not ad-hoc queries. use std::{ path::Path, sync::{Arc, Mutex}, }; use bevy_ecs::prelude::*; use rusqlite::{Connection, OpenFlags}; use serde::{Deserialize, Serialize}; /// Canonical culture identifier (D-128). /// /// String-backed — cultures expand with content, not code. /// Value space matches `star_systems.cultural_corridor` in systems.db: /// `"core"`, `"sol-gateway-axis"`, `"north_reach"`, `"south_reach"`, /// `"east_reach"`, `"west_reach"`, `"deep_frontier"`. /// /// Wire format: plain `String` over IPC. #[derive(Debug, Clone, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)] pub struct CultureTag(pub String); impl CultureTag { pub fn as_str(&self) -> &str { &self.0 } } impl std::fmt::Display for CultureTag { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } /// Error from culture resolution. #[derive(Debug, thiserror::Error)] pub enum CultureError { /// `location_id` does not resolve to any known row in systems.db. #[error("unknown location: `{0}`")] UnknownLocation(String), /// Location row found but `cultural_corridor` is NULL — this is a data bug. #[error("no culture assigned to location `{0}` in systems.db")] NoCulture(String), /// Underlying SQLite error. #[error("database error: {0}")] Db(String), } /// Handle that owns the DB connection. /// /// Constructed once at startup against `server/data/systems.db`. /// `Mutex` mirrors `SettingsStoreResource` — rusqlite `Connection` /// is `!Sync`. All queries are indexed primary-key lookups: sub-microsecond. pub struct CultureResolver { conn: Arc>, } impl CultureResolver { /// Open a resolver. `SQLITE_OPEN_READ_ONLY` — purely query-side. pub fn open(path: &Path) -> Result { let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) .map_err(|e| CultureError::Db(e.to_string()))?; Ok(CultureResolver { conn: Arc::new(Mutex::new(conn)), }) } } /// Bevy `Resource` wrapper — `Res` in systems. #[derive(Resource)] pub struct CultureResolverResource(pub CultureResolver); /// Resolve a `location_id` to the `CultureTag` it implies (D-128). /// /// Accepted forms: /// - `system_id` — e.g. `"GJ 35"` (matched against `star_systems.system_id`) /// - `body_id` — e.g. `"GJ 35-2"` (body override wins; falls to parent system) /// - `station_id` — e.g. `"sova-transit"` (always falls through to parent system) /// /// Tries each table in order, returns the first match. /// For v0.2 bookmark selection, callers pass `system_id`, but the function is /// body/station-aware so downstream systems don't need a second lookup path. /// /// # Errors /// - `CultureError::UnknownLocation` — not found in any table. /// - `CultureError::NoCulture` — found but culture column is NULL (data bug). /// - `CultureError::Db` — SQLite I/O failure. pub fn resolve_culture( resolver: &CultureResolver, location_id: &str, ) -> Result { let conn = resolver .conn .lock() .map_err(|e| CultureError::Db(format!("mutex poisoned: {}", e)))?; if let Some(culture) = query_system_culture(&conn, location_id)? { return Ok(CultureTag(culture)); } if let Some(culture) = query_body_culture(&conn, location_id)? { return Ok(CultureTag(culture)); } if let Some(culture) = query_station_culture(&conn, location_id)? { return Ok(CultureTag(culture)); } Err(CultureError::UnknownLocation(location_id.to_string())) } /// Try `star_systems` by `system_id`. /// Returns `Ok(None)` if no row. `Ok(Some(culture))` or `Err(NoCulture)` if found. fn query_system_culture(conn: &Connection, loc: &str) -> Result, CultureError> { let result: rusqlite::Result>> = conn .query_row( "SELECT cultural_corridor FROM star_systems WHERE system_id = ?1", [loc], |row| row.get(0), ) .map(Some) .or_else(|e| match e { rusqlite::Error::QueryReturnedNoRows => Ok(None), other => Err(other), }); match result.map_err(|e| CultureError::Db(e.to_string()))? { None => Ok(None), Some(Some(c)) => Ok(Some(c)), Some(None) => Err(CultureError::NoCulture(loc.to_string())), } } /// Try `bodies` by `body_id`. Body's own `cultural_corridor` wins; NULL falls /// through to parent system's corridor via COALESCE. fn query_body_culture(conn: &Connection, loc: &str) -> Result, CultureError> { let result: rusqlite::Result>> = conn .query_row( "SELECT COALESCE(b.cultural_corridor, s.cultural_corridor) \ FROM bodies b \ JOIN star_systems s ON s.system_id = b.system_id \ WHERE b.body_id = ?1", [loc], |row| row.get(0), ) .map(Some) .or_else(|e| match e { rusqlite::Error::QueryReturnedNoRows => Ok(None), other => Err(other), }); match result.map_err(|e| CultureError::Db(e.to_string()))? { None => Ok(None), Some(Some(c)) => Ok(Some(c)), Some(None) => Err(CultureError::NoCulture(loc.to_string())), } } /// Try `stations` by `station_id`. Always falls through to parent system's corridor. fn query_station_culture(conn: &Connection, loc: &str) -> Result, CultureError> { let result: rusqlite::Result>> = conn .query_row( "SELECT s.cultural_corridor \ FROM stations st \ JOIN star_systems s ON s.system_id = st.system_id \ WHERE st.station_id = ?1", [loc], |row| row.get(0), ) .map(Some) .or_else(|e| match e { rusqlite::Error::QueryReturnedNoRows => Ok(None), other => Err(other), }); match result.map_err(|e| CultureError::Db(e.to_string()))? { None => Ok(None), Some(Some(c)) => Ok(Some(c)), Some(None) => Err(CultureError::NoCulture(loc.to_string())), } } #[cfg(test)] mod tests { use super::*; fn fixture_db() -> CultureResolver { let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/knowledge/fixtures/culture_test.db"); CultureResolver::open(&path).expect("open fixture DB") } #[test] fn resolves_known_system() { let r = fixture_db(); let tag = resolve_culture(&r, "GJ 35").expect("resolve"); assert_eq!(tag.as_str(), "south_reach"); } #[test] fn resolves_known_system_gateway() { let r = fixture_db(); let tag = resolve_culture(&r, "GJ 244A").expect("resolve"); assert_eq!(tag.as_str(), "sol-gateway-axis"); } #[test] fn resolves_known_body_override() { let r = fixture_db(); // GJ 35-2 has body-level override "core" despite parent being "south_reach" let tag = resolve_culture(&r, "GJ 35-2").expect("resolve body"); assert_eq!(tag.as_str(), "core"); } #[test] fn resolves_body_inherits_parent_system() { let r = fixture_db(); // GJ 35-3 has NULL cultural_corridor — falls through to parent GJ 35 = "south_reach" let tag = resolve_culture(&r, "GJ 35-3").expect("resolve body inheritance"); assert_eq!(tag.as_str(), "south_reach"); } #[test] fn resolves_station_to_parent_system() { let r = fixture_db(); let tag = resolve_culture(&r, "sova-transit").expect("resolve station"); assert_eq!(tag.as_str(), "south_reach"); } #[test] fn unknown_location_returns_err() { let r = fixture_db(); let err = resolve_culture(&r, "BOGUS-SYSTEM-XYZ").unwrap_err(); assert!( matches!(err, CultureError::UnknownLocation(_)), "expected UnknownLocation, got {:?}", err ); } #[test] fn null_culture_returns_err() { let r = fixture_db(); // GJ 999-null exists in fixture but has NULL cultural_corridor let err = resolve_culture(&r, "GJ 999-null").unwrap_err(); assert!( matches!(err, CultureError::NoCulture(_)), "expected NoCulture, got {:?}", err ); } #[test] fn concurrent_reads_are_safe() { use std::sync::Arc; use std::thread; let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/knowledge/fixtures/culture_test.db"); let r = Arc::new(CultureResolver::open(&path).expect("open")); let handles: Vec<_> = (0..4) .map(|_| { let r2 = Arc::clone(&r); thread::spawn(move || { for _ in 0..250 { let tag = resolve_culture(&r2, "GJ 35").expect("resolve in thread"); assert_eq!(tag.as_str(), "south_reach"); } }) }) .collect(); for h in handles { h.join().expect("thread panic"); } } }