//! Mod-first body source resolution (#969, D-225). //! //! Resolves a `body_id` to its source `heightmap.png` by reading the body's //! repo-root-relative `terrain_reference` from `systems.db` (read-only) and //! searching an ordered list of roots — **mod directories (override) over the //! base install (the floor)**. First-party and mod bodies resolve through the //! identical path; there is no baked layer data (D-225). //! //! v1 wires the base root only; the resolver type and the search-order logic //! exist and are tested with a synthetic mod root, so the seam is mod-first //! from day one (Q-099 covers registering a mod's *new* bodies in the catalog). //! //! Read-only `systems.db` access mirrors [`crate::knowledge::culture::CultureResolver`]. use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use bevy_ecs::prelude::Resource; use rusqlite::{Connection, OpenFlags}; use thiserror::Error; #[derive(Debug, Error)] pub enum SourceResolveError { #[error("systems.db error: {0}")] Db(String), #[error("unknown body: {0}")] UnknownBody(String), #[error("body {body_id} has no terrain_reference")] NoTerrainReference { body_id: String }, #[error("terrain_reference for {body_id} ({rel}) not found under any source root")] SourceMissing { body_id: String, rel: String }, } /// Resolves `body_id` → absolute source `heightmap.png`, mod-first (D-225). pub struct BodySourceResolver { conn: Arc>, /// Search roots in precedence order — **earliest wins**. Mod roots first, /// the base install last. The body's repo-root-relative `terrain_reference` /// is joined onto each until an existing file is found. roots: Vec, } impl BodySourceResolver { /// Open against `systems_db` read-only. `roots` are searched in order (mod /// dirs first, base install last). v1 callers pass just the base root. pub fn open(systems_db: &Path, roots: Vec) -> Result { let conn = Connection::open_with_flags(systems_db, OpenFlags::SQLITE_OPEN_READ_ONLY) .map_err(|e| SourceResolveError::Db(e.to_string()))?; Ok(Self { conn: Arc::new(Mutex::new(conn)), roots, }) } /// The repo-root-relative `terrain_reference` for `body_id`. fn terrain_reference(&self, body_id: &str) -> Result { let conn = self .conn .lock() .map_err(|e| SourceResolveError::Db(format!("mutex poisoned: {e}")))?; // Outer Option = row presence; inner Option = the (nullable) column. let res: rusqlite::Result>> = conn .query_row( "SELECT terrain_reference FROM bodies WHERE body_id = ?1", [body_id], |row| row.get(0), ) .map(Some) .or_else(|e| match e { rusqlite::Error::QueryReturnedNoRows => Ok(None), other => Err(other), }); match res.map_err(|e| SourceResolveError::Db(e.to_string()))? { None => Err(SourceResolveError::UnknownBody(body_id.to_string())), Some(None) => Err(SourceResolveError::NoTerrainReference { body_id: body_id.to_string(), }), Some(Some(rel)) => Ok(rel), } } /// Resolve `body_id` to the absolute path of its source `heightmap.png`, /// searching mod roots over the base install. pub fn resolve(&self, body_id: &str) -> Result { let rel = self.terrain_reference(body_id)?; for root in &self.roots { let candidate = root.join(&rel); if candidate.is_file() { return Ok(candidate); } } Err(SourceResolveError::SourceMissing { body_id: body_id.to_string(), rel, }) } } /// Bevy `Resource` wrapper — `Res` in systems. #[derive(Resource)] pub struct BodySourceResolverResource(pub BodySourceResolver); #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU32, Ordering}; /// Create a throwaway systems.db with a `bodies(body_id, terrain_reference)` /// table and the given rows, returning its path. fn temp_db(rows: &[(&str, Option<&str>)]) -> PathBuf { static SEQ: AtomicU32 = AtomicU32::new(0); let n = SEQ.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!("sr_srcres_{}_{n}.db", std::process::id())); let _ = std::fs::remove_file(&path); let conn = Connection::open(&path).expect("create temp db"); conn.execute( "CREATE TABLE bodies (body_id TEXT PRIMARY KEY, terrain_reference TEXT)", [], ) .expect("create table"); for (id, tref) in rows { conn.execute( "INSERT INTO bodies (body_id, terrain_reference) VALUES (?1, ?2)", rusqlite::params![id, tref], ) .expect("insert"); } path } /// Unique temp dir to act as a search root. fn temp_root(tag: &str) -> PathBuf { static SEQ: AtomicU32 = AtomicU32::new(0); let n = SEQ.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir().join(format!("sr_root_{tag}_{}_{n}", std::process::id())); std::fs::create_dir_all(&dir).expect("mkdir root"); dir } fn touch(root: &Path, rel: &str) { let p = root.join(rel); std::fs::create_dir_all(p.parent().unwrap()).expect("mkdir"); std::fs::write(&p, b"x").expect("write file"); } const REL: &str = "wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png"; #[test] fn resolves_in_base_root() { let db = temp_db(&[("GJ1c", Some(REL))]); let base = temp_root("base"); touch(&base, REL); let r = BodySourceResolver::open(&db, vec![base.clone()]).unwrap(); assert_eq!(r.resolve("GJ1c").unwrap(), base.join(REL)); } #[test] fn mod_root_overrides_base() { let db = temp_db(&[("GJ1c", Some(REL))]); let base = temp_root("base"); let mods = temp_root("mod"); touch(&base, REL); touch(&mods, REL); // mod root first → wins. let r = BodySourceResolver::open(&db, vec![mods.clone(), base]).unwrap(); assert_eq!(r.resolve("GJ1c").unwrap(), mods.join(REL)); } #[test] fn falls_through_to_base_when_not_in_mod() { let db = temp_db(&[("GJ1c", Some(REL))]); let base = temp_root("base"); let mods = temp_root("mod"); // empty touch(&base, REL); let r = BodySourceResolver::open(&db, vec![mods, base.clone()]).unwrap(); assert_eq!(r.resolve("GJ1c").unwrap(), base.join(REL)); } #[test] fn unknown_body_errs() { let db = temp_db(&[]); let r = BodySourceResolver::open(&db, vec![temp_root("base")]).unwrap(); assert!(matches!( r.resolve("nope"), Err(SourceResolveError::UnknownBody(_)) )); } #[test] fn null_terrain_reference_errs() { let db = temp_db(&[("GJ1c", None)]); let r = BodySourceResolver::open(&db, vec![temp_root("base")]).unwrap(); assert!(matches!( r.resolve("GJ1c"), Err(SourceResolveError::NoTerrainReference { .. }) )); } #[test] fn missing_source_file_errs() { let db = temp_db(&[("GJ1c", Some(REL))]); let base = temp_root("base"); // file not created let r = BodySourceResolver::open(&db, vec![base]).unwrap(); assert!(matches!( r.resolve("GJ1c"), Err(SourceResolveError::SourceMissing { .. }) )); } }