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>
This commit is contained in:
@@ -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<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.
|
||||||
@@ -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<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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -8,12 +8,14 @@ use bevy_app::prelude::*;
|
|||||||
use bevy_ecs::prelude::*;
|
use bevy_ecs::prelude::*;
|
||||||
|
|
||||||
pub mod content_registry;
|
pub mod content_registry;
|
||||||
|
pub mod culture;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod graph;
|
pub mod graph;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use content_registry::ContentEntityRegistry;
|
pub use content_registry::ContentEntityRegistry;
|
||||||
|
pub use culture::{CultureError, CultureResolver, CultureResolverResource, CultureTag, resolve_culture};
|
||||||
pub use events::{
|
pub use events::{
|
||||||
ContradictionDetectedEvent, ContradictionDetectedQueue, InteractionType, KnowledgeEvent,
|
ContradictionDetectedEvent, ContradictionDetectedQueue, InteractionType, KnowledgeEvent,
|
||||||
KnowledgeEventQueue, KnowledgeEventType, ProcessedEntityGrant, ProcessedFactGrant,
|
KnowledgeEventQueue, KnowledgeEventType, ProcessedEntityGrant, ProcessedFactGrant,
|
||||||
|
|||||||
Reference in New Issue
Block a user