Files
settled-reach/docs/architecture/sprint-36-culture-api-spec.md
T
jpmschweitzerandClaude Sonnet 4.6 972703bf0f 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>
2026-04-19 13:14:05 +02:00

294 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<CultureTag, CultureError>;
```
### 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<Connection>` (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<Self, CultureError>;
}
/// Bevy Resource wrapper so systems can grab a `Res<CultureResolverResource>`.
#[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<CultureTag, CultureError>
{
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<String>,
}
```
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.