--- 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.