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

14 KiB
Raw Blame History

title, description, type, status, ticket, decision_refs, author, created, updated
title description type status ticket decision_refs author created updated
Location → Culture Resolution — Contract Spec (Sprint 36) Function signature, module placement, and error semantics for the culture resolver. Contract between server #679 and client #680. architecture draft #679
D-010
D-041
D-121
D-128
Tyre 2026-04-19 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:
    pub mod culture;
    pub use culture::{CultureTag, CultureError, resolve_culture};
    

3. Public API

3.1 Types

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

/// 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)

/// 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)

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:

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

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