Merge remote-tracking branch 'origin/sprint-36/server'

# Conflicts:
#	server/data/systems.db
This commit is contained in:
2026-04-19 18:45:06 +02:00
46 changed files with 3452 additions and 4218 deletions
@@ -0,0 +1,176 @@
---
title: "Bincode v1 → v2 Migration — Risk Audit (Sprint 36)"
description: "Audit of bincode usage in the server crate, risk assessment, and migration recommendation for ticket #636."
type: architecture
status: final
ticket: "#636"
author: "Tyre"
created: 2026-04-19
updated: 2026-04-19
---
# Bincode Migration — Risk Audit
**Ticket:** #636 (Migrate bincode v1.x to v2.x)
**Advisory:** RUSTSEC-2025-0141 (bincode v1.3.3 unmaintained)
**Author:** Tyre
**TL;DR:** **bincode is an orphan dependency — nothing in the server crate actually calls it. Remove it outright. The "migration" is a four-line change.**
---
## 1. What the audit found
Grep-audit of the **entire repository**, not just `server/src/`:
```bash
grep -rn "use bincode\|bincode::" server/ tests/ tooling/ --include="*.rs"
# → 0 hits
```
Cargo manifest references:
```bash
grep -rn "bincode" server/ --include="*.toml" --include="*.lock"
# → server/Cargo.toml:19 bincode = "1"
# → server/Cargo.lock:329 [[package]] name = "bincode" version = "1.3.3"
# → server/Cargo.lock:1311 " bincode"," — under settled-reach-server deps
# → server/audit.toml:6 ignore = ["RUSTSEC-2025-0141"]
```
No Rust source file in **any** crate (`server/`, `tests/`, `tooling/`) contains the string `bincode`. The lockfile entry under `tooling/test-client` shows bincode transiting through `rmp-serde` or a sibling — **not** from direct use.
**Conclusion:** `bincode = "1"` in `server/Cargo.toml` was added in anticipation of save-load / Rust-Rust sync (see `docs/workshops/v01-gap-analysis/round1-tyre.md`, `docs/workshops/save-load-architecture/workshop-brief.md`) but **the implementation path chose `rmp-serde` / MessagePack instead** (see `server/src/bridge/types.rs` — tests use `rmp_serde::to_vec_named` and `rmp_serde::from_slice`, line 1052 onward).
Bincode is a dead dependency.
## 2. Recommended migration: DELETE, don't bump
### 2.1 The actual changes
**server/Cargo.toml** — remove line 19:
```diff
-bincode = "1"
```
**server/audit.toml** — remove the ignore (lines 59):
```diff
-[advisories]
-# RUSTSEC-2025-0141: bincode v1.3.3 is unmaintained.
-# Migration to bincode v2 or an alternative is tracked in ticket #636.
-# This ignore can be removed once #636 is resolved.
-ignore = ["RUSTSEC-2025-0141"]
```
(If removing the whole `[advisories]` section leaves `audit.toml` empty, either delete the file or leave the file with just a header comment — check `.config/cargo-audit/` or the Makefile for how `cargo audit` is invoked.)
**server/Cargo.lock** — regenerate by running `cargo check` in `server/`. Verify `bincode` no longer appears.
### 2.2 Verification
```bash
# 1. No source regressions:
grep -rn "bincode" server/ tests/ tooling/ --include="*.rs"
# Expected: 0 hits.
# 2. Clean build:
cargo check --workspace --all-features
# 3. Clean tests:
cargo test --workspace
# 4. Audit is green without the ignore:
cargo audit
# Expected: no RUSTSEC-2025-0141 mention.
# 5. cargo-deny (once #726 lands):
cargo deny check
```
### 2.3 Risk assessment
| Risk | Likelihood | Impact | Mitigation |
|------------------------------------------------|------------|--------|------------|
| Hidden `use bincode` I missed | Near-zero | Build break | Covered by §2.2 step 1 grep + `cargo check` |
| Proc-macro or build.rs pulling bincode | Near-zero | Build break | No `build.rs` in server crate; no proc-macro deps use it |
| Transitive need (some crate depends on it) | Zero | N/A | Transitive deps come through lockfile without a manifest entry |
| Future save-load work expects it in manifest | Low | Re-add | If save-load lands with bincode later, re-add `bincode = "2"` then — fresh v2 install, no migration |
**All four risks are trivially mitigated. Net risk: ~0.**
---
## 3. If the team decides to keep bincode — the v1 → v2 cheat sheet
Included for completeness even though §2 is the recommendation. If save-load (#553, D-085) or a future Rust↔Rust server-sync feature decides to use bincode, adopt it fresh at v2 with these signature changes:
### 3.1 The core API difference
**v1 (current, unmaintained):**
```rust
// Relies on serde Serialize/Deserialize derives.
let bytes: Vec<u8> = bincode::serialize(&value)?;
let value: MyType = bincode::deserialize(&bytes)?;
```
**v2 (stable):**
```rust
// New "Encode"/"Decode" derives, explicit config.
use bincode::{config, encode_to_vec, decode_from_slice};
let cfg = config::standard();
let bytes: Vec<u8> = encode_to_vec(&value, cfg)?;
let (value, _used): (MyType, usize) = decode_from_slice(&bytes, cfg)?;
```
**Derive change:** v2 introduced its own `#[derive(bincode::Encode, bincode::Decode)]` traits. If the type must stay serde-compatible (required for us — we use `rmp-serde` and `ron` side-by-side), use the compat shim:
```rust
use bincode::serde::{encode_to_vec, decode_from_slice};
let bytes = encode_to_vec(&value, config::standard())?;
let (value, _) = decode_from_slice::<MyType, _>(&bytes, config::standard())?;
```
This keeps `#[derive(Serialize, Deserialize)]` as the only derives on the data types — no dual-derive required. That matters because the same types cross the MessagePack boundary via `rmp-serde`.
### 3.2 Config
v2 makes encoding config explicit. `config::standard()` uses variable-int, little-endian — matches v1 default for our types (no floats in the save shape today, so endian parity is not critical). For perfectly-byte-identical output to v1, use `config::legacy()`. **Any new adoption should use `config::standard()`** — don't inherit v1 quirks.
### 3.3 Known gotchas (for future reference)
- v2 does **not** auto-handle untagged serde enums in the compat layer (pre-v2.0.1); if we adopt it and hit an untagged enum, use `bincode::serde::Compat`.
- v2's `decode_from_slice` returns the byte count consumed — v1 silently ignored trailing bytes. Useful for streaming multi-message frames; irrelevant for one-shot save files.
- The `bincode::options()` builder from v1 (`with_fixint_encoding()` etc.) is gone — replaced by `config::Configuration`.
- Binary format is **not** compatible across v1 ↔ v2. Any v1-written blob is unreadable by v2. (This is moot for us — we have none.)
### 3.4 Touch points if we were actually migrating
None. Literally no source file imports or uses it.
---
## 4. For Dudley — execution checklist
1. Delete `bincode = "1"` from `server/Cargo.toml`.
2. Delete the `RUSTSEC-2025-0141` ignore block from `server/audit.toml`.
3. `cargo check --workspace` — regenerates `Cargo.lock`.
4. `cargo test --workspace` — must pass.
5. `cargo audit` — must not print RUSTSEC-2025-0141 anymore.
6. Commit:
```
fix(deps): remove unused bincode dependency (#636)
RUSTSEC-2025-0141 no longer relevant — bincode was declared but
never imported. Drop the crate and the audit ignore. Future
save-load work that wants bincode should adopt v2 fresh.
```
**Estimated effort:** ~15 minutes including verification.
## 5. What this means for docs
One doc to update: `docs/sprints/sprint-27/server.md` line 93 mentions the audit ignore. Either leave it (it's historical notes) or strike through. Not blocking.
---
**Audit status:** Complete. Recommendation: remove bincode entirely. If the team prefers "migrate now, don't remove" (symbolic commitment to the migration path), say the word and I'll spec that instead — but it costs more with zero benefit given the usage survey.
@@ -0,0 +1,334 @@
---
title: "Bookmark Definition — Contract Spec (Sprint 36)"
description: "Struct shape, module placement, and bridge protocol for the CK3-style bookmark system. Contract between server #614 and client #618."
type: architecture
status: draft
ticket: "#614"
decision_refs: [D-115, D-117, D-118, D-128, D-146]
author: "Tyre"
created: 2026-04-19
updated: 2026-04-19
---
# Bookmark Definition — Contract Spec
**Tickets:** server #614 (implementation), client #618 (consumer)
**Decisions:** D-115 (creation = skills + bookmark), D-117 (tycoon is the v0.2 bookmark), D-118 (start = small business owner), D-128 (culture implicit in location), D-146 (tile-scale preview — not in contract)
**Scope:** Minimum viable bookmark enumeration + selection. Skills live in a sibling system (#618 territory). Culture is derived from `starting_location_id` via the #679 API — NOT a field on `BookmarkDefinition`.
---
## 1. Purpose
A bookmark is a **named starting scenario** the player chooses at character creation. It bundles:
- A display identity (title, subtitle, flavor blurb) — what the player reads.
- A starting-state seed (location, career, a small set of seed parameters) — what the simulation consumes.
The client enumerates available bookmarks on the character-creation screen and emits a selected `bookmark_id` + `starting_location_id` when the player confirms.
For v0.2 there is exactly one bookmark: `tycoon`. The system is built for one but must not hard-code one — future bookmarks (explorer, homesteader, etc.) plug in as additional static entries.
## 2. Module placement
Per server-team convention (see `server/src/settings/`, `server/src/knowledge/`), bookmarks get their own top-level module:
```
server/src/bookmark/
├── mod.rs # Plugin, registry resource, public API
└── types.rs # BookmarkDefinition, BookmarkId, wire types
```
Registered as a `BookmarkPlugin` and added to the `App` alongside `SettingsPlugin` and `KnowledgePlugin`. Exports flow through `server/src/lib.rs`:
```rust
pub mod bookmark; // new
```
Rationale: parallel to settings/knowledge — bookmarks are a first-class domain, not simulation state. A sub-module under `settings/` would be wrong (settings are player prefs; bookmarks are content).
## 3. Rust types
### 3.1 BookmarkId (stable string key)
```rust
/// Stable identifier for a bookmark definition.
///
/// String-backed (not an enum) so new bookmarks can be added without bumping
/// the protocol version. v0.2 ships exactly one: `"tycoon"`.
#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub struct BookmarkId(pub String);
impl BookmarkId {
pub const TYCOON: &'static str = "tycoon";
pub fn as_str(&self) -> &str { &self.0 }
}
```
### 3.2 BookmarkDefinition (server-internal)
```rust
/// Full static definition of a bookmark. Loaded once at startup, immutable
/// at runtime. Lives server-side; a projection (`BookmarkWire`) crosses the
/// bridge.
#[derive(Debug, Clone)]
pub struct BookmarkDefinition {
/// Stable ID (e.g. `"tycoon"`).
pub id: BookmarkId,
/// Short display title for the bookmark card (≤32 chars).
/// e.g. "Tycoon" — shown as the tab/tile header.
pub title: String,
/// One-line subtitle under the title (≤64 chars).
/// e.g. "Small business owner on the make."
pub subtitle: String,
/// Flavor blurb shown on selection. 24 sentences, Mellanie-authored.
/// Markdown NOT supported — plain text only.
pub flavor: String,
/// Default starting location the bookmark places the character in.
/// Format: `system_id` from `server/data/systems.db` (e.g. "GJ 35").
/// The client location picker (#680) MAY let the player choose another
/// location within the bookmark's allowed set; this is the default.
pub default_location: String,
/// Candidate starting locations the player can pick from for this
/// bookmark (#680). Includes `default_location`. Empty = default only.
/// For v0.2 tycoon, this is the Van Maanen's Star system entry.
pub allowed_locations: Vec<String>,
/// Career seed — determines initial skills weighting, inventory, and
/// starting business. Enum so downstream systems (skill seeder,
/// apartment generator, monologue pool selector) can pattern-match.
pub career: CareerKind,
/// Starting capital in Tractus (D-118 small-business scale — not mogul).
pub starting_capital_tractus: i64,
/// Visible in the character-creation screen. Use `false` to author
/// work-in-progress bookmarks without exposing them to the client.
pub available: bool,
}
/// Career seed. v0.2: `Tycoon` only; extensible.
/// Used server-side to route into career-specific initialization
/// (monologue pool, apartment generator seed, starting inventory).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CareerKind {
/// Tycoon career — small business owner, D-117/D-118.
Tycoon,
}
```
### 3.3 BookmarkRegistry (Bevy resource)
```rust
/// Immutable at runtime: built once during `BookmarkPlugin::build`.
/// BTreeMap for deterministic iteration (D-010 principle 4, D-041).
#[derive(Resource, Debug, Default)]
pub struct BookmarkRegistry {
entries: BTreeMap<String, BookmarkDefinition>,
}
impl BookmarkRegistry {
pub fn get(&self, id: &str) -> Option<&BookmarkDefinition> { ... }
pub fn available(&self) -> impl Iterator<Item = &BookmarkDefinition> { ... }
pub fn contains(&self, id: &str) -> bool { ... }
}
```
## 4. Wire protocol (client-facing)
### 4.1 `BookmarkWire` — the projection that crosses the bridge
Drop server-only fields (none today, but keep the two types separate so future additions — e.g. a `validation` closure — don't leak through serde). Locates in `server/src/bridge/types.rs` next to the other `*Wire` structs.
```rust
/// Bookmark projection for the client. Sent as a catalog in
/// `ObserverSnapshot.bookmark_catalog` immediately after handshake
/// (and re-sent once if the client re-requests via PlayerAction).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BookmarkWire {
pub id: String,
pub title: String,
pub subtitle: String,
pub flavor: String,
pub default_location: String,
pub allowed_locations: Vec<String>,
pub career: CareerKindWire, // mirror of CareerKind, #[serde(rename_all = "snake_case")]
pub starting_capital_tractus: i64,
}
```
### 4.2 Delivery — where bookmarks show up on the wire
**Option A (chosen): piggyback on `ObserverSnapshot`.** Add a new field:
```rust
// In ObserverSnapshot (server/src/bridge/types.rs)
//
// v22 adds: bookmark_catalog (#614, D-115/D-117).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bookmark_catalog: Option<BookmarkCatalog>,
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BookmarkCatalog {
pub bookmarks: Vec<BookmarkWire>,
}
```
**Semantics:**
- Populated for **exactly one tick** after the handshake completes, and for **exactly one tick** after a `PlayerAction::RequestBookmarkCatalog` arrives. `None` otherwise.
- Same pattern as `settings_response` (#627) and `economy_snapshot` (#822) — one-shot catalog responses live in their own `Option<...>` field, not in the main snapshot every tick.
- Bumps `PROTOCOL_VERSION`. **Coordination note:** #848 (conversation-system retirement) also lands a wire-format change this sprint. Whichever PR lands first takes the next version number (22); the second rebases and takes the one after. Update the protocol comment in `bridge/types.rs` with the correct ticket ref when you land.
**Rejected:** a separate out-of-band message type. The bridge already framed MessagePack as `ObserverSnapshot`-shaped (`HandshakeMessage` + `StartupMessage` are the only exceptions and both exist for lifecycle reasons). A third top-level message type would need handling in `local.rs` + `tcp.rs` + test harness without buying anything over a snapshot field.
### 4.3 New PlayerAction variants
```rust
// In PlayerAction
/// Client requests the full bookmark catalog (#614).
/// Server responds with `ObserverSnapshot.bookmark_catalog` in the next tick.
RequestBookmarkCatalog,
/// Player confirms character creation with a chosen bookmark (#614, #618).
/// `bookmark_id` must match a `BookmarkId` the server emitted in
/// BookmarkCatalog. `starting_location_id` must be in
/// `BookmarkDefinition.allowed_locations` for that bookmark.
///
/// On invalid `bookmark_id` or `starting_location_id`: server pushes a
/// `SimError { kind: ProtocolError, ... }` — client should treat as a
/// fatal character-creation error (can't start the game).
ConfirmBookmark {
bookmark_id: String,
starting_location_id: String,
},
```
Note: `ConfirmBookmark` is the **trigger** for transitioning from the character-creation screen into the live world. The downstream chain (apartment generation, starting knowledge seed, culture resolution via #679) fires on this action. Details of that chain are out of scope for this spec — #614 just delivers the action into the input queue and records the selection on a new resource.
### 4.4 Server-side selection state
```rust
/// The confirmed bookmark selection for the current session.
/// Populated when `ConfirmBookmark` is processed. `None` during the
/// character-creation phase (before confirm) and always `None` in a
/// fresh session.
///
/// **v0.2 scope: transient only.** Not serialized — save/load of
/// `SelectedBookmark` is deferred to Sprint 37 (follow-up ticket
/// filed alongside #614). Add `Serialize`/`Deserialize` derives and
/// wire into `SaveState` when that ticket is claimed.
#[derive(Resource, Debug, Clone, Default)]
pub struct SelectedBookmark {
pub bookmark_id: Option<String>,
pub starting_location_id: Option<String>,
}
```
Downstream systems (apartment generator, skill seeder) read from this
resource.
**Save/load scope (v0.2 deferred):** `SelectedBookmark` is transient for
v0.2 — it lives in-memory from `ConfirmBookmark` through session end and
is not persisted. A reload after quit returns the player to the
character-creation screen. Promotion to persistent state (adding
`Serialize`/`Deserialize` and threading into `SaveState` / #553) is
tracked in a follow-up ticket for Sprint 37. `SelectedBookmark` must
carry an inline `// TODO(sprint-37): serialize — see #<follow-up ticket>`
comment in `server/src/bookmark/mod.rs` pointing at the follow-up so the
omission is greppable.
## 5. Content source — how bookmarks get into the registry
v0.2 scope: **hard-coded in `server/src/bookmark/mod.rs`**. A single entry:
```rust
fn register_default_bookmarks(registry: &mut BookmarkRegistry) {
registry.insert(BookmarkDefinition {
id: BookmarkId("tycoon".to_string()),
title: "Tycoon".into(),
subtitle: "Small business owner on the make.".into(),
flavor: "<Mellanie to author — 24 sentences>".into(),
default_location: "GJ 35".into(), // TBD — confirm with Miri
allowed_locations: vec!["GJ 35".into()],
career: CareerKind::Tycoon,
starting_capital_tractus: 5_000,
available: true,
});
}
```
**Why not TOML/YAML from disk?**
- One bookmark. File IO adds failure modes (missing file, parse errors) with no upside.
- When the second bookmark lands (Sprint 38+?), promote to `content/bookmarks/*.toml` — 1 day of work, pattern already established by `content/brands/`.
**Flavor text:** `flavor` is `<Mellanie to author>` on first pass. Ping her when #614 lands so she can fill it in before #618 renders it.
**Default location:** Van Maanen's Star's `system_id`. Worth double-checking with Miri that it's `GJ 35` vs another entry in `server/data/systems.db`. Tagged as TBD in the code until confirmed.
## 6. Handshake-time flow
```
client server
| |
|-- TCP/stdio connect ---------------->|
| |
|<----------- HandshakeMessage --------|
| |
|-- StartupMessage (seed, archetype)-->|
| |
| [server initializes BookmarkRegistry]
| |
|<-- ObserverSnapshot(tick=0) ---------| <-- bookmark_catalog: Some(...)
| | (and nothing else interesting;
| | no entities, no player)
| |
| [client renders character creation screen]
| |
|-- PlayerAction::ConfirmBookmark ---->|
| |
| [server reads SelectedBookmark,
| spawns player entity, runs
| apartment generator, etc.]
| |
|<-- ObserverSnapshot(tick=1...) ------| <-- normal gameplay begins
```
The client MAY send `PlayerAction::RequestBookmarkCatalog` explicitly (e.g. if it missed tick 0) — server re-sends the same catalog.
## 7. What this spec does NOT cover
- **Skills.** D-115 includes skills in character creation, but skill selection is a parallel system; the bookmark just seeds the initial weighting via `career`. See #618 and a separate spec (not yet written).
- **Culture resolution.** Culture is NOT on `BookmarkDefinition`. The client calls the `resolve_culture(location_id)` function from the #679 contract. See `sprint-36-culture-api-spec.md`.
- **Apartment/starting-state generation.** Downstream of `ConfirmBookmark`. Fires when `SelectedBookmark` is populated. Out of scope for this ticket.
- **Bookmark preview asset.** D-146 (tile-scale preview) is rendered client-side from the player's own character descriptor, not a server asset on `BookmarkDefinition`.
- **Save-format integration.** `SelectedBookmark` is a resource; save/load (#553, D-085) already serializes resources — adding two fields is a line-item for the save-state author.
## 8. Implementation plan (for Dudley)
**Size:** ~1 day. Straightforward Bevy plugin + bridge types + one hard-coded entry.
| Step | File | Work |
|------|------|------|
| 1 | `server/src/bookmark/mod.rs` (new) | `BookmarkPlugin`, `BookmarkRegistry` resource, `SelectedBookmark` resource, `register_default_bookmarks` fn |
| 2 | `server/src/bookmark/types.rs` (new) | `BookmarkId`, `BookmarkDefinition`, `CareerKind` |
| 3 | `server/src/lib.rs` | `pub mod bookmark;` |
| 4 | `server/src/bridge/types.rs` | `BookmarkWire`, `CareerKindWire`, `BookmarkCatalog`, `PROTOCOL_VERSION` bump to the next available number (coordinate with #848 — see §4.2), `bookmark_catalog` field on `ObserverSnapshot`, `RequestBookmarkCatalog` + `ConfirmBookmark` `PlayerAction` variants |
| 5 | `server/src/main.rs` (or wherever `App` is built) | `App.add_plugins(BookmarkPlugin)` |
| 6 | `server/src/perception/observer.rs` (`compute_observer_snapshot`) | Drain a `PendingBookmarkCatalog` flag — populate `bookmark_catalog` for one tick after handshake OR after `RequestBookmarkCatalog` |
| 7 | `server/src/simulation/input.rs` (or wherever PlayerAction is dispatched) | Handle `RequestBookmarkCatalog` (set the flag) and `ConfirmBookmark` (validate against registry, write `SelectedBookmark`, push `SimError` on invalid) |
| 8 | Unit tests in `server/src/bookmark/mod.rs` | Registry construction, round-trip serialization of `BookmarkWire`, `ConfirmBookmark` validation |
| 9 | Update `server/src/bridge/types.rs` module doc comment | "v22 adds: bookmark_catalog (#614, D-115/D-117)" |
## 9. Open questions
1. **Default `starting_location_id` for tycoon** — is it `"GJ 35"`, `"GJ 144"`, or a station-level ID? Need Miri's call. Flag as TODO in code; doesn't block implementation.
2. **Flavor text** — Mellanie to write once #614 lands. Temporarily use a placeholder; client handles empty strings gracefully.
3. **Later bookmarks** — when a second bookmark is planned, promote to TOML. Not a v0.2 concern.
---
**Contract status:** Ready for #614 implementation. Client #618 can start against this spec once the server ticket opens a PR with the bridge types landed (any Sprint 36 mid-point is fine).
@@ -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.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.