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:
2026-05-24 11:36:18 +02:00
co-authored by Claude Opus 4.7
parent bd5355d0a5
commit 0097fd5027
2 changed files with 210 additions and 0 deletions
+1
View File
@@ -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;
+209
View File
@@ -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 { .. })
));
}
}