diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index f971695dd..291912473 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -15,6 +15,7 @@ pub mod heightmap; pub mod layer1; pub mod plugin; pub mod skeleton_gen; +pub mod source_resolver; pub mod subbiome; pub mod tile_condition; diff --git a/server/src/atlas/source_resolver.rs b/server/src/atlas/source_resolver.rs new file mode 100644 index 000000000..111adf56b --- /dev/null +++ b/server/src/atlas/source_resolver.rs @@ -0,0 +1,209 @@ +//! 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 { .. }) + )); + } +}