feat(simulation): location-to-culture resolution system (#679)
CultureResolver with Arc<Mutex<Connection>> over systems.db (SQLITE_OPEN_READ_ONLY). 3-pass lookup: system_id → body_id (COALESCE parent fallback) → station_id. CultureResolverResource registered in main.rs with graceful warn-on-missing. BookmarkRegistry.build_catalog() uses resolver for allowed_locations_cultures. 8 unit tests including concurrent safety. SQLite fixture at server/src/knowledge/fixtures/culture_test.db. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
//! 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<Connection>` mirrors `SettingsStoreResource` — rusqlite `Connection`
|
||||
/// is `!Sync`. All queries are indexed primary-key lookups: sub-microsecond.
|
||||
pub struct CultureResolver {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl CultureResolver {
|
||||
/// Open a resolver. `SQLITE_OPEN_READ_ONLY` — purely query-side.
|
||||
pub fn open(path: &Path) -> Result<Self, CultureError> {
|
||||
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<CultureResolverResource>` 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<CultureTag, CultureError> {
|
||||
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<Option<String>, CultureError> {
|
||||
let result: rusqlite::Result<Option<Option<String>>> = 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<Option<String>, CultureError> {
|
||||
let result: rusqlite::Result<Option<Option<String>>> = 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<Option<String>, CultureError> {
|
||||
let result: rusqlite::Result<Option<Option<String>>> = 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -8,12 +8,14 @@ use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
pub mod content_registry;
|
||||
pub mod culture;
|
||||
pub mod events;
|
||||
pub mod graph;
|
||||
pub mod registry;
|
||||
pub mod types;
|
||||
|
||||
pub use content_registry::ContentEntityRegistry;
|
||||
pub use culture::{CultureError, CultureResolver, CultureResolverResource, CultureTag, resolve_culture};
|
||||
pub use events::{
|
||||
ContradictionDetectedEvent, ContradictionDetectedQueue, InteractionType, KnowledgeEvent,
|
||||
KnowledgeEventQueue, KnowledgeEventType, ProcessedEntityGrant, ProcessedFactGrant,
|
||||
|
||||
Reference in New Issue
Block a user