feat(simulation): mod-first BodySourceResolver (#969, D-225)
First piece of the layer-stream proxy. Resolves body_id -> source heightmap.png read-only from systems.db (terrain_reference, mirroring CultureResolver's pattern), searching roots mod-first over the base install. Explicit errors (UnknownBody / NoTerrainReference / SourceMissing). v1 callers pass the base root only; the search-order logic is proven with synthetic mod roots so the mod-first seam is ready (Q-099 covers registering a mod's new bodies). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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<Mutex<Connection>>,
|
||||
/// 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<PathBuf>,
|
||||
}
|
||||
|
||||
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<PathBuf>) -> Result<Self, SourceResolveError> {
|
||||
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<String, SourceResolveError> {
|
||||
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<Option<Option<String>>> = 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<PathBuf, SourceResolveError> {
|
||||
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<BodySourceResolverResource>` 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 { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user