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:
2026-04-19 13:14:05 +02:00
co-authored by Claude Sonnet 4.6
parent c19d84f2e3
commit 972703bf0f
4 changed files with 575 additions and 0 deletions
+280
View File
@@ -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.
+2
View File
@@ -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,