Per R-012: delete conversation.rs, both overheard content files, and remove all 6 wire-up points (social_plugin, bridge/types, monologue, voice/integration). Protocol version 22 → 23. Scope confirmed by #842 audit — npc/ and content/global/ untouched. Surviving NPC components (NpcName, NpcColorIndex, NpcConversation) migrated to simulation/npc_components.rs for use by D-080 knowledge propagation. Also applies pre-existing cargo fmt debt (names.rs and 4 others). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
284 lines
9.4 KiB
Rust
284 lines
9.4 KiB
Rust
//! 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");
|
|
}
|
|
}
|
|
}
|