From 972703bf0febe3e9ff43cef3d7aa5a06581509f2 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 19 Apr 2026 13:14:05 +0200 Subject: [PATCH] feat(simulation): location-to-culture resolution system (#679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CultureResolver with Arc> 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 --- .../sprint-36-culture-api-spec.md | 293 ++++++++++++++++++ server/src/knowledge/culture.rs | 280 +++++++++++++++++ server/src/knowledge/fixtures/culture_test.db | Bin 0 -> 28672 bytes server/src/knowledge/mod.rs | 2 + 4 files changed, 575 insertions(+) create mode 100644 docs/architecture/sprint-36-culture-api-spec.md create mode 100644 server/src/knowledge/culture.rs create mode 100644 server/src/knowledge/fixtures/culture_test.db diff --git a/docs/architecture/sprint-36-culture-api-spec.md b/docs/architecture/sprint-36-culture-api-spec.md new file mode 100644 index 000000000..7ba8019fc --- /dev/null +++ b/docs/architecture/sprint-36-culture-api-spec.md @@ -0,0 +1,293 @@ +--- +title: "Location → Culture Resolution — Contract Spec (Sprint 36)" +description: "Function signature, module placement, and error semantics for the culture resolver. Contract between server #679 and client #680." +type: architecture +status: draft +ticket: "#679" +decision_refs: [D-010, D-041, D-121, D-128] +author: "Tyre" +created: 2026-04-19 +updated: 2026-04-19 +--- + +# Location → Culture Resolution — Contract Spec + +**Tickets:** server #679 (implementation), client #680 (consumer), downstream #621 NPC personality, #681 apartment generator +**Decisions:** D-128 (culture implicit in starting location), D-121 (voice is culture-driven), D-010 (info boundaries), D-041 (BTreeMap mandate) +**Scope:** A pure read-only lookup function: `location_id → culture_tag`. No mutation, no IPC, no generation. This is the **ground truth** that downstream pipelines (voice, NPC blueprint, apartment generator, visual grammar) will pull from. + +--- + +## 1. Purpose + +D-128 established that culture is implicit in the starting bookmark location — Van Maanen's Star start = Van Maanen's Star culture. To keep that decision load-bearing rather than aspirational, the server needs one canonical function every downstream consumer calls. Without that single function, each consumer re-implements lookup against `systems.db`, drifts apart, and D-128 becomes a handshake instead of a contract. + +This spec is that function. + +## 2. Module placement + +``` +server/src/knowledge/culture.rs (new) +``` + +**Rationale:** +- Culture is a **world-knowledge** property (what the world is), not a simulation tick system (what the world is doing). It belongs under `knowledge/` alongside the knowledge graph — both are *what is true about the world*, read-only from most callers. +- NOT `server/src/settings/culture.rs`: `settings/` is player-config storage; putting world data there confuses the domain. +- NOT a new top-level `server/src/culture/`: the resolver is ~150 LOC, and a dedicated top-level module is heavier than it deserves. If the culture system grows (rules engine, inheritance, overrides for named cities), promote to top-level later — cheap refactor. + +**Wiring:** +- Expose at `crate::knowledge::culture::{CultureTag, CultureError, resolve_culture}`. +- Re-export from `server/src/knowledge/mod.rs` for ergonomics: + ```rust + pub mod culture; + pub use culture::{CultureTag, CultureError, resolve_culture}; + ``` + +## 3. Public API + +### 3.1 Types + +```rust +/// Canonical culture identifier. +/// +/// String-backed (NOT an enum) — 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: passed as plain `String` over IPC (mirrors system_id handling). +#[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 } +} + +/// Error from culture resolution. See `resolve_culture`. +#[derive(Debug, thiserror::Error)] +pub enum CultureError { + /// `location_id` does not resolve to any known row in systems.db. + /// Either a typo / stale bookmark, or the DB is out of sync with code. + #[error("unknown location: `{0}`")] + UnknownLocation(String), + + /// The location matched a row but its culture column is NULL. + /// This is a **data bug** — every inhabited row should have a culture. + /// Callers should log loudly; see §5 on fallback policy. + #[error("no culture assigned to location `{0}` in systems.db")] + NoCulture(String), + + /// Underlying SQLite error. Wraps `rusqlite::Error` to avoid leaking + /// the rusqlite type to crates that don't depend on it. + #[error("database error: {0}")] + Db(String), +} +``` + +### 3.2 Function signature + +```rust +/// Resolve a location identifier to the culture that location implies (D-128). +/// +/// # Input +/// `location_id` — a location string. Accepted forms: +/// 1. `system_id` — e.g. `"GJ 35"`. Matched against `star_systems.system_id`. +/// 2. `body_id` — e.g. `"GJ 35-2"`. Matched against `bodies.body_id`. +/// If the body has `cultural_corridor` set, that wins; otherwise the +/// parent system's `cultural_corridor` is used. +/// 3. `station_id` — e.g. `"sova-transit"`. Matched against +/// `stations.station_id`. Falls through to parent system. +/// +/// The resolver tries each table in order and returns the first match. +/// For v0.2 (bookmark selection), callers will pass `system_id` — but the +/// function is body/station-aware from day one so downstream systems +/// (apartment generator, NPC spawn) don't need a second lookup. +/// +/// # Output +/// `Ok(CultureTag)` — the canonical culture for the location. +/// +/// # Errors +/// - `CultureError::UnknownLocation` — `location_id` not in any table. +/// - `CultureError::NoCulture` — row found but culture column NULL. +/// - `CultureError::Db` — SQLite I/O failure. +/// +/// # Determinism +/// Pure function of `(location_id, snapshot of systems.db)`. No RNG, no tick +/// state. `systems.db` is shipped read-only with the game (see schema +/// comment), so repeated calls always return the same value. +/// +/// # Performance +/// Caller provides the `&CultureResolver` (caches connection + prepared +/// statements). A single resolution is a single indexed lookup — +/// sub-microsecond. Safe to call per-tick if needed, though for bookmark +/// selection this is a one-shot. +pub fn resolve_culture( + resolver: &CultureResolver, + location_id: &str, +) -> Result; +``` + +### 3.3 `CultureResolver` (the handle) + +```rust +/// Handle that owns the DB connection + prepared statements. +/// Constructed once at startup, cheap to clone-reference across callers. +/// Internally uses `Mutex` (mirrors `SettingsStoreResource`). +pub struct CultureResolver { /* private */ } + +impl CultureResolver { + /// Open the resolver against `server/data/systems.db` (default) or a + /// test fixture. Read-only — opens with `SQLITE_OPEN_READ_ONLY`. + pub fn open(path: &Path) -> Result; +} + +/// Bevy Resource wrapper so systems can grab a `Res`. +#[derive(Resource)] +pub struct CultureResolverResource(pub CultureResolver); +``` + +**Why not a top-level free function reading `systems.db` on every call?** +A connection-per-call serializes SQLite open latency (few ms × N callers) and forces error handling at every call site. Single owned handle = one place to configure, one place to fail-fast at startup. + +## 4. Lookup algorithm (implementation sketch) + +```rust +fn resolve_culture(resolver: &CultureResolver, loc: &str) + -> Result +{ + let conn = resolver.0.lock().map_err(|e| CultureError::Db(e.to_string()))?; + + // 1. Try as system_id. + if let Some(c) = query_system_culture(&conn, loc)? { + return Ok(CultureTag(c)); + } + + // 2. Try as body_id — body override OR parent system. + if let Some(c) = query_body_culture(&conn, loc)? { + return Ok(CultureTag(c)); + } + + // 3. Try as station_id — currently always falls through to parent system. + if let Some(c) = query_station_culture(&conn, loc)? { + return Ok(CultureTag(c)); + } + + Err(CultureError::UnknownLocation(loc.to_string())) +} +``` + +Each helper distinguishes *row not found* (return `Ok(None)`, fall through) from *row found but NULL culture* (return `Err(CultureError::NoCulture)` — this is a data bug, not a miss). + +SQL: + +```sql +-- query_system_culture +SELECT cultural_corridor FROM star_systems WHERE system_id = ?; + +-- query_body_culture +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 = ?; + +-- query_station_culture +SELECT s.cultural_corridor +FROM stations st +JOIN star_systems s ON s.system_id = st.system_id +WHERE st.station_id = ?; +``` + +(If `bodies` or `stations` don't expose `cultural_corridor` at schema level by the time #679 lands, start with system-only and add body/station passes in a follow-up. The function signature is stable either way — it already takes an opaque `location_id`.) + +## 5. Error handling for callers + +| Scenario | Server response | Client expectation | +|----------|----------------|--------------------| +| `UnknownLocation` during bookmark flow | Push `SimError::ProtocolError` via `SimErrorBuffer` and reject `ConfirmBookmark` | Character creation error — disallow confirm, re-enable picker | +| `NoCulture` during bookmark flow | Same as above, plus `tracing::error!` — this is a DB bug | Same (user-facing) — but file a bug; should not happen | +| `Db` during bookmark flow | Server shuts down (same as any fatal storage failure) | Session terminates | + +**No silent fallback.** D-128 is load-bearing: if we fall back to a default culture on unknown location, we erase the signal and every downstream pipeline gets corrupted input. Loud error > quiet wrong answer. + +**Special case — test worlds:** The Gauntlet and other test maps use synthetic location IDs (e.g. `"gauntlet:room-7"`) that aren't in `systems.db`. The resolver's caller handles this: at test-world init we insert a `SelectedBookmark { starting_location_id: "gauntlet:hub" }` and route those through a hard-coded `"core"` culture assignment before the resolver is consulted. The resolver itself stays pure. + +## 6. Determinism and thread safety + +- `rusqlite::Connection` is `!Sync` — wrapped in `Mutex` exactly like `SettingsStoreResource`. +- All queries use indexed primary keys — deterministic per-input. +- Pure function of `(location_id, systems.db contents)`. `systems.db` is shipped read-only with the game build, so the mapping is pinned at release time. +- Safe to call concurrently from multiple Bevy systems; the mutex serializes at sub-microsecond cost. + +Satisfies D-010 principle 4 (deterministic simulation). + +## 7. Test plan (#679 acceptance) + +Unit tests in `server/src/knowledge/culture.rs`: + +1. `resolves_known_system` — opens a fixture DB, resolves `"GJ 35"` to `"south_reach"`. +2. `resolves_known_body` — body override wins over system default. +3. `resolves_station_to_parent_system` — station falls through to its parent's corridor. +4. `unknown_location_returns_err` — unknown string returns `UnknownLocation`. +5. `null_culture_returns_err` — fixture with NULL `cultural_corridor` → `NoCulture`. +6. `concurrent_reads_are_safe` — spawn two threads, each resolving 1000 times; results match. + +Fixture DB at `server/src/knowledge/fixtures/culture_test.db` — seeded in a build.rs or committed as a tiny blob. Opt for committed fixture — zero-effort for CI. + +## 8. Consumers (for coordination) + +| Consumer | Ticket | How it calls | +|----------|--------|--------------| +| Character creation (client) | #680 | Via IPC — see §9 | +| Apartment generator | #681 | Direct `resolve_culture()` when `SelectedBookmark` populated | +| NPC generator | (deferred, was #621) | Direct — passes culture into NpcBlueprint | +| Voice pipeline / Gemma | already integrated via `server/src/voice/` | Reads culture from NPC blueprint (no direct call) | +| Cultural visual grammar | (sprint 38+) | Direct — reads from `SelectedBookmark` → resolver | + +## 9. Client exposure — how #680 sees culture + +The client does NOT call `resolve_culture()` — it calls it indirectly via the bookmark flow: + +**Option A (simplest):** server sends resolved culture as a field on the bookmark catalog per allowed_location. + +```rust +// On BookmarkCatalog (see sprint-36-bookmark-spec.md §4.1) +pub struct BookmarkWire { + // ... existing fields ... + /// Parallel to `allowed_locations`: same index → same location. + /// Pre-resolved on the server. Saves the client a round trip. + pub allowed_locations_cultures: Vec, +} +``` + +Rationale: cultures are effectively static data shipped with `systems.db`. Resolving them server-side once and shipping the catalog saves: +- A second IPC round-trip (client picks a location, server tells it the culture). +- Error handling duplication (client would need a "culture lookup failed" path). + +**Trade-off:** catalog payload grows ~one short string per allowed location. For v0.2's single allowed location, the overhead is 8 bytes. Acceptable. + +**Option B (rejected):** dedicated `PlayerAction::ResolveCulture(location_id)` → `ObserverSnapshot.culture_response`. Works fine mechanically; just unnecessary given how static culture data is. + +Stig (client) should plan on reading `allowed_locations_cultures[i]` when the player highlights the `i`-th entry in the picker, then display the culture label inline (e.g. "Van Maanen's Star — south_reach culture"). + +## 10. Implementation plan (for Dudley) + +**Size:** ~0.5–1 day. Pure DB wrapper + tests. + +| Step | File | Work | +|------|------|------| +| 1 | `server/src/knowledge/culture.rs` (new) | Types + `CultureResolver` + `resolve_culture` | +| 2 | `server/src/knowledge/mod.rs` | `pub mod culture;` + re-exports | +| 3 | `server/src/main.rs` (App build) | Open `CultureResolverResource` against `server/data/systems.db` | +| 4 | `server/src/knowledge/fixtures/culture_test.db` | Tiny fixture for unit tests | +| 5 | Tests (§7) | 6 unit tests | +| 6 | Update bookmark catalog (§9 Option A) | Populate `allowed_locations_cultures` by resolving each allowed location at catalog-build time | + +## 11. Open questions + +1. **Scope of `location_id` at the bookmark boundary.** Is it `system_id` ("GJ 35") or a proper name ("Van Maanen's Star")? Convention so far is `system_id`. Confirm with Miri when she signs off on the tycoon default location in the bookmark spec. +2. **`bodies.cultural_corridor` column availability.** Schema has it (`server/data/systems-schema.sql` line 175). But does actual content populate it anywhere that differs from the parent system? If no, the body-level lookup still works — it just always falls back to the parent. Harmless. + +--- + +**Contract status:** Ready for #679 implementation. Client #680 can start against the bookmark catalog once #614 + #679 land the server-side resolution. diff --git a/server/src/knowledge/culture.rs b/server/src/knowledge/culture.rs new file mode 100644 index 000000000..f38cadbf5 --- /dev/null +++ b/server/src/knowledge/culture.rs @@ -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` mirrors `SettingsStoreResource` — rusqlite `Connection` +/// is `!Sync`. All queries are indexed primary-key lookups: sub-microsecond. +pub struct CultureResolver { + conn: Arc>, +} + +impl CultureResolver { + /// Open a resolver. `SQLITE_OPEN_READ_ONLY` — purely query-side. + pub fn open(path: &Path) -> Result { + 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` 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 { + 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, CultureError> { + let result: rusqlite::Result>> = 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, CultureError> { + let result: rusqlite::Result>> = 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, CultureError> { + let result: rusqlite::Result>> = 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"); + } + } +} diff --git a/server/src/knowledge/fixtures/culture_test.db b/server/src/knowledge/fixtures/culture_test.db new file mode 100644 index 0000000000000000000000000000000000000000..b706a688b3a21cc891925d0480a504e3670de553 GIT binary patch literal 28672 zcmeI((QDH{90%~bT$XN??xx!avlLE7!J$#wZb%=*)lo!Or_K)CQ^?l11+oT{)NKzw z^mTuWe}dqjAo%37?8W)wi|C_w$x=F`AihZDdo;PIU>f z00Izz00bZa0SG_<0uX?}-wQ+&IkQxv{d(-|_-+(Cu@`istTxs*n~io;v>W$ZO_BAA z>v0a<2^lO4*>{300Izz z00bZa0SG_<0uX?J5}2c`Y(~Lr$BILz8+mc^4uIPKr~Dg{9~1~c00Izz00bZa0SG_< T0uX=z1pX5N&dO86ivYg>+$jGN literal 0 HcmV?d00001 diff --git a/server/src/knowledge/mod.rs b/server/src/knowledge/mod.rs index 6bd8a80fb..c6aa7a4fd 100644 --- a/server/src/knowledge/mod.rs +++ b/server/src/knowledge/mod.rs @@ -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,