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
+4
View File
@@ -70,6 +70,10 @@ Thumbs.db
*.swp
*.swo
# Generated economics pipeline artifacts (re-created by make economy-db)
wiki/economics/corporations/generated_brands.toml
wiki/economics/corporations/generated_corporations.toml
# Claude Code internals (plans, session transcripts)
# Note: .claude/agents/, .claude/skills/, and .claude/settings.json ARE tracked
.claude/plans/
+9 -3
View File
@@ -3,7 +3,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
.PHONY: help setup build check-protocol client server game stop test lint lint-python setup-venv ci ci-client ci-server clean \
decisions-sync decisions-coverage decisions-active decisions-orphan \
db-backup db-install validate-content check-fact-ids setup-hooks \
audit atlas-verify economy-db atlas-generate \
audit deny atlas-verify economy-db atlas-generate \
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client fixtures-gauntlet golden-diff golden-update \
@@ -50,6 +50,7 @@ help:
@echo " make decisions-active List active decisions"
@echo " make decisions-orphan Decisions without implementing tickets"
@echo " make audit Run cargo audit (security advisory check)"
@echo " make deny Run cargo deny check (license/ban policy)"
@echo " make validate-content Validate content YAML against schemas"
@echo " make check-fact-ids Check fact_id references against knowledge catalogs"
@echo " make atlas-verify Verify atlas proposal JSONs (all in docs/atlas/proposals/)"
@@ -249,7 +250,7 @@ lint-client:
# --- Pre-PR verification ---
pre-pr: pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures audit
pre-pr: pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures audit deny
@echo ""
@echo "=== PRE-PR: ALL CHECKS PASSED ==="
@echo "Safe to create PR."
@@ -302,7 +303,7 @@ pre-pr-fixtures:
# Branch-specific variants (faster, scope-appropriate)
pre-pr-server: lint-server build-server test-server pre-pr-fixtures audit
pre-pr-server: lint-server build-server test-server pre-pr-fixtures audit deny
@echo "=== Server pre-PR: PASSED ==="
pre-pr-client: lint-client build-client test-client check-star-map
@@ -328,6 +329,8 @@ db-install:
@tooling/db-install
economy-db: ## Import economics data (commodities, chains, gate links) into systems.db
@echo " Generating minor brands (D-189 #829)..."
@tooling/generate-brands
@python3 tooling/economy-db/import_economics.py
atlas-generate: ## Generate atlas markers (cities, roads, rail) for all inhabited bodies (#832)
@@ -383,6 +386,9 @@ atlas-verify:
audit:
cd server && cargo audit
deny:
cd server && cargo deny check
checklist-validate:
@tooling/validate-checklist --check
+4 -3
View File
@@ -283,9 +283,9 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
### D-061: Dialogue box — unified conversation log, bottom screen, max 20% height, no portraits
- **Date:** 2026-02-13
- **Decision:** Dialogue occupies the bottom of the screen, max 20% height, max-width 1200px ([D-076](#d-076-dialogue-box-max-width--1200px-oq-29-resolution)). NO portraits — the NPC is on screen, a portrait is redundant. Monologue floats ABOVE the dialogue box on z-layer 7 — spatial separation allows monologue to contradict dialogue visually (character thinks one thing while NPC says another). Walk-away via WASD, dialogue fades over 300ms, no close button ([D-064](content.md#d-064-walk-away--three-phase-consequences)). Auto-pause in single-player when implant UI is open; overlay design for multiplayer readiness.
- **Unified conversation log (Sprint 14, #535):** The dialogue box is a single chronological log — not separate panels for active vs passive dialogue. Player-NPC conversations and overheard NPC-NPC conversations ([D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter)) flow into the same scrolling log. Each entry shows `Speaker → Target: text` with per-character name colours (hash-indexed from configurable palette in `data/dialogue-theme.yaml`). Player response options render below the log; max 3 visible. Locked options invisible ([D-062](content.md#d-062-invisible-locked-dialogue-options)).
- **Unified conversation log (Sprint 14, #535):** The dialogue box is a single chronological log for player-NPC conversations. Each entry shows `Speaker → Target: text` with per-character name colours (hash-indexed from configurable palette in `data/dialogue-theme.yaml`). Player response options render below the log; max 3 visible. Locked options invisible ([D-062](content.md#d-062-invisible-locked-dialogue-options)).
- **Entry lifecycle:** All entries share the same timeout (15s + 3s fade, configurable via theme YAML). Walk-away clears response options but preserves log entries — earned information is fair game. Panel auto-hides when all entries expire and no active conversation is in progress.
- **Passive overheard lines:** Rendered at reduced opacity (0.9) per [D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter). No response options for overheard content. Walk-away does not fire for passive-only display — the panel dismisses naturally when entries expire or the player walks out of earshot.
- **Passive overheard lines:** D-078 (overheard NPC conversations) was scrapped per R-012. Passive dialogue display will be redesigned after Phase 5 walkable environment.
- **Rationale:** Game world stays live above the dialogue box — player sees NPC body language while talking. Monologue above + dialogue below = the character can think one thing while saying another. Max 3 options + invisible locks = player never knows what they're missing. No portrait because the NPC IS on screen. A single unified log avoids a separate UI element for overheard content and makes the flow of conversation feel natural — active and passive dialogue interleave chronologically.
- **Cross-reference:** Invisible locks ([D-062](content.md#d-062-invisible-locked-dialogue-options)), confrontation ([D-063](content.md#d-063-confrontation--same-box-different-weight)), walk-away ([D-064](content.md#d-064-walk-away--three-phase-consequences)), z-stack ([D-049](#d-049-z-level-rendering-stack-8-layers)), max-width ([D-076](#d-076-dialogue-box-max-width--1200px-oq-29-resolution)), overheard NPC conversation ([D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter))
- **Source:** Control & Interaction Workshop (2026-02-13). Amended Sprint 14 (#535): unified log architecture.
@@ -424,7 +424,8 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
- **Raised by:** Workshop — unanimous
- **Dissent:** None
- **Implements:** Ticket #548
- **Cross-reference:** [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-083](#d-083-contradiction-detection-pipeline), [D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter)
- **Cross-reference:** [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-083](#d-083-contradiction-detection-pipeline), [D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter) (D-078 scrapped per R-012; `transfer_npc_knowledge` retained for Phase 5 rewire — see Amendment 2026-04-19)
- **Amendment (2026-04-19, R-012 / #848):** D-078 was scrapped (R-012) and the `run_npc_conversations` system was deleted in #848. The `transfer_npc_knowledge` system is **retained in-tree for Phase 5 rewire** but no longer fires in production — its `Added<NpcConversation>` trigger is now only inserted by test fixtures. The design (trust-gated transfer, dual-mutable KG access, `KnowsOf` confidence cap, `disclosure_blocked` honoring) is preserved; Phase 5 will wire a new proximity/dialogue trigger in its place. Until then, treat the system as dormant and guard against assuming it runs.
### D-081: Unprompted Disclosure Design
- **Date:** 2026-02-24
@@ -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.
+1 -11
View File
@@ -325,15 +325,6 @@ dependencies = [
"thread_local",
]
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "2.10.0"
@@ -1303,12 +1294,11 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.34"
version = "0.1.35"
dependencies = [
"bevy_app",
"bevy_ecs",
"bevy_tasks",
"bincode",
"clap",
"crossbeam-channel",
"econ-sim",
+1 -1
View File
@@ -2,6 +2,7 @@
name = "settled-reach-server"
version = "0.1.35"
edition = "2021"
publish = false
[dependencies]
bevy_ecs = "0.18"
@@ -16,7 +17,6 @@ serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
ron = "0.8"
rmp-serde = "1"
bincode = "1"
rand = "0.9"
rand_chacha = "0.9"
pathfinding = "4.11"
-6
View File
@@ -1,9 +1,3 @@
# cargo audit configuration for settled-reach-server.
# Known advisories that are tracked but not yet resolved are listed here.
# New advisories NOT in this list will fail CI.
[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"]
File diff suppressed because it is too large Load Diff
@@ -1,855 +0,0 @@
# DEPRECATED — archived by ticket #664 (copy team, Sprint 27)
#
# This file is NOT served at runtime. It is preserved for authoring reference only.
#
# WHY DEPRECATED: Every entry in this file references named NPCs from the Sova Transit
# District vertical slice (Kael Davan, Sera Venn, Naia Tamm, Voss, Torek, Renn, Maret,
# Lera, Nils, Drin, Olin, etc.). Per D-122 (all NPCs generated, no hand-authored
# characters), all named character references in this file are invalid against the
# generated NPC population. The drama module system (ticket #158) that these entries
# were authored for was superseded before implementation.
#
# WHAT REPLACED IT: content/npc-conversations/overheard.ron
# Generator-compatible RON pool. Parameterized by role pair and zone type.
# No named NPC references. Culture-neutral text accepting culture modifier at assembly.
#
# WHAT IS STRUCTURALLY WORTH PRESERVING (applied in overheard.ron):
# - Overheard conversations as ambient information layer (D-078)
# - Register taxonomy: social / work / gossip
# - Occlusion-resilient authoring rules (D-078): front-load key info, short
# declarative sentences, no pronoun-first openers, each turn self-contained
# - knowledge_payload pattern for investigative inference
#
# Original file header below:
# NPC-to-NPC Overheard Dialogue Pool
# Ticket: #536 | Author: Mellanie | Sprint: 14
# Decision refs: D-078, D-018, D-071, D-035
#
# LEGACY CONTENT — v0.1 hand-authored pool. DO NOT DELETE.
# All entries in this file reference named NPCs from the Sova Transit District
# vertical slice (Kael Davan, Sera Venn, Naia Tamm, Voss, Nils, Maret Korr, etc.).
# These named NPCs were authored for the drama module system (ticket #158), which
# was superseded before implementation. The named references in this file are
# therefore broken — they reference characters that do not exist in the generated
# NPC population.
# Ticket #664 tracks replacement of this file with a zone-type-aware overheard
# generator that produces conversations from role pairs rather than named characters.
# Until #664 ships, this file is not served at runtime.
#
# These are conversations the player can overhear when stationary near two
# NPCs. The passive dialogue panel (D-078) displays them with per-word
# occlusion based on distance, ambient noise, and ListeningFocus stance.
#
# OCCLUSION-RESILIENT AUTHORING RULES (D-078):
# 1. Front-load key information — most important word in first third.
# 2. Short declarative sentences — one idea per turn.
# 3. No pronoun-first openers — first word has highest drop risk; a dropped
# pronoun without antecedent is unresolvable. Use names or nouns.
# 4. Each turn is self-contained — a player who hears only one side gets
# a complete thought.
#
# SCHEMA NOTE:
# `relationship_type` and `knowledge_payload` are custom extensions to the
# D-035 schema for this content type. NPC-sourced lines use `role: npc`,
# `access` and `trust` apply normally. Monologue-specific tags (`character`,
# `trigger`, `prerequisite`) are not used here.
# Schema confirmation with Gestalt (#168) before CI validation.
#
# REGISTERS:
# social — idle chat, personal news, relationship talk
# work — shift logistics, job gripes, operational notes
# gossip — third-party information with player-relevant knowledge payload
#
# RELATIONSHIP TYPES:
# colleague, friend, hostile, romantic
#
# KNOWLEDGE PAYLOAD FORMAT:
# Plain English description of what the player can infer from hearing this
# exchange clearly — or the key fragment they retain under partial occlusion.
# null if the exchange is social noise with no investigative value.
pairs:
# ===================================================================
# SOCIAL register — personal news, idle chat, relationships
# ===================================================================
- id: overheard_001
register: social
speaker_a:
role: dock-worker
text: "Kael brought food for the whole bay yesterday. Just showed up with it."
speaker_b:
role: dock-worker
text: "Naia must have made him feel guilty about something. Again."
relationship_type: colleague
topic: personal-gossip
location_hints: [the-terminal, the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael and Naia have a relationship dynamic. Naia has leverage or emotional pull over Kael's behavior."
tags: [kael, naia, social, atmosphere]
- id: overheard_002
register: social
speaker_a:
role: bar-regular
text: "Lera's keeping the kitchen open late this week. Span gate delay — crews stuck here."
speaker_b:
role: bar-regular
text: "Good for Lera. Bad for everyone waiting on the gate."
relationship_type: friend
topic: station-life
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [lera, span-gate, atmosphere, social]
- id: overheard_003
register: social
speaker_a:
role: dock-worker
text: "Drin's applying for the transfer. Again. Third time he's tried."
speaker_b:
role: dock-worker
text: "Drin's never getting off Sova. Some people just belong to a place."
relationship_type: colleague
topic: career-gossip
location_hints: [the-terminal, the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Drin wants out of Sova Transit. Unhappy enough to pursue transfer requests."
tags: [drin, transfer, social, atmosphere]
- id: overheard_004
register: social
speaker_a:
role: bar-regular
text: "Naia and Kael had a fight. Loud enough that Lera had to step in."
speaker_b:
role: bar-regular
text: "Kael looked rough this morning. That tracks."
relationship_type: friend
topic: relationship-drama
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael and Naia are under relationship strain. Kael's mood is affected. Cross-reference: behavioral.kael_behavioral_change."
tags: [kael, naia, relationship, friend-arc-adjacent, dual-lens]
- id: overheard_005
register: social
speaker_a:
role: dock-worker
text: "Renn's finally getting his lattice service done. Been putting it off for years."
speaker_b:
role: dock-worker
text: "Commission clinic wait time is eight months. Renn found someone faster."
relationship_type: colleague
topic: lattice-access
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Renn found a non-Commission lattice service provider. Points toward unlicensed lattice components market."
tags: [renn, lattice, contraband-adjacent, atmosphere]
- id: overheard_006
register: social
speaker_a:
role: bar-regular
text: "Maret's promotion came through. Third level supervisor."
speaker_b:
role: bar-regular
text: "Good. Maret actually knows what she's doing. Unlike some."
relationship_type: colleague
topic: career-news
location_hints: [the-last-shift, the-terminal]
access: [public]
trust: surface
knowledge_payload: null
tags: [maret, promotion, atmosphere, social]
- id: overheard_007
register: social
speaker_a:
role: dock-worker
text: "Voss has been in a mood all week. Something from upstairs."
speaker_b:
role: dock-worker
text: "Voss is always in a mood. Week ends in -day, Voss is in a mood."
relationship_type: colleague
topic: supervisor-gossip
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Voss is under external pressure from 'upstairs.' Something is stressing management."
tags: [voss, management, atmosphere, social]
- id: overheard_008
register: social
speaker_a:
role: bar-regular
text: "Sera's been coming here every evening this week. Thought Commission people didn't drink."
speaker_b:
role: bar-regular
text: "Sera's different. She fits here better than she fits over there."
relationship_type: colleague
topic: social-observation
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Sera Venn is a regular at The Last Shift, unusual for a Commission employee. She has social roots in the district."
tags: [sera, commission, atmosphere, social, dual-lens]
- id: overheard_009
register: social
speaker_a:
role: dock-worker
text: "Nils covered Kael's morning slot yesterday. No explanation, just a roster note."
speaker_b:
role: dock-worker
text: "Kael's been doing that a lot lately. Taking sick leave, then showing up mid-shift."
relationship_type: colleague
topic: roster-anomaly
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Kael's schedule is irregular. Unexplained absences and late arrivals. Cross-reference: observe_anomaly triggers for Kael."
tags: [kael, nils, roster, friend-arc-adjacent]
- id: overheard_010
register: social
speaker_a:
role: maintenance-tech
text: "Olin's kid got into the Commission cadet program. Starts next cycle."
speaker_b:
role: maintenance-tech
text: "Olin must be pleased. Cost of living here, Commission pay makes sense."
relationship_type: colleague
topic: family-news
location_hints: [maintenance-corridors, the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [olin, commission, atmosphere, social]
# ===================================================================
# WORK register — shift logistics, operational gripes, job talk
# ===================================================================
- id: overheard_011
register: work
speaker_a:
role: dock-worker
text: "Maret shifted the roster again. Bay four to bay seven, no reason given."
speaker_b:
role: dock-worker
text: "Bay seven's the low-traffic slot. Somebody wanted less oversight over there."
relationship_type: colleague
topic: roster-change
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Bay seven has been given a low-oversight crew deliberately. This is operationally significant."
tags: [maret, roster, bay-seven, investigation-adjacent]
- id: overheard_012
register: work
speaker_a:
role: dock-worker
text: "Loading arm three is grinding again. Filed the report last week."
speaker_b:
role: dock-worker
text: "Maintenance says next cycle. Means nothing gets done until someone loses a hand."
relationship_type: colleague
topic: equipment-maintenance
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: null
tags: [equipment, maintenance, atmosphere, work]
- id: overheard_013
register: work
speaker_a:
role: dock-worker
text: "Container 4471's been in temp for three days. Someone should move it."
speaker_b:
role: dock-worker
text: "Routing says hold. Don't ask me, I just process what the board says."
relationship_type: colleague
topic: container-routing
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Container 4471 is being deliberately held in temp storage. 'Routing says hold' — someone modified the routing instruction."
tags: [container-4471, routing, contraband-adjacent, investigation-payload]
- id: overheard_014
register: work
speaker_a:
role: dock-worker
text: "Voss cut the B-section crew by two. Says it's budget. Doesn't feel like budget."
speaker_b:
role: dock-worker
text: "Less crew in B-section means less oversight. Could be budget. Could be something else."
relationship_type: colleague
topic: crew-reduction
location_hints: [the-terminal, maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "B-section is understaffed. The speaker suspects this is deliberate. Cross-reference: ring oversight windows."
tags: [voss, b-section, oversight, investigation-payload]
- id: overheard_015
register: work
speaker_a:
role: dock-worker
text: "Span gate's backed up again. Forty-minute delay on the freight queue."
speaker_b:
role: dock-worker
text: "Forty minutes is nothing. Last month it was three hours. Patience."
relationship_type: colleague
topic: span-gate-delay
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: null
tags: [span-gate, freight, atmosphere, work]
- id: overheard_016
register: work
speaker_a:
role: maintenance-tech
text: "Junction C-2's camera was repositioned. Nobody filed a maintenance request."
speaker_b:
role: maintenance-tech
text: "Someone moved it without logging it. That's a compliance violation."
relationship_type: colleague
topic: camera-anomaly
location_hints: [maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "Camera at junction C-2 was moved without documentation. Suggests deliberate surveillance manipulation. Cross-reference: awareness.surveillance_change."
tags: [camera, surveillance, c-2, investigation-payload]
- id: overheard_017
register: work
speaker_a:
role: dock-worker
text: "Commission wants another inspection. Fourth one this quarter."
speaker_b:
role: dock-worker
text: "Inspections mean overtime. Inspections mean everyone's watching everyone."
relationship_type: colleague
topic: commission-inspection
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Commission is conducting elevated inspections of the terminal. Something has drawn their attention."
tags: [commission, inspection, atmosphere, investigation-adjacent]
- id: overheard_018
register: work
speaker_a:
role: dock-worker
text: "Weight manifest on the morning freight came in five hundred kilos short. Recalibration error."
speaker_b:
role: dock-worker
text: "Five hundred kilos doesn't disappear from a calibration error. It disappears from somewhere else."
relationship_type: colleague
topic: weight-discrepancy
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Manifest weight discrepancy exists and dock workers are suspicious of the official explanation."
tags: [manifest, weight-discrepancy, contraband-adjacent, investigation-payload]
- id: overheard_019
register: work
speaker_a:
role: dock-worker
text: "Shift handover's going to be rough tonight. Torek's team hasn't filed."
speaker_b:
role: dock-worker
text: "Torek's team never files on time. Let Maret handle it."
relationship_type: colleague
topic: shift-handover
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: null
tags: [torek, maret, shift, atmosphere, work]
- id: overheard_020
register: work
speaker_a:
role: maintenance-tech
text: "Sub-level access has been requested twice this week. Both times outside shift hours."
speaker_b:
role: maintenance-tech
text: "Scheduled maintenance happens in-shift. Off-hours access needs sign-off."
relationship_type: colleague
topic: sub-level-access
location_hints: [maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "Someone has been accessing sub-level spaces outside normal hours. No sign-off implies unauthorized use. Cross-reference: smuggling-hold activity."
tags: [sub-level, access, maintenance, investigation-payload]
- id: overheard_021
register: work
speaker_a:
role: dock-worker
text: "Bay four's been sealed for inspection since yesterday morning."
speaker_b:
role: dock-worker
text: "Commission authorized it. Not Voss. Commission went over his head."
relationship_type: colleague
topic: bay-inspection
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Bay four inspection bypassed Voss's authority. Commission is operating independently of local management."
tags: [bay-four, commission, voss, inspection, investigation-payload]
- id: overheard_022
register: work
speaker_a:
role: dock-worker
text: "Kael's running Renn's route today. Renn's supposed to be on that."
speaker_b:
role: dock-worker
text: "Kael volunteered. Said Renn had something come up."
relationship_type: colleague
topic: route-substitution
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Kael is voluntarily taking routes outside his assignment. Could be covering for Renn, could be operational flexibility needed by the ring."
tags: [kael, renn, route, ring-adjacent]
- id: overheard_023
register: work
speaker_a:
role: bar-regular
text: "Lera's dealing with the supply chain issue again. Grain spirit supplier changed terms."
speaker_b:
role: bar-regular
text: "Lera will figure it out. Lera always figures it out."
relationship_type: friend
topic: supply-chain
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [lera, supply, atmosphere, work]
- id: overheard_024
register: work
speaker_a:
role: maintenance-tech
text: "Condensation in the B-corridor's gotten worse. Someone's running heat-generation equipment down there."
speaker_b:
role: maintenance-tech
text: "Heat-gen in a maintenance corridor. That's either a storage issue or a very bad idea."
relationship_type: colleague
topic: corridor-anomaly
location_hints: [maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "Heat-generating equipment is being used in maintenance corridors unofficially. Points toward the smuggling hold or ring operations."
tags: [b-corridor, heat, maintenance, smuggling-adjacent]
- id: overheard_025
register: work
speaker_a:
role: dock-worker
text: "Torek's been doing double manifests for a month. Every container logged twice."
speaker_b:
role: dock-worker
text: "Double logging means one version goes somewhere it shouldn't."
relationship_type: colleague
topic: manifest-anomaly
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Torek is maintaining duplicate manifest records. One version is falsified. Direct evidence of ring operation at the administrative level."
tags: [torek, manifest, contraband, investigation-payload, high-value]
# ===================================================================
# GOSSIP register — third-party knowledge with investigative payload
# ===================================================================
- id: overheard_026
register: gossip
speaker_a:
role: dock-worker
text: "Kael was in corridor B-7 last night. Saw him myself. Off shift, wrong time, wrong place."
speaker_b:
role: dock-worker
text: "Kael lives near B-section. Could have been heading home the long way."
relationship_type: colleague
topic: kael-location
location_hints: [the-terminal, the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael was in corridor B-7 off-shift. Eyewitness account."
tags: [kael, corridor-b7, investigation-payload, friend-arc, high-value]
- id: overheard_027
register: gossip
speaker_a:
role: bar-regular
text: "Kael was talking to someone near B-7 last night. Person I didn't recognize."
speaker_b:
role: bar-regular
text: "Unknown people at junction B-7 at night. That's not normal."
relationship_type: friend
topic: kael-unknown-contact
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael met with an unidentified person at corridor B-7 during off-hours."
tags: [kael, unknown-contact, corridor-b7, friend-arc, high-value, investigation-payload]
- id: overheard_028
register: gossip
speaker_a:
role: bar-regular
text: "Torek spent three thousand credits at Lera's last week. Three thousand. On a dock worker's salary."
speaker_b:
role: bar-regular
text: "Torek's either very lucky or very stupid. Either way, someone's going to notice."
relationship_type: friend
topic: torek-spending
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Torek is spending significantly above his salary at The Last Shift."
tags: [torek, spending, lera, investigation-payload, ring-adjacent]
- id: overheard_029
register: gossip
speaker_a:
role: dock-worker
text: "Renn got new boots. Commission-grade. Renn can't afford Commission-grade boots."
speaker_b:
role: dock-worker
text: "Renn's been running extra shifts. Or extra something."
relationship_type: colleague
topic: renn-spending
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Renn has unexplained extra income. Cross-reference: ring membership payments."
tags: [renn, income, ring-adjacent, investigation-adjacent]
- id: overheard_030
register: gossip
speaker_a:
role: bar-regular
text: "Sera Venn avoids Torek every time he's here. Every single time. Noticed it three weeks straight."
speaker_b:
role: bar-regular
text: "Torek does that to people. He talks too much and says things he shouldn't."
relationship_type: colleague
topic: sera-torek-avoidance
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Sera Venn has a consistent avoidance pattern toward Torek Lintar. Cross-reference: behavioral.sera_avoidance_pattern."
tags: [sera, torek, avoidance, friend-arc, investigation-payload, high-value]
- id: overheard_031
register: gossip
speaker_a:
role: bar-regular
text: "Commission officer's been asking questions at The Terminal. Polite questions. Thorough ones."
speaker_b:
role: bar-regular
text: "Polite and thorough is the worst combination. That's someone who has time."
relationship_type: colleague
topic: commission-investigation
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "A Commission officer is conducting a quiet investigation at The Terminal. Cross-reference: awareness.detective_presence."
tags: [commission, detective, investigation-payload, awareness]
- id: overheard_032
register: gossip
speaker_a:
role: dock-worker
text: "Lattice components went through last month. Unlicensed grade. Manifest said 'mechanical parts.'"
speaker_b:
role: dock-worker
text: "Mechanical parts. Right. People find ways when the Commission won't."
relationship_type: colleague
topic: contraband-transit
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Unlicensed lattice components are moving through The Terminal under falsified manifests. Cross-reference: contraband operation confirmed."
tags: [lattice, contraband, manifest, investigation-payload, high-value]
- id: overheard_033
register: gossip
speaker_a:
role: maintenance-tech
text: "Sub-level temp storage has been accessed four times this week. Door log shows it."
speaker_b:
role: maintenance-tech
text: "Four times and no maintenance ticket filed. Someone's using it off-book."
relationship_type: colleague
topic: unauthorized-access
location_hints: [maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "The sub-level storage (smuggling hold) has heavy unauthorized use documented in door logs. Direct evidence of ring operations."
tags: [sub-level, access-log, ring, investigation-payload, high-value]
- id: overheard_034
register: gossip
speaker_a:
role: bar-regular
text: "Voss changed the B-section rotation. Kael and Renn are both on the overnight slot now."
speaker_b:
role: bar-regular
text: "Kael and Renn on overnight in B-section. That's a very specific combination."
relationship_type: colleague
topic: roster-combination
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael and Renn have been placed on the same overnight B-section rotation. This creates the oversight gap the ring needs."
tags: [kael, renn, voss, roster, ring-adjacent, investigation-payload]
- id: overheard_035
register: gossip
speaker_a:
role: dock-worker
text: "Sera asked me about container routing last week. Wanted to know who approves temp storage extensions."
speaker_b:
role: dock-worker
text: "Commission tech asking about temp storage approvals. That's outside her job scope."
relationship_type: colleague
topic: sera-investigation
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Sera Venn is conducting her own investigation into temp storage approvals, outside her Commission mandate. Cross-reference: behavioral.sera_kiosk_pattern."
tags: [sera, storage, investigation-payload, friend-arc, dual-lens, high-value]
- id: overheard_036
register: gossip
speaker_a:
role: bar-regular
text: "Naia told me Kael hasn't been sleeping. Says he's up late, doesn't explain where."
speaker_b:
role: bar-regular
text: "Naia's worried about him. Kael won't talk about whatever it is."
relationship_type: friend
topic: kael-behavioral-change
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael's behavior has changed at home. Sleepless, secretive, won't explain to Naia. Cross-reference: behavioral.kael_behavioral_change."
tags: [kael, naia, behavior, friend-arc, investigation-payload]
- id: overheard_037
register: gossip
speaker_a:
role: dock-worker
text: "Someone flagged a manifest discrepancy on the morning freight. Voss cleared the flag himself."
speaker_b:
role: dock-worker
text: "Voss doesn't clear flags. That's Maret's job. Or Commission's."
relationship_type: colleague
topic: manifest-flag-cleared
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Voss manually cleared a manifest discrepancy flag, bypassing protocol. Suggests Voss is actively covering for ring activity."
tags: [voss, manifest, flag-cleared, investigation-payload, high-value]
- id: overheard_038
register: gossip
speaker_a:
role: bar-regular
text: "Kael's been asking about exit options. Not just talk — actually asking Lera if she knows anyone who could help someone disappear quietly."
speaker_b:
role: bar-regular
text: "Kael wants out of something. That's the only reason people ask that kind of question."
relationship_type: friend
topic: kael-exit-attempt
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Kael is actively trying to leave 'something' — most likely the ring. He's researching options."
tags: [kael, exit, ring-adjacent, friend-arc, investigation-payload, high-value]
- id: overheard_039
register: gossip
speaker_a:
role: dock-worker
text: "Medical lattice upgrades are showing up on the black-side. Commission-grade, no paperwork."
speaker_b:
role: dock-worker
text: "People who need them can't wait on Commission approval. Someone's doing a service."
relationship_type: colleague
topic: black-market-lattice
location_hints: [the-terminal, the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Medical-grade lattice components are available outside Commission channels. Confirms the moral framing of the contraband operation — it serves real medical need."
tags: [lattice, medical, black-market, contraband, moral-framing]
- id: overheard_040
register: gossip
speaker_a:
role: bar-regular
text: "Torek told someone he had a meeting last night. Past midnight. In maintenance."
speaker_b:
role: bar-regular
text: "Torek having midnight maintenance meetings. Sure. That's completely normal."
relationship_type: colleague
topic: torek-late-meeting
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Torek met with someone in the maintenance corridors late at night."
tags: [torek, maintenance, midnight, ring-adjacent, investigation-payload]
# ===================================================================
# BONUS PAIRS — social and work depth
# ===================================================================
- id: overheard_041
register: social
speaker_a:
role: bar-regular
text: "Lera's thinking about expanding. Back room could seat twenty more."
speaker_b:
role: bar-regular
text: "Back room's the only reason this place has any privacy. Expand it and we lose that."
relationship_type: friend
topic: bar-expansion
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [lera, bar, atmosphere, social]
- id: overheard_042
register: work
speaker_a:
role: dock-worker
text: "Shift change is late again. Maret says weather on Velen is delaying the span gate."
speaker_b:
role: dock-worker
text: "Weather on Velen. Which means fog. Which means the morning run is going to be rough."
relationship_type: colleague
topic: weather-delay
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: null
tags: [velen, fog, span-gate, weather, atmosphere]
- id: overheard_043
register: social
speaker_a:
role: bar-regular
text: "Olin's getting a commendation from Commission. Ten years of service."
speaker_b:
role: bar-regular
text: "Ten years. Commission gives you a piece of paper and a handshake."
relationship_type: colleague
topic: commission-recognition
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [olin, commission, atmosphere, social]
- id: overheard_044
register: work
speaker_a:
role: maintenance-tech
text: "Power fluctuation in sub-level B last night. Lasted three minutes."
speaker_b:
role: maintenance-tech
text: "Three minutes is enough to blind the cameras if you know the timing."
relationship_type: colleague
topic: power-fluctuation
location_hints: [maintenance-corridors]
access: [public]
trust: surface
knowledge_payload: "A power fluctuation in sub-level B temporarily disabled cameras. Could be timed to enable unobserved access."
tags: [power, cameras, sub-level, timing, investigation-adjacent]
- id: overheard_045
register: gossip
speaker_a:
role: dock-worker
text: "Commission officer asked Drin about cargo manifest irregularities. Drin told him everything he knows, which isn't much."
speaker_b:
role: dock-worker
text: "Drin doesn't know much by design. That's why Drin's on inspection detail."
relationship_type: colleague
topic: commission-inquiry
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "The Commission officer is asking dock workers directly about manifest irregularities. Drin has been interviewed. Cross-reference: awareness.detective_presence."
tags: [drin, commission, detective, interview, investigation-payload]
- id: overheard_046
register: gossip
speaker_a:
role: bar-regular
text: "Sera's been keeping a list. Someone told Naia. Private list, personal data."
speaker_b:
role: bar-regular
text: "Sera keeping a list of what? And why would Naia know?"
relationship_type: friend
topic: sera-documentation
location_hints: [the-last-shift]
access: [public]
trust: surface
knowledge_payload: "Sera is documenting something privately — possibly her own investigation or evidence she's sitting on."
tags: [sera, naia, list, evidence, friend-arc, investigation-payload, dual-lens]
- id: overheard_047
register: social
speaker_a:
role: dock-worker
text: "Sova's been home for twenty years. Still don't know if I love it or just got used to it."
speaker_b:
role: dock-worker
text: "Twenty years is the same thing."
relationship_type: friend
topic: station-life
location_hints: [the-terminal, the-last-shift]
access: [public]
trust: surface
knowledge_payload: null
tags: [sova, atmosphere, personal, social]
- id: overheard_048
register: work
speaker_a:
role: dock-worker
text: "Voss approved overtime for two extra heads on the B-section night shift. Unusual."
speaker_b:
role: dock-worker
text: "Overtime plus extra bodies in B at night. Either something's wrong or something's being made to look right."
relationship_type: colleague
topic: overtime-approval
location_hints: [the-terminal]
access: [public]
trust: surface
knowledge_payload: "Voss approved extra overnight staffing in B-section — possibly to create cover or provide legitimate-looking explanation for movement in that area."
tags: [voss, overtime, b-section, night-shift, investigation-adjacent]
Binary file not shown.
+41
View File
@@ -0,0 +1,41 @@
# cargo deny configuration for settled-reach-server.
# Enforces license allowlist, RUSTSEC advisory checks, and duplicate detection.
# Run: cargo deny check (from server/)
# econ-sim is an internal path dependency with no license declaration.
# Excluding it skips only econ-sim itself — its dependencies are still checked
# because they are shared with settled-reach-server's own dep tree.
[graph]
exclude = ["econ-sim"]
[advisories]
version = 2
[licenses]
version = 2
allow = [
"MIT",
"Apache-2.0",
# Needed for wasip2/wit-bindgen (WASM support crates via bevy_tasks).
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unlicense",
"Zlib",
"CC0-1.0",
# Required by unicode-ident: "(MIT OR Apache-2.0) AND Unicode-3.0".
"Unicode-3.0",
# Required by ryu: "Apache-2.0 OR BSL-1.0".
"BSL-1.0",
]
# settled-reach-server has publish = false and carries no license declaration.
[licenses.private]
ignore = true
[bans]
multiple-versions = "warn"
[sources]
unknown-registry = "warn"
unknown-git = "warn"
+801
View File
@@ -0,0 +1,801 @@
//! Generate ~10,000 minor brand products for the Settled Reach economy.
//!
//! Reads brand archetype templates from `wiki/economics/archetypes/brand_templates.toml`,
//! queries `systems.db` for the 48 hand-authored Tier-1/2 corporations, then generates
//! minor brand products (halo + volume tier pairs) assigned to those corporations.
//!
//! Output: `wiki/economics/corporations/generated_brands.toml`
//! Format: [[brand_products]] and [[brand_inputs]] TOML arrays compatible with
//! `import_economics.py`'s `import_brands()` function.
//!
//! Each (corp, template) eligible pair produces 2 brand_product rows:
//! - halo tier — `{corp_id}-{archetype}-{scale}-halo`
//! - volume tier — `{corp_id}-{archetype}-{scale}-vol`
//!
//! Decision references: D-189 (brand layer architecture), D-190 (volume calibration)
//!
//! # Usage
//! ```sh
//! cargo run --bin generate_brands
//! cargo run --bin generate_brands -- --seed 42 --min-brands 10000
//! cargo run --bin generate_brands -- --db server/data/systems.db --output wiki/economics/corporations/generated_brands.toml
//! ```
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::process;
use clap::Parser;
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
mod names;
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
#[derive(Parser)]
#[command(
name = "generate_brands",
about = "Generate minor brand products (halo+volume pairs) from archetype templates"
)]
struct Cli {
/// Path to systems.db
#[arg(long)]
db: Option<PathBuf>,
/// Path to brand_templates.toml
#[arg(long)]
templates: Option<PathBuf>,
/// Output TOML path
#[arg(
long,
default_value = "wiki/economics/corporations/generated_brands.toml"
)]
output: PathBuf,
/// PRNG seed for deterministic generation.
/// Seed 1 is canonical — the committed output in db/ was produced at seed=1.
/// Use a different seed only for experimentation; committed output must stay seed=1.
#[arg(long, default_value = "1")]
seed: u64,
/// Minimum brand_product rows to generate (approximate target)
#[arg(long, default_value = "10000")]
min_brands: usize,
}
// ---------------------------------------------------------------------------
// Brand template deserialization
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct CommodityInputTemplate {
commodity_id: String,
quantity_min: f64,
quantity_max: f64,
}
#[derive(Debug, Deserialize)]
struct BrandTemplate {
archetype_group: String,
brand_category: String,
scale_tier: String, // local | regional | reach_wide
naming_pattern: String,
commodity_inputs: Vec<CommodityInputTemplate>,
premium_range: [f64; 2],
scarcity_class: String,
value_trajectory: String,
#[serde(default)]
terroir_locked: bool,
}
// ---------------------------------------------------------------------------
// DB types
// ---------------------------------------------------------------------------
struct Corp {
corp_id: String,
scope: String,
headquarters_system: Option<String>,
geographic_sector: Option<String>,
currency_zone: Option<String>,
shadow_economy_access: bool,
}
// ---------------------------------------------------------------------------
// Output types
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
struct BrandProduct {
brand_product_id: String,
corp_id: String,
product_name: String,
brand_category: String,
value_trajectory: String,
scarcity_class: String,
#[serde(skip_serializing_if = "Option::is_none")]
product_subcategory: Option<String>,
base_premium_multiplier: f64,
premium_floor: f64,
#[serde(skip_serializing_if = "Option::is_none")]
origin_system: Option<String>,
terroir_locked: bool,
currency_denomination: String,
shadow_viable: bool,
brand_tier: String,
#[serde(skip_serializing_if = "Option::is_none")]
halo_brand_id: Option<String>,
}
#[derive(Debug, Serialize)]
struct BrandInput {
brand_product_id: String,
commodity_id: String,
quantity: f64,
}
// ---------------------------------------------------------------------------
// Path resolution
// ---------------------------------------------------------------------------
fn resolve_repo_root() -> PathBuf {
let mut dir = std::env::current_dir().expect("Cannot determine CWD");
loop {
if dir.join("server").join("data").join("systems.db").exists() {
return dir;
}
if !dir.pop() {
break;
}
}
eprintln!("error: cannot find repo root (looking for server/data/systems.db)");
process::exit(1);
}
// ---------------------------------------------------------------------------
// Template loading
// ---------------------------------------------------------------------------
fn load_templates(path: &PathBuf) -> Vec<(String, BrandTemplate)> {
let content = std::fs::read_to_string(path).unwrap_or_else(|e| {
eprintln!("error: cannot read {}: {}", path.display(), e);
process::exit(1);
});
let raw: toml::Value = toml::from_str(&content).unwrap_or_else(|e| {
eprintln!("error: cannot parse {}: {}", path.display(), e);
process::exit(1);
});
let table = raw.as_table().unwrap_or_else(|| {
eprintln!("error: brand_templates.toml is not a TOML table");
process::exit(1);
});
let mut templates = Vec::new();
for (key, value) in table {
if value.is_table() {
// Skip comment-only keys (headers are usually bare strings, not tables)
match toml::Value::try_into::<BrandTemplate>(value.clone()) {
Ok(t) => templates.push((key.clone(), t)),
Err(e) => {
eprintln!("warning: skipping template {:?}: {}", key, e);
}
}
}
}
// Sort by key for deterministic ordering
templates.sort_by(|a, b| a.0.cmp(&b.0));
templates
}
// ---------------------------------------------------------------------------
// DB queries
// ---------------------------------------------------------------------------
fn load_corps(conn: &Connection) -> Vec<Corp> {
let mut stmt = conn
.prepare(
"SELECT co.corp_id, co.scope, co.headquarters_system,
ss.geographic_sector,
ss.currency_zone,
co.shadow_economy_access
FROM corporations co
LEFT JOIN star_systems ss ON co.headquarters_system = ss.system_id
ORDER BY co.corp_id",
)
.unwrap_or_else(|e| {
eprintln!("error: failed to prepare corps query: {}", e);
process::exit(1);
});
stmt.query_map([], |row| {
Ok(Corp {
corp_id: row.get(0)?,
scope: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
headquarters_system: row.get(2)?,
geographic_sector: row.get(3)?,
currency_zone: row.get(4)?,
shadow_economy_access: row.get::<_, i64>(5).unwrap_or(0) != 0,
})
})
.unwrap_or_else(|e| {
eprintln!("error: failed to query corporations: {}", e);
process::exit(1);
})
.filter_map(|r| r.ok())
.collect()
}
fn load_valid_commodity_ids(conn: &Connection) -> BTreeSet<String> {
let mut stmt = conn
.prepare("SELECT commodity_id FROM commodities")
.unwrap_or_else(|e| {
eprintln!("error: failed to prepare commodities query: {}", e);
process::exit(1);
});
stmt.query_map([], |row| row.get::<_, String>(0))
.unwrap_or_else(|e| {
eprintln!("error: failed to query commodities: {}", e);
process::exit(1);
})
.filter_map(|r| r.ok())
.collect()
}
// ---------------------------------------------------------------------------
// Template-to-corridor parsing
// ---------------------------------------------------------------------------
/// Parse the corridor prefix from a template's naming_pattern field.
/// The pattern starts with "{corridor} — ..." or "{corridor}/{sub} — ...".
fn parse_template_corridor(naming_pattern: &str) -> &str {
if let Some(idx) = naming_pattern.find("") {
&naming_pattern[..idx]
} else if let Some(idx) = naming_pattern.find(" - ") {
&naming_pattern[..idx]
} else {
"reach_wide"
}
}
/// Determine whether a corp is eligible for a given template.
///
/// Matching rules:
/// - `reach_wide` scale templates: all corps eligible.
/// - `regional` or `local` templates with corridor `reach_wide`, `inner_corridor*`:
/// all corps eligible (the inner corridor is the Reach's trade hub).
/// - `regional` or `local` templates with specific corridor: reach-wide corps
/// (scope = "reach-wide") plus corps whose HQ geographic_sector matches.
fn corp_eligible(corp: &Corp, template_scale: &str, template_corridor: &str) -> bool {
// Reach-wide scale — no restriction
if template_scale == "reach_wide" {
return true;
}
// Neutral / inner-corridor templates — open to all
if matches!(
template_corridor,
"reach_wide" | "inner_corridor" | "inner_corridor/neutral"
) {
return true;
}
// Reach-wide corps carry everything
if corp.scope == "reach-wide" {
return true;
}
// Match sector to corridor
let sector = corp.geographic_sector.as_deref().unwrap_or("core");
match template_corridor {
"north_reach" | "north_reach/compact" => sector == "north_reach",
"south_reach" => sector == "south_reach",
"west_reach" | "west_reach/compact" => sector == "west_reach",
"east_reach" | "inner_corridor/east_reach" => sector == "east_reach" || sector == "core",
"frontier" => sector == "deep_frontier",
_ => false,
}
}
// ---------------------------------------------------------------------------
// Brand ID / naming helpers
// ---------------------------------------------------------------------------
fn scale_abbrev(scale_tier: &str) -> &'static str {
match scale_tier {
"local" => "l",
"regional" => "r",
"reach_wide" => "rw",
_ => "x",
}
}
fn to_slug(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.join("-")
}
fn archetype_slug(archetype_group: &str) -> String {
to_slug(archetype_group)
}
fn halo_id(corp_id: &str, archetype_group: &str, scale_tier: &str) -> String {
format!(
"{}-{}-{}-halo",
corp_id,
archetype_slug(archetype_group),
scale_abbrev(scale_tier)
)
}
fn volume_id(corp_id: &str, archetype_group: &str, scale_tier: &str) -> String {
format!(
"{}-{}-{}-vol",
corp_id,
archetype_slug(archetype_group),
scale_abbrev(scale_tier)
)
}
fn currency_for_corp(corp: &Corp) -> &'static str {
match corp.currency_zone.as_deref() {
Some(z) if z.contains("MARK") => "mark",
Some(z) if z.contains("SOL") => "sol_adjacent",
_ => "tractus",
}
}
// ---------------------------------------------------------------------------
// Brand generation
// ---------------------------------------------------------------------------
struct GeneratedPair {
halo: BrandProduct,
volume: BrandProduct,
inputs_halo: Vec<BrandInput>,
inputs_volume: Vec<BrandInput>,
}
fn generate_pair(
rng: &mut ChaCha8Rng,
corp: &Corp,
template_key: &str,
template: &BrandTemplate,
valid_commodities: &BTreeSet<String>,
) -> Option<GeneratedPair> {
let corridor = parse_template_corridor(&template.naming_pattern);
let halo_bid = halo_id(
&corp.corp_id,
&template.archetype_group,
&template.scale_tier,
);
let vol_bid = volume_id(
&corp.corp_id,
&template.archetype_group,
&template.scale_tier,
);
let halo_name = names::generate_halo_name(rng, corridor, &template.brand_category);
let vol_name = names::generate_volume_name(rng, corridor, &template.brand_category);
let base_premium = {
let lo = template.premium_range[0];
let hi = template.premium_range[1];
lo + rng.random::<f64>() * (hi - lo)
};
let premium_floor = base_premium * 0.45;
let origin_system = if template.terroir_locked {
corp.headquarters_system.clone()
} else {
None
};
let currency = currency_for_corp(corp).to_string();
let subcategory = Some(archetype_slug(&template.archetype_group));
let halo = BrandProduct {
brand_product_id: halo_bid.clone(),
corp_id: corp.corp_id.clone(),
product_name: halo_name,
brand_category: template.brand_category.clone(),
value_trajectory: template.value_trajectory.clone(),
scarcity_class: template.scarcity_class.clone(),
product_subcategory: subcategory.clone(),
base_premium_multiplier: round2(base_premium),
premium_floor: round2(premium_floor),
origin_system: origin_system.clone(),
terroir_locked: template.terroir_locked,
currency_denomination: currency.clone(),
shadow_viable: corp.shadow_economy_access,
brand_tier: "halo".to_string(),
halo_brand_id: None,
};
let vol_premium = base_premium * 0.35 + rng.random::<f64>() * (base_premium * 0.25);
let vol_floor = vol_premium * 0.30;
let volume = BrandProduct {
brand_product_id: vol_bid.clone(),
corp_id: corp.corp_id.clone(),
product_name: vol_name,
brand_category: template.brand_category.clone(),
value_trajectory: template.value_trajectory.clone(),
scarcity_class: downgrade_scarcity(&template.scarcity_class),
product_subcategory: subcategory,
base_premium_multiplier: round2(vol_premium),
premium_floor: round2(vol_floor),
origin_system: None,
terroir_locked: false,
currency_denomination: currency,
shadow_viable: corp.shadow_economy_access,
brand_tier: "volume".to_string(),
halo_brand_id: Some(halo_bid.clone()),
};
// Build inputs — filter to valid commodity IDs only
let inputs_halo: Vec<BrandInput> = template
.commodity_inputs
.iter()
.filter(|ci| valid_commodities.contains(&ci.commodity_id))
.map(|ci| {
let qty_range = ci.quantity_max - ci.quantity_min;
let qty = ci.quantity_min + rng.random::<f64>() * qty_range;
BrandInput {
brand_product_id: halo_bid.clone(),
commodity_id: ci.commodity_id.clone(),
quantity: round2(qty),
}
})
.collect();
// Halo brands without any valid commodity inputs fail V-B03 validation
if inputs_halo.is_empty() {
eprintln!(
"warning: template {:?} has no valid commodity inputs — skipping",
template_key
);
return None;
}
// Volume inputs: same commodities, 35× higher quantities
let vol_scale = 3.0 + rng.random::<f64>() * 2.0;
let inputs_volume: Vec<BrandInput> = inputs_halo
.iter()
.map(|i| BrandInput {
brand_product_id: vol_bid.clone(),
commodity_id: i.commodity_id.clone(),
quantity: round2(i.quantity * vol_scale),
})
.collect();
Some(GeneratedPair {
halo,
volume,
inputs_halo,
inputs_volume,
})
}
/// Volume tiers get a slightly less restrictive scarcity class.
fn downgrade_scarcity(scarcity: &str) -> String {
match scarcity {
"capped" => "constrained".to_string(),
"constrained" => "scalable".to_string(),
other => other.to_string(),
}
}
fn round2(v: f64) -> f64 {
(v * 100.0).round() / 100.0
}
// ---------------------------------------------------------------------------
// Output serialization
// ---------------------------------------------------------------------------
fn write_output(path: &PathBuf, products: &[BrandProduct], inputs: &[BrandInput]) {
let mut out = String::new();
out.push_str("# Generated Minor Brand Products — The Settled Reach\n");
out.push_str("# Auto-generated by generate_brands binary. Do not hand-edit.\n");
out.push_str("# Re-run: tooling/generate-brands\n");
out.push_str(&format!(
"# Total: {} brand_products, {} brand_inputs\n\n",
products.len(),
inputs.len()
));
for p in products {
out.push_str("[[brand_products]]\n");
out.push_str(&format!("brand_product_id = {:?}\n", p.brand_product_id));
out.push_str(&format!("corp_id = {:?}\n", p.corp_id));
out.push_str(&format!("product_name = {:?}\n", p.product_name));
out.push_str(&format!("brand_category = {:?}\n", p.brand_category));
out.push_str(&format!("value_trajectory = {:?}\n", p.value_trajectory));
out.push_str(&format!("scarcity_class = {:?}\n", p.scarcity_class));
if let Some(ref sub) = p.product_subcategory {
out.push_str(&format!("product_subcategory = {:?}\n", sub));
}
out.push_str(&format!(
"base_premium_multiplier = {:.2}\n",
p.base_premium_multiplier
));
out.push_str(&format!("premium_floor = {:.2}\n", p.premium_floor));
if let Some(ref sys) = p.origin_system {
out.push_str(&format!("origin_system = {:?}\n", sys));
}
out.push_str(&format!("terroir_locked = {}\n", p.terroir_locked));
out.push_str(&format!(
"currency_denomination = {:?}\n",
p.currency_denomination
));
out.push_str(&format!("shadow_viable = {}\n", p.shadow_viable));
out.push_str(&format!("brand_tier = {:?}\n", p.brand_tier));
if let Some(ref hid) = p.halo_brand_id {
out.push_str(&format!("halo_brand_id = {:?}\n", hid));
}
out.push('\n');
}
for i in inputs {
out.push_str("[[brand_inputs]]\n");
out.push_str(&format!("brand_product_id = {:?}\n", i.brand_product_id));
out.push_str(&format!("commodity_id = {:?}\n", i.commodity_id));
out.push_str(&format!("quantity = {:.2}\n", i.quantity));
out.push('\n');
}
std::fs::write(path, &out).unwrap_or_else(|e| {
eprintln!("error: cannot write {}: {}", path.display(), e);
process::exit(1);
});
}
// ---------------------------------------------------------------------------
// Coverage report
// ---------------------------------------------------------------------------
fn print_coverage(products: &[BrandProduct]) {
let mut by_category: BTreeMap<String, usize> = BTreeMap::new();
let mut by_tier: BTreeMap<String, usize> = BTreeMap::new();
let mut by_corp: BTreeMap<String, usize> = BTreeMap::new();
for p in products {
*by_category.entry(p.brand_category.clone()).or_insert(0) += 1;
*by_tier.entry(p.brand_tier.clone()).or_insert(0) += 1;
*by_corp.entry(p.corp_id.clone()).or_insert(0) += 1;
}
println!("\n Coverage Report:");
println!(" Total brand_products: {}", products.len());
println!(" By category:");
for (cat, n) in &by_category {
println!(" {}: {}", cat, n);
}
println!(" By brand_tier:");
for (tier, n) in &by_tier {
println!(" {}: {}", tier, n);
}
let min_per_corp = by_corp.values().min().copied().unwrap_or(0);
let max_per_corp = by_corp.values().max().copied().unwrap_or(0);
println!(" Corps covered: {}", by_corp.len());
println!(
" Brands per corp: min={} max={}",
min_per_corp, max_per_corp
);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
fn main() {
let cli = Cli::parse();
let repo_root = resolve_repo_root();
let db_path = cli
.db
.unwrap_or_else(|| repo_root.join("server").join("data").join("systems.db"));
let templates_path = cli
.templates
.unwrap_or_else(|| repo_root.join("wiki/economics/archetypes/brand_templates.toml"));
// Resolve output relative to repo root if it's a relative path
let output_path = if cli.output.is_relative() {
repo_root.join(&cli.output)
} else {
cli.output.clone()
};
println!("\n Minor Brand Generator");
println!(" DB: {}", db_path.display());
println!(" Templates: {}", templates_path.display());
println!(" Output: {}", output_path.display());
println!(" Seed: {}", cli.seed);
println!(" Target: {} brand_product rows", cli.min_brands);
println!();
// Load templates
println!(" [1/5] Loading brand archetype templates...");
let templates = load_templates(&templates_path);
println!(" {} templates loaded", templates.len());
// Open DB
let conn = Connection::open(&db_path).unwrap_or_else(|e| {
eprintln!("error: cannot open {}: {}", db_path.display(), e);
process::exit(1);
});
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
.unwrap_or_else(|e| {
eprintln!("error: failed to configure DB pragmas: {}", e);
process::exit(1);
});
// Load corps and commodity IDs
println!(" [2/5] Loading corporations and commodities...");
let corps = load_corps(&conn);
let valid_commodities = load_valid_commodity_ids(&conn);
println!(
" {} corps, {} valid commodities",
corps.len(),
valid_commodities.len()
);
// Generate brand pairs
println!(" [3/5] Generating brand pairs...");
let mut rng = ChaCha8Rng::seed_from_u64(cli.seed);
let mut products: Vec<BrandProduct> = Vec::new();
let mut inputs: Vec<BrandInput> = Vec::new();
let mut seen_ids: BTreeSet<String> = BTreeSet::new();
let mut skipped = 0usize;
for (template_key, template) in &templates {
for corp in &corps {
if !corp_eligible(
corp,
&template.scale_tier,
parse_template_corridor(&template.naming_pattern),
) {
continue;
}
let halo_bid = halo_id(
&corp.corp_id,
&template.archetype_group,
&template.scale_tier,
);
let vol_bid = volume_id(
&corp.corp_id,
&template.archetype_group,
&template.scale_tier,
);
// Skip if IDs already generated (name-collision guard)
if seen_ids.contains(&halo_bid) || seen_ids.contains(&vol_bid) {
skipped += 1;
continue;
}
if let Some(pair) =
generate_pair(&mut rng, corp, template_key, template, &valid_commodities)
{
seen_ids.insert(halo_bid);
seen_ids.insert(vol_bid);
products.push(pair.halo);
products.push(pair.volume);
inputs.extend(pair.inputs_halo);
inputs.extend(pair.inputs_volume);
}
}
}
println!(
" {} brand_products generated ({} skipped)",
products.len(),
skipped
);
// Gap-fill: if under target, add more by re-applying reach_wide templates
// with a counter suffix to avoid ID collisions.
if products.len() < cli.min_brands {
println!(" [4/5] Gap-fill to reach {} rows...", cli.min_brands);
let reach_wide_templates: Vec<&(String, BrandTemplate)> = templates
.iter()
.filter(|(_, t)| t.scale_tier == "reach_wide")
.collect();
let mut counter = 0usize;
let mut template_idx = 0usize;
while products.len() < cli.min_brands && !reach_wide_templates.is_empty() {
let (template_key, template) =
reach_wide_templates[template_idx % reach_wide_templates.len()];
let corp = &corps[counter % corps.len()];
counter += 1;
template_idx += 1;
// Use a suffixed ID to avoid duplicates
let suffix = counter;
let halo_bid = format!(
"{}-{}-rw-halo-{}",
corp.corp_id,
archetype_slug(&template.archetype_group),
suffix
);
let vol_bid = format!(
"{}-{}-rw-vol-{}",
corp.corp_id,
archetype_slug(&template.archetype_group),
suffix
);
if seen_ids.contains(&halo_bid) {
continue;
}
if let Some(pair) =
generate_pair(&mut rng, corp, template_key, template, &valid_commodities)
{
let mut halo = pair.halo;
let mut volume = pair.volume;
halo.brand_product_id = halo_bid.clone();
volume.brand_product_id = vol_bid.clone();
volume.halo_brand_id = Some(halo_bid.clone());
let mut inputs_halo = pair.inputs_halo;
let mut inputs_volume = pair.inputs_volume;
for i in &mut inputs_halo {
i.brand_product_id = halo_bid.clone();
}
for i in &mut inputs_volume {
i.brand_product_id = vol_bid.clone();
}
seen_ids.insert(halo_bid);
seen_ids.insert(vol_bid);
products.push(halo);
products.push(volume);
inputs.extend(inputs_halo);
inputs.extend(inputs_volume);
}
// Safety: avoid infinite loop if gap-fill produces no progress
if counter > cli.min_brands * 2 {
eprintln!("warning: gap-fill exhausted after {} iterations", counter);
break;
}
}
println!(" {} brand_products after gap-fill", products.len());
} else {
println!(" [4/5] Target reached — no gap-fill needed");
}
// Coverage report
print_coverage(&products);
// Write output
println!("\n [5/5] Writing output...");
write_output(&output_path, &products, &inputs);
println!(
" {} brand_products, {} brand_inputs",
products.len(),
inputs.len()
);
println!(" Output: {}", output_path.display());
println!(" Done.\n");
}
+670
View File
@@ -0,0 +1,670 @@
//! Deterministic product name generation for minor brand instances.
//!
//! Reuses the corridor surname pools from generate_corporations/names.rs
//! but combines them with category-specific product descriptors rather
//! than business suffixes. Halo and volume tiers get distinct descriptor
//! pools so the output sounds differentiated.
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
// ---------------------------------------------------------------------------
// Surname pools (same corpus as generate_corporations/names.rs)
// ---------------------------------------------------------------------------
const CORE_NAMES: &[&str] = &[
"Alvarez",
"Benoit",
"Carvalho",
"Durand",
"Eriksen",
"Fournier",
"Gao",
"Hartmann",
"Ishida",
"Johansson",
"Kirchner",
"Lemaire",
"Moreau",
"Nakamura",
"Olsson",
"Pelletier",
"Richter",
"Saito",
"Torres",
"Ueda",
"Vasquez",
"Werner",
"Xu",
"Yamada",
"Zhou",
"Andersen",
"Beaumont",
"Costa",
"Delacroix",
"Engel",
"Fujita",
"Gutierrez",
"Hayashi",
"Ibarra",
"Jensen",
"Klein",
"Laurent",
"Mercier",
"Novak",
"Ortiz",
"Park",
"Reuter",
"Suzuki",
"Takahashi",
"Ulrich",
"Valentin",
"Wagner",
"Xie",
"Yilmaz",
"Zhang",
];
const NORTH_REACH_NAMES: &[&str] = &[
"Andersson",
"Bjornsson",
"Calloway",
"Dalsgaard",
"Eklund",
"Falk",
"Grimstad",
"Hedlund",
"Ivarsson",
"Jonasson",
"Kirkpatrick",
"Lindqvist",
"MacLeod",
"Nordstrom",
"Olafsson",
"Pettersson",
"Rehn",
"Strandberg",
"Thorsen",
"Ulvskog",
"Vikstrom",
"Wahlberg",
"Aberg",
"Berglund",
"Carlsen",
"Dalgaard",
"Engstrom",
"Forsell",
"Gustafsson",
"Halvorsen",
"Ingvarsson",
"Jansson",
"Knudsen",
"Lundin",
"MacPherson",
"Nylund",
"Ostergaard",
"Palsson",
"Rasmussen",
"Sjoberg",
"Toft",
"Ulfsson",
"Vestergaard",
"Wiklund",
"Aasen",
"Brannstrom",
"Dahl",
"Eide",
"Friberg",
"Gren",
];
const SOUTH_REACH_NAMES: &[&str] = &[
"Adamski",
"Baranov",
"Chernov",
"Dubois",
"Egorov",
"Filipov",
"Gromov",
"Horvat",
"Ivanova",
"Jankovic",
"Kowalski",
"Lazarev",
"Morozov",
"Novikov",
"Ostrowski",
"Petrov",
"Reznik",
"Sokolov",
"Tkachenko",
"Uvarov",
"Volkov",
"Wojcik",
"Yakimov",
"Zheng",
"Babic",
"Chernyshev",
"Dragunov",
"Fedorov",
"Grushevsky",
"Havel",
"Ito",
"Jovanovic",
"Katsaros",
"Lebedev",
"Mazur",
"Nemec",
"Ochoa",
"Popov",
"Radic",
"Smirnov",
"Tanaka",
"Urasawa",
"Vasiliev",
"Watanabe",
"Xiang",
"Yegorov",
"Zaytsev",
"Borysko",
"Chen",
"Dimitrov",
];
const WEST_REACH_NAMES: &[&str] = &[
"Albrecht",
"Baumann",
"Christensen",
"Dietrich",
"Eisenberg",
"Fischer",
"Gruber",
"Hoffmann",
"Ingolstadt",
"Jaeger",
"Kessler",
"Lehmann",
"Mueller",
"Neumann",
"Obermann",
"Pfeiffer",
"Quandt",
"Roth",
"Schaefer",
"Thiel",
"Urban",
"Vogt",
"Weidenfeld",
"Ziegler",
"Becker",
"Claussen",
"Dorfmann",
"Eberhardt",
"Fleischer",
"Gerstner",
"Haber",
"Imhof",
"Jung",
"Kraemer",
"Linden",
"Metzger",
"Niedermann",
"Opitz",
"Preuss",
"Raabe",
"Steinbach",
"Trautmann",
"Unger",
"Vollmer",
"Winterberg",
"Zahn",
"Auerbach",
"Bruckner",
"Dahlem",
"Eckhardt",
];
const EAST_REACH_NAMES: &[&str] = &[
"Aquino",
"Bautista",
"Cruz",
"Dalisay",
"Espiritu",
"Flores",
"Garcia",
"Hernandez",
"Ilagan",
"Jeon",
"Kim",
"Lim",
"Magalang",
"Navarro",
"Ocampo",
"Park",
"Quijano",
"Reyes",
"Santos",
"Tan",
"Uy",
"Villanueva",
"Wong",
"Yoo",
"Aguilar",
"Buenaventura",
"Castillo",
"Dizon",
"Enriquez",
"Fernandez",
"Gonzales",
"Hwang",
"Ignacio",
"Jeong",
"Kwon",
"Lee",
"Marasigan",
"Nakamura",
"Oh",
"Perez",
"Ramos",
"Son",
"Tolentino",
"Umali",
"Valdez",
"Yun",
"Zamora",
"Baek",
"Choi",
"Dela Cruz",
];
const FRONTIER_NAMES: &[&str] = &[
"Adeyemi",
"Bergstrom",
"Chandra",
"Duval",
"Emeka",
"Fonseca",
"Gupta",
"Hassan",
"Ibrahim",
"Jansson",
"Kovac",
"Liu",
"Martinez",
"Nkosi",
"Okafor",
"Patel",
"Quinn",
"Rodriguez",
"Sousa",
"Thorne",
"Uddin",
"Varga",
"Wu",
"Xiong",
"Yoshida",
"Zhao",
"Abara",
"Beaumont",
"Cardenas",
"Doyle",
"Ekwueme",
"Ferreira",
"Gomes",
"Henriksen",
"Idris",
"Juma",
"Kato",
"Larsen",
"Morales",
"Ndlovu",
"Osei",
"Petrov",
"Ruiz",
"Singh",
"Tavares",
"Uchida",
"Volkov",
"Wang",
"Yang",
"Zaman",
];
fn names_for_corridor(corridor: &str) -> &'static [&'static str] {
match corridor {
"north_reach" | "north_reach/compact" => NORTH_REACH_NAMES,
"south_reach" => SOUTH_REACH_NAMES,
"west_reach" | "west_reach/compact" => WEST_REACH_NAMES,
"east_reach" | "inner_corridor/east_reach" => EAST_REACH_NAMES,
"frontier" => FRONTIER_NAMES,
_ => CORE_NAMES, // core / inner_corridor / reach_wide
}
}
// ---------------------------------------------------------------------------
// Product descriptor pools by brand_category × tier
// ---------------------------------------------------------------------------
const TERROIR_HALO: &[&str] = &[
"Reserve",
"Single",
"Estate",
"Vintage",
"Heritage",
"Grand",
"Limited",
"Prestige",
"Signature",
"Cellar",
"Select",
"Cru",
"Premier",
"Old",
"Aged",
];
const TERROIR_VOLUME: &[&str] = &[
"Standard", "Export", "Blend", "Classic", "Field", "Ordinary", "Running", "Table", "Regular",
"Common", "House", "Station", "Corridor", "Transit",
];
const HERITAGE_CRAFT_HALO: &[&str] = &[
"Heritage",
"Limited",
"Artisan",
"Master",
"Guild",
"Prestige",
"Premium",
"Classic",
"Signature",
"Original",
"Bespoke",
"Traditional",
"First",
];
const HERITAGE_CRAFT_VOLUME: &[&str] = &[
"Standard", "Classic", "Working", "Everyday", "Regular", "Plain", "Field", "Grade", "Basic",
"Workshop", "Studio", "Common",
];
const TECH_PREMIUM_HALO: &[&str] = &[
"Elite",
"Pro",
"Advanced",
"Precision",
"Superior",
"Grand",
"Signature",
"First",
"Prime",
"Expert",
"Master",
"Apex",
"Summit",
];
const TECH_PREMIUM_VOLUME: &[&str] = &[
"Standard",
"Series",
"Base",
"Classic",
"Regular",
"Field",
"Grade",
"Value",
"Essential",
"Core",
"Basic",
];
const CULTURAL_HALO: &[&str] = &[
"Archive",
"Heritage",
"Classic",
"Definitive",
"Prestige",
"Limited",
"Master",
"Collected",
"Curated",
"Canonical",
"Flagship",
"Grand",
];
const CULTURAL_VOLUME: &[&str] = &[
"Standard",
"Classic",
"Essential",
"Value",
"Regular",
"Base",
"Field",
"Running",
"Everyday",
"Popular",
];
const SERVICE_PREMIUM_HALO: &[&str] = &[
"Premier",
"Elite",
"Priority",
"Signature",
"Prestige",
"Grand",
"First",
"Select",
"Platinum",
"Gold",
"Senior",
"Executive",
];
const SERVICE_PREMIUM_VOLUME: &[&str] = &[
"Standard",
"Basic",
"Classic",
"Regular",
"Field",
"Value",
"General",
"Common",
"Ordinary",
"Essential",
];
const COMMODITY_BRANDED_HALO: &[&str] = &[
"Original",
"Select",
"Premium",
"Reserve",
"Classic",
"Heritage",
"Signature",
"Superior",
"First",
"Grade",
"Certified",
];
const COMMODITY_BRANDED_VOLUME: &[&str] = &[
"Standard", "Basic", "Regular", "Field", "Value", "Economy", "Bulk", "Run", "Common", "Grade",
"Plain",
];
const DESIGN_HERITAGE_HALO: &[&str] = &[
"Heritage",
"Limited",
"Prestige",
"Grand",
"Signature",
"Edition",
"Series",
"Classic",
"Archive",
"Collector",
"Retrospective",
];
const DESIGN_HERITAGE_VOLUME: &[&str] = &[
"Standard",
"Classic",
"Regular",
"Field",
"Value",
"Base",
"Essential",
"Running",
"Contemporary",
"Current",
];
const PLATFORM_CATALOGUE_HALO: &[&str] = &[
"Premium",
"Pro",
"Plus",
"Elite",
"Advanced",
"Signature",
"Select",
"Grand",
"Unlimited",
"Complete",
"Full",
];
const PLATFORM_CATALOGUE_VOLUME: &[&str] = &[
"Standard",
"Basic",
"Classic",
"Regular",
"Field",
"Value",
"Entry",
"Lite",
"Essential",
"Free",
];
fn halo_descriptors(brand_category: &str) -> &'static [&'static str] {
match brand_category {
"terroir" => TERROIR_HALO,
"heritage_craft" => HERITAGE_CRAFT_HALO,
"tech_premium" => TECH_PREMIUM_HALO,
"cultural" => CULTURAL_HALO,
"service_premium" => SERVICE_PREMIUM_HALO,
"commodity_branded" => COMMODITY_BRANDED_HALO,
"design_heritage" => DESIGN_HERITAGE_HALO,
"platform_catalogue" => PLATFORM_CATALOGUE_HALO,
_ => COMMODITY_BRANDED_HALO,
}
}
fn volume_descriptors(brand_category: &str) -> &'static [&'static str] {
match brand_category {
"terroir" => TERROIR_VOLUME,
"heritage_craft" => HERITAGE_CRAFT_VOLUME,
"tech_premium" => TECH_PREMIUM_VOLUME,
"cultural" => CULTURAL_VOLUME,
"service_premium" => SERVICE_PREMIUM_VOLUME,
"commodity_branded" => COMMODITY_BRANDED_VOLUME,
"design_heritage" => DESIGN_HERITAGE_VOLUME,
"platform_catalogue" => PLATFORM_CATALOGUE_VOLUME,
_ => COMMODITY_BRANDED_VOLUME,
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Generate a plausible halo-tier product name.
/// Deterministic for a given RNG state.
pub fn generate_halo_name(rng: &mut ChaCha8Rng, corridor: &str, brand_category: &str) -> String {
let names = names_for_corridor(corridor);
let descs = halo_descriptors(brand_category);
let surname = names[rng.random_range(0..names.len())];
let desc = descs[rng.random_range(0..descs.len())];
// 25% chance: "Surname & Surname Descriptor" double-barrel
if rng.random::<f64>() < 0.25 {
let surname2 = names[rng.random_range(0..names.len())];
if surname != surname2 {
return format!("{} & {} {}", surname, surname2, desc);
}
}
format!("{} {}", surname, desc)
}
/// Generate a plausible volume-tier product name.
/// Volume names are shorter and more utilitarian than halo names.
pub fn generate_volume_name(rng: &mut ChaCha8Rng, corridor: &str, brand_category: &str) -> String {
let names = names_for_corridor(corridor);
let descs = volume_descriptors(brand_category);
let surname = names[rng.random_range(0..names.len())];
let desc = descs[rng.random_range(0..descs.len())];
format!("{} {}", surname, desc)
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
#[test]
fn halo_names_not_empty() {
let mut rng = ChaCha8Rng::seed_from_u64(1);
for corridor in &[
"north_reach",
"south_reach",
"west_reach",
"east_reach",
"frontier",
"core",
"reach_wide",
] {
for cat in &[
"terroir",
"heritage_craft",
"tech_premium",
"cultural",
"service_premium",
"commodity_branded",
"design_heritage",
"platform_catalogue",
] {
let name = generate_halo_name(&mut rng, corridor, cat);
assert!(!name.is_empty());
assert!(name.contains(' '));
}
}
}
#[test]
fn volume_names_not_empty() {
let mut rng = ChaCha8Rng::seed_from_u64(2);
for corridor in &["north_reach", "east_reach", "reach_wide"] {
for cat in &["terroir", "tech_premium", "cultural"] {
let name = generate_volume_name(&mut rng, corridor, cat);
assert!(!name.is_empty());
}
}
}
#[test]
fn deterministic_names() {
let mut rng1 = ChaCha8Rng::seed_from_u64(42);
let mut rng2 = ChaCha8Rng::seed_from_u64(42);
for _ in 0..50 {
let a = generate_halo_name(&mut rng1, "north_reach", "terroir");
let b = generate_halo_name(&mut rng2, "north_reach", "terroir");
assert_eq!(a, b);
}
}
}
+233
View File
@@ -0,0 +1,233 @@
//! Bookmark definition system (#614, D-115/D-117).
//!
//! A bookmark is a named starting scenario: skills weighting + starting state +
//! flavor text. The player chooses a bookmark on the character-creation screen.
//! v0.2 ships exactly one bookmark: `"tycoon"`.
pub mod types;
pub use types::{BookmarkDefinition, BookmarkId, CareerKind};
use std::collections::BTreeMap;
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use crate::bridge::types::{BookmarkCatalog, BookmarkWire, CareerKindWire, SnapshotBuffer};
use crate::knowledge::{resolve_culture, CultureResolver};
/// Immutable registry of bookmark definitions.
///
/// Built once during `BookmarkPlugin::build`, read-only at runtime.
/// `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 insert(&mut self, defn: BookmarkDefinition) {
self.entries.insert(defn.id.0.clone(), defn);
}
pub fn get(&self, id: &str) -> Option<&BookmarkDefinition> {
self.entries.get(id)
}
pub fn available(&self) -> impl Iterator<Item = &BookmarkDefinition> {
self.entries.values().filter(|d| d.available)
}
pub fn contains(&self, id: &str) -> bool {
self.entries.contains_key(id)
}
/// Build the wire catalog for the client.
///
/// `allowed_locations_cultures` is populated via the culture resolver (D-128, #679)
/// when one is provided. Entries for locations that fail resolution are empty strings
/// — callers should log a warning; see the error handling spec in #679.
pub fn build_catalog(&self, culture: Option<&CultureResolver>) -> BookmarkCatalog {
let bookmarks: Vec<BookmarkWire> = self
.available()
.map(|defn| {
let allowed_locations_cultures = defn
.allowed_locations
.iter()
.map(|loc| {
culture
.and_then(|r| match resolve_culture(r, loc) {
Ok(tag) => Some(tag.0),
Err(e) => {
tracing::warn!(
location = %loc,
error = %e,
"culture resolution failed for bookmark location"
);
None
}
})
.unwrap_or_default()
})
.collect();
BookmarkWire {
id: defn.id.0.clone(),
title: defn.title.clone(),
subtitle: defn.subtitle.clone(),
flavor: defn.flavor.clone(),
default_location: defn.default_location.clone(),
allowed_locations: defn.allowed_locations.clone(),
allowed_locations_cultures,
career: CareerKindWire::from(defn.career),
starting_capital_tractus: defn.starting_capital_tractus,
}
})
.collect();
BookmarkCatalog { bookmarks }
}
}
/// 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.
///
/// Downstream systems (apartment generator, skill seeder) read from this resource.
// TODO(sprint-37): serialize — see #863
#[derive(Resource, Debug, Clone, Default)]
pub struct SelectedBookmark {
pub bookmark_id: Option<String>,
pub starting_location_id: Option<String>,
}
/// Bookmark system plugin.
///
/// Registers `BookmarkRegistry`, `SelectedBookmark`, and the startup system that
/// stages the initial catalog in `SnapshotBuffer` for tick-0 delivery.
pub struct BookmarkPlugin;
impl Plugin for BookmarkPlugin {
fn build(&self, app: &mut App) {
let mut registry = BookmarkRegistry::default();
register_default_bookmarks(&mut registry);
app.insert_resource(registry)
.init_resource::<SelectedBookmark>()
.add_systems(Startup, prime_initial_catalog);
tracing::debug!("BookmarkPlugin initialized");
}
}
/// Startup system: stage the initial bookmark catalog in `SnapshotBuffer` so it
/// is delivered in the first `ObserverSnapshot` (tick 0).
///
/// `SnapshotBuffer` is registered by `BridgePlugin` (added before `BookmarkPlugin`).
/// `CultureResolverResource` is optional — missing in test worlds and when
/// `systems.db` is unavailable (server logs a warning in that case).
fn prime_initial_catalog(
registry: Res<BookmarkRegistry>,
mut buffer: ResMut<SnapshotBuffer>,
culture: Option<Res<crate::knowledge::CultureResolverResource>>,
) {
let resolver = culture.as_deref().map(|c| &c.0);
buffer.pending_bookmark_catalog = Some(registry.build_catalog(resolver));
tracing::debug!("BookmarkPlugin: initial catalog staged for tick-0 snapshot");
}
/// Register all v0.2 bookmarks.
///
/// Hard-coded for now (single entry). Promote to TOML loading in Sprint 38+
/// when a second bookmark is planned; the pattern is established by `content/brands/`.
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(),
// TODO(mellanie): author flavor text (24 sentences, plain text only).
flavor: String::new(),
// TODO(miri): confirm system_id for Van Maanen's Star.
default_location: "GJ 35".into(),
allowed_locations: vec!["GJ 35".into()],
career: CareerKind::Tycoon,
starting_capital_tractus: 5_000,
available: true,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bridge::types::CareerKindWire;
fn make_registry() -> BookmarkRegistry {
let mut r = BookmarkRegistry::default();
register_default_bookmarks(&mut r);
r
}
#[test]
fn registry_contains_tycoon() {
let r = make_registry();
assert!(r.contains("tycoon"));
assert_eq!(r.available().count(), 1);
}
#[test]
fn registry_get_returns_definition() {
let r = make_registry();
let defn = r.get("tycoon").expect("tycoon missing");
assert_eq!(defn.id.as_str(), "tycoon");
assert!(!defn.title.is_empty());
assert_eq!(defn.career, CareerKind::Tycoon);
assert!(defn.starting_capital_tractus > 0);
}
#[test]
fn catalog_builds_with_one_entry() {
let r = make_registry();
let catalog = r.build_catalog(None);
assert_eq!(catalog.bookmarks.len(), 1);
let wire = &catalog.bookmarks[0];
assert_eq!(wire.id, "tycoon");
assert_eq!(wire.career, CareerKindWire::Tycoon);
assert!(!wire.default_location.is_empty());
assert_eq!(
wire.allowed_locations.len(),
wire.allowed_locations_cultures.len()
);
}
#[test]
fn catalog_roundtrip_msgpack() {
let r = make_registry();
let catalog = r.build_catalog(None);
let bytes = rmp_serde::to_vec_named(&catalog).expect("serialize");
let decoded: BookmarkCatalog = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.bookmarks.len(), 1);
assert_eq!(decoded.bookmarks[0].id, "tycoon");
assert_eq!(decoded.bookmarks[0].career, CareerKindWire::Tycoon);
}
#[test]
fn confirm_bookmark_validates_id() {
let r = make_registry();
assert!(r.get("tycoon").is_some());
assert!(r.get("explorer").is_none());
}
#[test]
fn confirm_bookmark_validates_location() {
let r = make_registry();
let defn = r.get("tycoon").unwrap();
assert!(defn.allowed_locations.contains(&"GJ 35".to_string()));
assert!(!defn
.allowed_locations
.contains(&"Unknown System".to_string()));
}
}
+53
View File
@@ -0,0 +1,53 @@
use serde::{Deserialize, Serialize};
/// Stable identifier for a bookmark definition.
///
/// String-backed 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
}
}
/// Career seed used to route into career-specific initialization.
///
/// Pattern-matched by downstream systems (skill seeder, apartment generator,
/// monologue pool selector) to derive starting state.
/// v0.2: `Tycoon` only; extensible.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CareerKind {
/// Tycoon career — small business owner, D-117/D-118.
Tycoon,
}
/// 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).
pub title: String,
/// One-line subtitle under the title (≤64 chars).
pub subtitle: String,
/// Flavor blurb shown on selection. 24 sentences, plain text only.
pub flavor: String,
/// Default starting location (`system_id` from systems.db, e.g. `"GJ 35"`).
pub default_location: String,
/// Candidate starting locations the player can pick from.
/// Includes `default_location`. Empty vec = default only.
pub allowed_locations: Vec<String>,
/// Career seed — determines initial skill weighting, inventory, and starting business.
pub career: CareerKind,
/// Starting capital in Tractus (D-118 small-business scale).
pub starting_capital_tractus: i64,
/// Whether this bookmark is visible in the character-creation screen.
pub available: bool,
}
+1 -1
View File
@@ -14,9 +14,9 @@ use crate::bridge::types::{
};
use crate::knowledge::EntityRegistry;
use crate::npc::Npc;
use crate::simulation::conversation::NpcName;
use crate::simulation::economy::{EconSimResource, EconStateResource};
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use crate::simulation::npc_components::NpcName;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::SimulationTime;
use crate::simulation::triangle::TriangleState;
-1
View File
@@ -244,7 +244,6 @@ impl Plugin for BridgePlugin {
crate::simulation::monologue::trigger_event_monologue
.after(crate::simulation::monologue::process_sprint_anomaly_monologue)
.after(crate::simulation::sound::collect_sound_events)
.after(crate::simulation::conversation::run_npc_conversations)
.after(crate::simulation::dialogue::process_walk_away),
crate::simulation::monologue::process_contradiction_monologue
.after(crate::simulation::monologue::trigger_event_monologue),
+4 -4
View File
@@ -304,8 +304,7 @@ mod tests {
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
character_pressure: None,
sound_events: vec![],
@@ -321,6 +320,7 @@ mod tests {
current_ticker: None,
settings_response: None,
economy_snapshot: None,
bookmark_catalog: None,
}
}
@@ -446,8 +446,7 @@ mod tests {
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
character_pressure: None,
sound_events: vec![],
@@ -463,6 +462,7 @@ mod tests {
current_ticker: None,
settings_response: None,
economy_snapshot: None,
bookmark_catalog: None,
};
let text = format_snapshot_text(&snap);
assert!(text.contains("Tick 0"));
+69 -10
View File
@@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
/// negotiation is unnecessary. Client should reject snapshots with version !=
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
/// period, then the default is removed once both sides are updated.
pub const PROTOCOL_VERSION: u8 = 21;
pub const PROTOCOL_VERSION: u8 = 23;
/// Handshake message sent as the very first framed message after connection (#555).
/// Client reads this before entering the normal tick loop and validates
@@ -83,6 +83,9 @@ pub struct StartupMessage {
/// v20 adds: settings_response (#627, SQLite settings IPC).
/// v21 adds: economy_snapshot (#822, D-181 7-signal snapshot per queried system),
/// EconStateQuery PlayerAction variant (#822).
/// v22 adds: bookmark_catalog (#614, D-115/D-117 CK3-style bookmark system),
/// RequestBookmarkCatalog + ConfirmBookmark PlayerAction variants (#614).
/// v23 removes: conversation_events, conversation_ended (D-078 scrapped per R-012).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
@@ -140,15 +143,6 @@ pub struct ObserverSnapshot {
/// Empty when no sounds are in range.
#[serde(default)]
pub sound_events: Vec<crate::simulation::sound::SoundEvent>,
/// Overheard NPC-to-NPC conversation lines this tick (#247, D-078).
/// Each event carries pre-occluded text — client renders verbatim.
/// Empty when no conversations are overheard.
#[serde(default)]
pub conversation_events: Vec<crate::simulation::conversation::ConversationEvent>,
/// Conversations that ended this tick (#247, D-078).
/// Client dismisses the passive dialogue panel for these pairs.
#[serde(default)]
pub conversation_ended: Vec<crate::simulation::conversation::ConversationEndEvent>,
/// Follow-mode state for client HUD display (#241).
/// Present when the player is actively following an NPC.
/// Client shows follow indicator with distance, LOS, and tension.
@@ -224,6 +218,56 @@ pub struct ObserverSnapshot {
/// None during normal gameplay; client queries explicitly via `EconStateQuery`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub economy_snapshot: Option<EconomySnapshot>,
/// Bookmark catalog (#614, D-115/D-117).
/// Present for exactly one tick after handshake (tick 0) and after
/// `PlayerAction::RequestBookmarkCatalog`. None otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bookmark_catalog: Option<BookmarkCatalog>,
}
/// Career kind wire-safe mirror for `CareerKind` (#614, D-117).
///
/// Kept separate from the server-internal `CareerKind` so future server-only
/// variants (e.g. debug/test archetypes) don't leak through serde.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CareerKindWire {
Tycoon,
}
impl From<crate::bookmark::types::CareerKind> for CareerKindWire {
fn from(k: crate::bookmark::types::CareerKind) -> Self {
match k {
crate::bookmark::types::CareerKind::Tycoon => CareerKindWire::Tycoon,
}
}
}
/// Bookmark projection for the client (#614, D-115/D-117).
///
/// Sent inside `BookmarkCatalog` in `ObserverSnapshot.bookmark_catalog`.
/// Drop server-only fields; keep the struct stable for the bridge.
/// `allowed_locations_cultures` is pre-resolved server-side (§9 of culture spec, #679).
#[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>,
/// Parallel to `allowed_locations`: resolved culture tag per location (#679).
/// Populated by `BookmarkRegistry::build_catalog` using `CultureResolverResource`.
/// Empty string when the resolver has no mapping for a location.
pub allowed_locations_cultures: Vec<String>,
pub career: CareerKindWire,
pub starting_capital_tractus: i64,
}
/// Catalog of all available bookmarks sent to the client (#614).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BookmarkCatalog {
pub bookmarks: Vec<BookmarkWire>,
}
/// A single news ticker headline crossing the wire boundary (#591).
@@ -564,6 +608,19 @@ pub enum PlayerAction {
EconStateQuery {
system_id: String,
},
/// 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 known `BookmarkId`. `starting_location_id` must
/// be in `BookmarkDefinition.allowed_locations` for that bookmark.
/// On invalid inputs: server pushes a `SimError { kind: ProtocolError }` and
/// ignores the action — client should block the confirm button and re-prompt.
ConfirmBookmark {
bookmark_id: String,
starting_location_id: String,
},
}
impl PlayerAction {
@@ -1038,6 +1095,8 @@ pub struct SnapshotBuffer {
pub pending_settings_response: Option<crate::settings::types::SettingsResponseWire>,
/// Pending economy snapshot, consumed once by `compute_observer_snapshot` (#822).
pub pending_economy_response: Option<EconomySnapshot>,
/// Pending bookmark catalog, consumed once by `compute_observer_snapshot` (#614).
pub pending_bookmark_catalog: Option<BookmarkCatalog>,
}
#[cfg(test)]
+283
View File
@@ -0,0 +1,283 @@
//! 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");
}
}
}
+1 -1
View File
@@ -185,7 +185,7 @@ pub fn process_knowledge_events(
mut queue: ResMut<KnowledgeEventQueue>,
mut contradiction_queue: ResMut<ContradictionDetectedQueue>,
registry: Res<EntityRegistry>,
npc_names: Query<&crate::simulation::conversation::NpcName>,
npc_names: Query<&crate::simulation::npc_components::NpcName>,
mut knowledge_query: Query<&mut KnowledgeGraph>,
) {
let events = queue.drain();
Binary file not shown.
+4
View File
@@ -8,12 +8,16 @@ use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
pub mod content_registry;
pub mod culture;
pub mod events;
pub mod graph;
pub mod registry;
pub mod types;
pub use content_registry::ContentEntityRegistry;
pub use culture::{
resolve_culture, CultureError, CultureResolver, CultureResolverResource, CultureTag,
};
pub use events::{
ContradictionDetectedEvent, ContradictionDetectedQueue, InteractionType, KnowledgeEvent,
KnowledgeEventQueue, KnowledgeEventType, ProcessedEntityGrant, ProcessedFactGrant,
+1
View File
@@ -1,6 +1,7 @@
// The Settled Reach - Simulation Server
// Rust/bevy_ecs simulation server for D-010 client-server architecture
pub mod bookmark;
pub mod bridge;
pub mod cause_chain;
pub mod knowledge;
+21 -2
View File
@@ -153,6 +153,25 @@ fn main() {
app.add_plugins(settled_reach_server::npc::NpcPlugin);
app.add_plugins(settled_reach_server::storyteller::StorytellerPlugin);
app.add_plugins(settled_reach_server::settings::SettingsPlugin);
app.add_plugins(settled_reach_server::bookmark::BookmarkPlugin);
// Initialize culture resolver (#679, D-128).
// systems.db is shipped read-only alongside the binary.
let systems_db_path = std::path::PathBuf::from("data/systems.db");
match settled_reach_server::knowledge::CultureResolver::open(&systems_db_path) {
Ok(resolver) => {
tracing::info!("Culture resolver opened: {:?}", systems_db_path);
app.insert_resource(settled_reach_server::knowledge::CultureResolverResource(
resolver,
));
}
Err(e) => {
tracing::warn!(
"Culture resolver unavailable ({}). Culture lookups will not work.",
e
);
}
}
// Initialize SQLite settings store (#627).
// Path: alongside save files in the server's working directory.
@@ -326,8 +345,6 @@ fn send_panic_error(app: &App, panic_msg: &str) {
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
character_pressure: None,
rng_seed: None,
@@ -341,6 +358,7 @@ fn send_panic_error(app: &App, panic_msg: &str) {
current_ticker: None,
settings_response: None,
economy_snapshot: None,
bookmark_catalog: None,
sim_errors: vec![SimError {
kind: SimErrorKind::Panic,
message: format!("Simulation panic: {}", panic_msg),
@@ -376,6 +394,7 @@ fn dump_schedule_graph() {
app.add_plugins(settled_reach_server::npc::NpcPlugin);
app.add_plugins(settled_reach_server::storyteller::StorytellerPlugin);
app.add_plugins(settled_reach_server::settings::SettingsPlugin);
app.add_plugins(settled_reach_server::bookmark::BookmarkPlugin);
app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(0));
// Access Schedules resource directly — schedules are populated by plugins
+4 -11
View File
@@ -17,7 +17,6 @@ use crate::perception::cognitive_delay::CognitiveDelay;
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
use crate::perception::vision_cone::Facing;
use crate::simulation::contraband::ScanEventBuffer;
use crate::simulation::conversation::ConversationEventBuffer;
use crate::simulation::dialogue::DialogueResponseBuffer;
use crate::simulation::examine::ExamineResultBuffer;
use crate::simulation::follow::FollowTarget;
@@ -84,7 +83,6 @@ pub fn compute_observer_snapshot(
Option<&CognitiveDelay>,
Option<&mut DialogueResponseBuffer>,
Option<&mut ScanEventBuffer>,
Option<&mut ConversationEventBuffer>,
Option<&FollowTarget>,
Option<&mut ExamineResultBuffer>,
),
@@ -121,7 +119,6 @@ pub fn compute_observer_snapshot(
cognitive_delay_opt,
mut dialogue_response_opt,
mut scan_event_buffer_opt,
mut conversation_buffer_opt,
follow_target_opt,
mut examine_result_buffer_opt,
)) = observer_query.single_mut()
@@ -225,12 +222,6 @@ pub fn compute_observer_snapshot(
.map(|buf| buf.take())
.unwrap_or_default();
// Drain NPC-to-NPC conversation events (#247, D-078)
let (conversation_events, conversation_ended) = conversation_buffer_opt
.as_mut()
.map(|buf| (buf.take_events(), buf.take_ended()))
.unwrap_or_default();
// Collect sound events audible to the observer (D-038, #124).
// Filter by D-018 range: only events the player can hear based on distance.
let sound_events = if let Some(ref queue) = sound_queue {
@@ -397,6 +388,9 @@ pub fn compute_observer_snapshot(
// Consume pending economy snapshot for this tick (#822).
let economy_snapshot = buffer.pending_economy_response.take();
// Consume pending bookmark catalog for this tick (#614).
let bookmark_catalog = buffer.pending_bookmark_catalog.take();
// Consume pending save/load result for this tick (#553).
let save_result = buffer.pending_save_result.take();
@@ -473,8 +467,6 @@ pub fn compute_observer_snapshot(
dialogue_response,
blocked_entities,
scan_events,
conversation_events,
conversation_ended,
follow_state,
character_pressure: pressure_query
.iter()
@@ -493,6 +485,7 @@ pub fn compute_observer_snapshot(
current_ticker,
settings_response,
economy_snapshot,
bookmark_catalog,
});
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -27,7 +27,6 @@ use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, Stab
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::npc::interaction::{InteractionEvent, InteractionEventKind, InteractionMemory};
use crate::npc::relationships::{TrustEvent, TrustEventQueue};
use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, NpcName};
use crate::simulation::knowledge_grant::KnowledgeGrant;
use crate::simulation::line_pool::LinePoolIndexResource;
use crate::simulation::line_pool::{
@@ -35,6 +34,7 @@ use crate::simulation::line_pool::{
};
use crate::simulation::monologue::{MonologueBuffer, MonologueState};
use crate::simulation::movement::PlayerCharacter;
use crate::simulation::npc_components::{display_label_for_role, NpcColorIndex, NpcName};
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
use crate::storyteller::EngagementRecord;
+277 -1
View File
@@ -2,8 +2,12 @@
// Timestamped player input events for deterministic simulation (D-010 principle 4)
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance)
use crate::bookmark::{BookmarkRegistry, SelectedBookmark};
use crate::bridge::debug::DebugCommandBuffer;
use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInput};
use crate::bridge::types::{
FacingDirection, ObjectType, PlayerAction, PlayerInput, SimError, SimErrorKind, SnapshotBuffer,
};
use crate::knowledge::CultureResolverResource;
use crate::knowledge::{EntityRegistry, StableId};
use crate::perception::vision_cone::{facing_from_delta, Facing};
use crate::settings::{SettingsCommand, SettingsCommandBuffer};
@@ -18,6 +22,7 @@ use crate::simulation::stance::{PlayerMoveCooldown, Stance};
use crate::simulation::time::{SimulationTime, TickRate};
use crate::test_world::reset::{RoomResetTrigger, RoomSnapshots};
use bevy_ecs::prelude::*;
use bevy_ecs::system::SystemParam;
use std::collections::VecDeque;
/// Maximum number of inputs the queue will hold before dropping oldest.
@@ -78,6 +83,19 @@ impl InputQueue {
}
}
/// Bundled SystemParam for bookmark-related input handling.
///
/// Bevy's blanket `IntoSystem` impl covers functions up to 16 parameters.
/// Bundling the 4 bookmark params keeps `process_player_input` at exactly 16.
#[derive(SystemParam)]
pub struct BookmarkInputParams<'w> {
pub registry: Option<Res<'w, BookmarkRegistry>>,
pub selected: Option<ResMut<'w, SelectedBookmark>>,
pub snapshot_buf: Option<ResMut<'w, SnapshotBuffer>>,
pub sim_error_buf: Option<ResMut<'w, crate::bridge::types::SimErrorBuffer>>,
pub culture: Option<Res<'w, CultureResolverResource>>,
}
/// Drains InputQueue for the current tick, converts PlayerActions to ECS components.
/// Handles stance toggling (D-053), movement cooldown, Take/Place verbs (#424),
/// and save/load commands (#553).
@@ -106,6 +124,7 @@ pub fn process_player_input(
mut econ_query_buf: Option<ResMut<EconQueryBuffer>>,
door_states: Query<&DoorState>,
object_types: Query<&ObjectType>,
mut bookmark: BookmarkInputParams<'_>,
) {
let current_tick = time.tick;
let paused = time.paused();
@@ -130,6 +149,8 @@ pub fn process_player_input(
| PlayerAction::RequestAllSettings
| PlayerAction::DeleteSetting { .. }
| PlayerAction::EconStateQuery { .. }
| PlayerAction::RequestBookmarkCatalog
| PlayerAction::ConfirmBookmark { .. }
)
{
continue;
@@ -420,6 +441,32 @@ pub fn process_player_input(
tracing::debug!("EconStateQuery received but EconQueryBuffer not registered — economy not loaded");
}
}
PlayerAction::RequestBookmarkCatalog => {
if let (Some(ref registry), Some(ref mut buf)) =
(&bookmark.registry, &mut bookmark.snapshot_buf)
{
let resolver = bookmark.culture.as_deref().map(|c| &c.0);
buf.pending_bookmark_catalog = Some(registry.build_catalog(resolver));
tracing::debug!("RequestBookmarkCatalog: catalog staged");
} else {
tracing::warn!(
"RequestBookmarkCatalog: BookmarkRegistry or SnapshotBuffer not available"
);
}
}
PlayerAction::ConfirmBookmark {
bookmark_id,
starting_location_id,
} => {
handle_confirm_bookmark(
bookmark_id,
starting_location_id,
&bookmark.registry,
&mut bookmark.selected,
&mut bookmark.sim_error_buf,
current_tick,
);
}
}
}
@@ -1121,6 +1168,71 @@ fn handle_teleport_to_hub(
}
}
fn handle_confirm_bookmark(
bookmark_id: String,
starting_location_id: String,
registry: &Option<Res<BookmarkRegistry>>,
selected_bookmark: &mut Option<ResMut<SelectedBookmark>>,
sim_error_buf: &mut Option<ResMut<crate::bridge::types::SimErrorBuffer>>,
tick: u64,
) {
let Some(ref registry) = registry else {
tracing::warn!("ConfirmBookmark: BookmarkRegistry not available");
return;
};
let Some(defn) = registry.get(&bookmark_id) else {
let msg = format!("ConfirmBookmark: unknown bookmark_id {:?}", bookmark_id);
tracing::warn!("{}", msg);
if let Some(ref mut buf) = sim_error_buf {
buf.push(SimError {
kind: SimErrorKind::ProtocolError,
message: msg,
tick,
});
}
return;
};
if !defn.allowed_locations.contains(&starting_location_id) {
let msg = format!(
"ConfirmBookmark: starting_location_id {:?} not in allowed_locations for {:?}",
starting_location_id, bookmark_id
);
tracing::warn!("{}", msg);
if let Some(ref mut buf) = sim_error_buf {
buf.push(SimError {
kind: SimErrorKind::ProtocolError,
message: msg,
tick,
});
}
return;
}
if let Some(ref mut sel) = selected_bookmark {
if sel.bookmark_id.is_some() {
let msg = format!(
"ConfirmBookmark: bookmark already confirmed ({}), ignoring retry",
sel.bookmark_id.as_deref().unwrap_or("?")
);
tracing::warn!("{}", msg);
if let Some(ref mut buf) = sim_error_buf {
buf.push(SimError {
kind: SimErrorKind::ProtocolError,
message: msg,
tick,
});
}
return;
}
sel.bookmark_id = Some(bookmark_id.clone());
sel.starting_location_id = Some(starting_location_id.clone());
tracing::info!(
bookmark_id,
starting_location_id,
"ConfirmBookmark: selection recorded"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2462,4 +2574,168 @@ mod tests {
let hub_spawn = crate::test_world::constants::HUB.spawn;
assert_eq!(pos.x, hub_spawn.x, "teleport works while paused");
}
fn make_bookmark_world() -> bevy_ecs::world::World {
use crate::bookmark::types::{BookmarkDefinition, BookmarkId, CareerKind};
use crate::bookmark::{BookmarkRegistry, SelectedBookmark};
use crate::bridge::types::SimErrorBuffer;
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.init_resource::<SelectedBookmark>();
world.init_resource::<SimErrorBuffer>();
let mut registry = BookmarkRegistry::default();
registry.insert(BookmarkDefinition {
id: BookmarkId("test_bookmark".to_string()),
title: "Test Bookmark".into(),
subtitle: String::new(),
flavor: String::new(),
default_location: "Loc A".into(),
allowed_locations: vec!["Loc A".into(), "Loc B".into()],
career: CareerKind::Tycoon,
starting_capital_tractus: 1_000,
available: true,
});
world.insert_resource(registry);
world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
world
}
#[test]
fn confirm_bookmark_unknown_id_emits_sim_error() {
use crate::bridge::types::SimErrorBuffer;
let mut world = make_bookmark_world();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::ConfirmBookmark {
bookmark_id: "no_such_bookmark".into(),
starting_location_id: "Loc A".into(),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
let errors = world.resource_mut::<SimErrorBuffer>().drain();
assert_eq!(
errors.len(),
1,
"expected one SimError for unknown bookmark"
);
assert_eq!(
errors[0].kind,
crate::bridge::types::SimErrorKind::ProtocolError
);
assert!(errors[0].message.contains("unknown bookmark_id"));
assert!(
world
.resource::<crate::bookmark::SelectedBookmark>()
.bookmark_id
.is_none(),
"SelectedBookmark must not be set on error"
);
}
#[test]
fn confirm_bookmark_invalid_location_emits_sim_error() {
use crate::bridge::types::SimErrorBuffer;
let mut world = make_bookmark_world();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::ConfirmBookmark {
bookmark_id: "test_bookmark".into(),
starting_location_id: "Not A Location".into(),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
let errors = world.resource_mut::<SimErrorBuffer>().drain();
assert_eq!(
errors.len(),
1,
"expected one SimError for invalid location"
);
assert_eq!(
errors[0].kind,
crate::bridge::types::SimErrorKind::ProtocolError
);
assert!(errors[0].message.contains("not in allowed_locations"));
}
#[test]
fn confirm_bookmark_valid_inputs_populate_selected_bookmark() {
use crate::bridge::types::SimErrorBuffer;
let mut world = make_bookmark_world();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::ConfirmBookmark {
bookmark_id: "test_bookmark".into(),
starting_location_id: "Loc B".into(),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
let errors = world.resource_mut::<SimErrorBuffer>().drain();
assert!(
errors.is_empty(),
"no errors expected for valid ConfirmBookmark"
);
let sel = world.resource::<crate::bookmark::SelectedBookmark>();
assert_eq!(sel.bookmark_id.as_deref(), Some("test_bookmark"));
assert_eq!(sel.starting_location_id.as_deref(), Some("Loc B"));
}
#[test]
fn confirm_bookmark_double_confirm_emits_sim_error() {
use crate::bridge::types::SimErrorBuffer;
let mut world = make_bookmark_world();
// Both confirms at tick=0: processed in order within the same run.
// First succeeds; second hits the idempotency guard.
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::ConfirmBookmark {
bookmark_id: "test_bookmark".into(),
starting_location_id: "Loc A".into(),
},
});
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::ConfirmBookmark {
bookmark_id: "test_bookmark".into(),
starting_location_id: "Loc B".into(),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
let errors = world.resource_mut::<SimErrorBuffer>().drain();
assert_eq!(errors.len(), 1, "double-confirm must emit ProtocolError");
assert_eq!(
errors[0].kind,
crate::bridge::types::SimErrorKind::ProtocolError
);
assert!(errors[0].message.contains("already confirmed"));
// Selection must remain the original, not overwritten
let sel = world.resource::<crate::bookmark::SelectedBookmark>();
assert_eq!(sel.starting_location_id.as_deref(), Some("Loc A"));
}
}
+1 -1
View File
@@ -7,7 +7,6 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod chunk_streaming;
// Phase sub-plugins (#843)
pub mod contraband;
pub mod conversation;
pub mod dialogue;
pub mod economy;
pub mod economy_plugin;
@@ -25,6 +24,7 @@ pub mod modification;
pub mod monologue;
pub mod movement;
pub mod movement_plugin;
pub mod npc_components;
pub mod npc_knowledge_transfer;
pub mod path_follow;
pub mod pathfinding;
+8 -105
View File
@@ -16,8 +16,8 @@ use rand::Rng;
use crate::bridge::types::MonologueEvent;
use crate::knowledge::{ContradictionDetectedQueue, EntityRegistry};
use crate::perception::interpretation::ObservationTrigger;
use crate::simulation::conversation::NpcName;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::npc_components::NpcName;
use crate::simulation::rng::EntityRng;
use crate::simulation::time::SimulationTime;
use crate::storyteller::EngagementRecord;
@@ -84,15 +84,6 @@ const HEAR_SOUND_LINES: &[(&str, &str)] = &[
("hear_sound_03", "Something just happened nearby."),
];
/// Hardcoded v0.1 witness_interaction monologue lines.
/// Fire when the player overhears an NPC-to-NPC conversation (D-078).
/// Future: move to content pools with trigger="witness_interaction".
const WITNESS_INTERACTION_LINES: &[(&str, &str)] = &[
("witness_01", "Interesting. Wonder what that was about."),
("witness_02", "I should remember what they just said."),
("witness_03", "They didn't know I was listening."),
];
/// Hardcoded v0.1 post_conversation monologue lines.
/// Fire after a player-NPC dialogue concludes (walk-away or natural end).
/// Future: move to content pools with trigger="post_conversation".
@@ -375,7 +366,6 @@ fn select_hardcoded_fallback(trigger: &str, rng: &mut impl Rng) -> (String, Stri
let lines = match trigger {
"observe_npc" => OBSERVE_NPC_LINES,
"hear_sound" => HEAR_SOUND_LINES,
"witness_interaction" => WITNESS_INTERACTION_LINES,
"post_conversation" => POST_CONVERSATION_LINES,
unknown => {
tracing::warn!(
@@ -405,16 +395,15 @@ fn sound_range_tiles(range: &crate::knowledge::types::SoundRange) -> u32 {
/// Event-driven monologue trigger system (#119, D-035).
///
/// Checks observation events, sound events, overheard conversations, and
/// completed dialogues for monologue-worthy triggers. Fires at most one
/// Checks observation events, sound events, and completed dialogues for
/// monologue-worthy triggers. Fires at most one
/// monologue per tick. Event-driven — no cooldown gate. Updates
/// `last_fired_tick` so v0.2 periodic triggers can respect the recency window.
///
/// Priority order (first match wins):
/// 1. observe_npc (new entity spotted — uses previous-tick observation events)
/// 2. hear_sound (non-routine sound: Machinery, Alert)
/// 3. witness_interaction (overheard NPC-to-NPC conversation, D-078)
/// 4. post_conversation (player-NPC dialogue concluded)
/// 3. post_conversation (player-NPC dialogue concluded)
///
/// System ordering: after all event producers + recognition/anomaly monologue
/// systems, before compute_observer_snapshot.
@@ -429,7 +418,6 @@ pub fn trigger_event_monologue(
&TilePosition,
&mut MonologueState,
&mut MonologueBuffer,
Option<&crate::simulation::conversation::ConversationEventBuffer>,
&mut EntityRng,
),
With<PlayerCharacter>,
@@ -441,9 +429,7 @@ pub fn trigger_event_monologue(
// Saved for NPC attribution (engagement tracking #570) and trigger detection.
let post_conv_npcs: Vec<Entity> = post_conv_queue.drain();
let Ok((player_pos, mut state, mut buffer, conv_buffer_opt, mut entity_rng)) =
query.single_mut()
else {
let Ok((player_pos, mut state, mut buffer, mut entity_rng)) = query.single_mut() else {
return;
};
@@ -468,11 +454,6 @@ pub fn trigger_event_monologue(
.unwrap_or(false)
{
Some("hear_sound")
} else if conv_buffer_opt
.map(|b| !b.events.is_empty())
.unwrap_or(false)
{
Some("witness_interaction")
} else if !post_conv_npcs.is_empty() {
Some("post_conversation")
} else {
@@ -511,7 +492,7 @@ pub fn trigger_event_monologue(
);
// Engagement tracking (#570): attribute monologue_trigger_count to specific NPCs.
// Only NPC-context triggers are attributed — hear_sound/witness_interaction are not NPC-specific.
// Only NPC-context triggers are attributed — hear_sound is not NPC-specific.
match trigger {
"observe_npc" => {
// Attribute to all NPCs whose NewEntity event triggered this monologue
@@ -537,7 +518,7 @@ pub fn trigger_event_monologue(
}
}
}
_ => {} // hear_sound, witness_interaction: no NPC-specific attribution
_ => {} // hear_sound: no NPC-specific attribution
}
}
@@ -1345,7 +1326,6 @@ mod tests {
use crate::perception::interpretation::{
ObservationEvent, ObservationEventQueue, ObservationTrigger,
};
use crate::simulation::conversation::ConversationEventBuffer;
use crate::simulation::sound::{SoundEvent, SoundEventKind, SoundEventQueue};
fn setup_event_world() -> World {
@@ -1366,7 +1346,6 @@ mod tests {
TilePosition::new(10, 10, 0),
MonologueState::default(),
MonologueBuffer::default(),
ConversationEventBuffer::default(),
))
.id()
}
@@ -1521,36 +1500,6 @@ mod tests {
);
}
#[test]
fn witness_interaction_fires_on_conversation_event() {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
// Pre-fill ConversationEventBuffer with an overheard conversation
world
.get_mut::<ConversationEventBuffer>(player)
.unwrap()
.events
.push(crate::simulation::conversation::ConversationEvent {
occluded_line: "Keep your head down today.".to_string(),
speaker_id: 100,
target_id: 101,
speaker_name: "Worker".to_string(),
target_name: "Courier".to_string(),
speaker_color_index: 0,
target_color_index: 1,
});
run_event_system(&mut world);
let buf = world.get::<MonologueBuffer>(player).unwrap();
assert!(
buf.event.is_some(),
"witness_interaction should fire when conversation overheard"
);
assert!(buf.event.as_ref().unwrap().id.starts_with("witness_"));
}
#[test]
fn post_conversation_fires_on_queue_entry() {
let mut world = setup_event_world();
@@ -1668,47 +1617,6 @@ mod tests {
);
}
#[test]
fn priority_hear_sound_over_witness_interaction() {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
// Sound event
world
.resource_mut::<SoundEventQueue>()
.events
.push(SoundEvent::at(
&TilePosition::new(11, 10, 0),
SoundEventKind::Alert,
1.0,
crate::knowledge::types::SoundRange::Medium,
None,
));
// Conversation event
world
.get_mut::<ConversationEventBuffer>(player)
.unwrap()
.events
.push(crate::simulation::conversation::ConversationEvent {
occluded_line: "Test".to_string(),
speaker_id: 100,
target_id: 101,
speaker_name: "A".to_string(),
target_name: "B".to_string(),
speaker_color_index: 0,
target_color_index: 1,
});
run_event_system(&mut world);
let buf = world.get::<MonologueBuffer>(player).unwrap();
assert!(
buf.event.as_ref().unwrap().id.starts_with("hear_sound_"),
"hear_sound should have priority over witness_interaction"
);
}
#[test]
fn event_trigger_updates_last_fired_tick() {
let mut world = setup_event_world();
@@ -1813,12 +1721,7 @@ mod tests {
#[test]
fn hardcoded_lines_all_valid() {
for lines in &[
OBSERVE_NPC_LINES,
HEAR_SOUND_LINES,
WITNESS_INTERACTION_LINES,
POST_CONVERSATION_LINES,
] {
for lines in &[OBSERVE_NPC_LINES, HEAR_SOUND_LINES, POST_CONVERSATION_LINES] {
assert!(!lines.is_empty());
for (id, text) in *lines {
assert!(!id.is_empty(), "line id should not be empty");
+50
View File
@@ -0,0 +1,50 @@
// NPC component types shared across simulation systems.
//
// Extracted from conversation.rs (D-078 scrapped per R-012). These types
// survive because they are used by dialogue, debug, knowledge transfer,
// monologue, and the D-080 knowledge propagation system.
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
/// Display name for an NPC.
/// Attached during content spawn.
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct NpcName(pub String);
/// Map a dialogue role string to a display label for use when the player
/// does not yet know the NPC's real name.
pub fn display_label_for_role(role: &str) -> String {
match role {
"dock-worker" => "Dock Worker",
"courier" => "Courier",
"maintenance-tech" => "Technician",
"new-hire" | "day-worker" | "transit-worker" => "Worker",
"scheduler" => "Scheduler",
"shift-supervisor" => "Supervisor",
"bartender" => "Bartender",
"bar-regular" => "Patron",
_ => "Bystander",
}
.to_string()
}
/// Color index (0-7) for rendering this NPC with a distinct color.
/// Assigned at spawn time as `(stable_id % 8)`.
#[derive(Component, Debug, Clone, Copy, Serialize, Deserialize)]
pub struct NpcColorIndex(pub u8);
/// Active NPC-to-NPC conversation session (D-080 knowledge propagation).
/// Attached to the "speaker" NPC (the one who initiated).
/// The "listener" is tracked by entity reference.
#[derive(Component, Debug)]
pub struct NpcConversation {
/// The other NPC in the conversation.
pub partner: Entity,
/// Tick when the conversation started.
pub started_tick: u64,
/// Tick when the conversation will end.
pub end_tick: u64,
/// Ticks since last line was spoken (for pacing).
pub ticks_since_last_line: u64,
}
@@ -28,8 +28,8 @@ use crate::knowledge::{
};
use crate::npc::relationships::RelationshipGraph;
use crate::npc::Npc;
use crate::simulation::conversation::NpcConversation;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::npc_components::NpcConversation;
use crate::simulation::rng::SimRng;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::SimulationTime;
@@ -115,7 +115,7 @@ impl TransferCandidate {
/// If the player is within VOICE_RANGE_TILES, they gain entity-level knowledge
/// about both NPCs at `Suspects` confidence (`Heard` source).
///
/// System ordering: after(run_npc_conversations).
/// Runs in [`TickPhase::Simulation`] with no ordering constraint.
#[allow(clippy::too_many_arguments)]
pub fn transfer_npc_knowledge(
time: Res<SimulationTime>,
@@ -203,10 +203,9 @@ pub fn transfer_npc_knowledge(
}
// --- Dual-mutable KG access ---
// Transfer is one-directional per conversation tick: speaker → partner.
// If both participants are Active NPCs, each fires as "speaker" in
// separate conversation pairs (run_npc_conversations creates symmetric
// pairs), so both directions are covered across two iterations.
// Transfer is one-directional per NpcConversation component: speaker → partner.
// When both participants have NpcConversation, each fires as "speaker"
// in a separate query row, so both directions are covered.
let Ok([speaker_kg, mut partner_kg]) =
kg_query.get_many_mut([speaker_entity, partner_entity])
@@ -438,8 +437,8 @@ mod tests {
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::npc::relationships::{RelationshipEdge, RelationshipGraph};
use crate::npc::RelationshipKind;
use crate::simulation::conversation::NpcConversation;
use crate::simulation::movement::TilePosition;
use crate::simulation::npc_components::NpcConversation;
use crate::simulation::rng::SimRng;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::SimulationTime;
+4 -11
View File
@@ -1,8 +1,6 @@
//! Social simulation plugin — NPC conversations, knowledge transfer, disclosure.
//! Social simulation plugin — NPC knowledge transfer, disclosure, and social systems.
//!
//! All systems run in [`TickPhase::Simulation`]. Intra-phase ordering:
//! - conversations → knowledge_transfer (transfer reads conversation results)
//! - conversations → sound collection (sound reads conversation events)
//! All systems run in [`TickPhase::Simulation`].
use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
@@ -20,16 +18,11 @@ impl Plugin for SocialPlugin {
.add_systems(
Update,
(
super::conversation::run_npc_conversations,
super::npc_knowledge_transfer::transfer_npc_knowledge
.after(super::conversation::run_npc_conversations),
super::sound::collect_sound_events
.after(super::conversation::run_npc_conversations),
super::npc_knowledge_transfer::transfer_npc_knowledge,
super::sound::collect_sound_events,
// Voice enrichment (D-138) — rewrite NPC text with voiced variants.
// No-op when VoiceCacheResource is absent.
crate::voice::integration::voice_enrich_dialogue_response,
crate::voice::integration::voice_enrich_conversation_events
.after(super::conversation::run_npc_conversations),
// POI discovery reads visibility geometry (also Simulation phase)
super::poi_discovery::discover_pois,
// Follow state reads visibility geometry + movement
+1 -1
View File
@@ -614,8 +614,8 @@ pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterA
// can respond to Talk using content from the YAML dialogue pools.
{
use crate::npc::Npc;
use crate::simulation::conversation::NpcColorIndex;
use crate::simulation::dialogue::{CurrentMood, DialogueProfile};
use crate::simulation::npc_components::NpcColorIndex;
// (location, role) pairs matching server/content/campaigns/.../dialogue/ YAML pools.
// Cycling through these gives NPC variety across rooms.
+4 -86
View File
@@ -1,15 +1,13 @@
//! Observer integration for the voice pipeline (D-138, Phase 3).
//!
//! Two enrichment systems run before `compute_observer_snapshot` and rewrite
//! NPC text in the dialogue and conversation buffers with voiced variants
//! looked up from the cache. Cache miss → base text (never blocks).
//! One enrichment system runs before `compute_observer_snapshot` and rewrites
//! NPC dialogue text with voiced variants looked up from the cache.
//! Cache miss → base text (never blocks).
//!
//! ## System ordering
//!
//! ```text
//! process_talk_interaction ─┐
//! run_npc_conversations ─┤─► voice_enrich_dialogue_response ─┐
//! └─► voice_enrich_conversation_events ─► compute_observer_snapshot
//! process_talk_interaction ─► voice_enrich_dialogue_response ─► compute_observer_snapshot
//! ```
//!
//! ## Content index derivation
@@ -24,7 +22,6 @@ use bevy_ecs::prelude::*;
use crate::knowledge::{EntityRegistry, StableId};
use crate::npc::tell_state::DerivedTellState;
use crate::npc::{Npc, NpcVoiceProfile};
use crate::simulation::conversation::ConversationEventBuffer;
use crate::simulation::dialogue::DialogueResponseBuffer;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::zone::ZoneMap;
@@ -127,85 +124,6 @@ pub fn voice_enrich_dialogue_response(
response.text = voiced;
}
/// Enrich the conversation event buffer with voiced lines from the cache.
///
/// Must run after `run_npc_conversations` and before
/// `compute_observer_snapshot`. No-op when the voice cache resource is absent.
///
/// Each event in the buffer is processed: the pre-occlusion base text is
/// not available at this point (occlusion has already been applied), so the
/// `occluded_line` is treated as the base text for the voice lookup.
/// This means the voice register wraps the already-occluded line.
///
/// ## Accepted risk: re-voicing of heavily occluded text
///
/// When many words are dropped by D-078 occlusion, the remaining text may
/// be fragmentary ("... the ... came in ..."). Re-voicing such fragments can
/// produce incoherent output. This is acceptable for two reasons:
/// 1. Cache misses are common for conversation text (no pre-baking pipeline),
/// so the base text fallback in `voiced_behavior()` fires most of the time.
/// 2. Even incoherent voiced output is no worse than the already-degraded
/// overheard line — the occlusion itself has already broken coherence.
/// The player's inability to fully parse overheard speech is the mechanic.
#[allow(clippy::type_complexity)]
pub fn voice_enrich_conversation_events(
voice_cache: Option<Res<VoiceCacheResource>>,
registry: Res<EntityRegistry>,
zone_map: Option<Res<ZoneMap>>,
player_query: Query<&TilePosition, With<PlayerCharacter>>,
npc_voice_query: Query<(&NpcVoiceProfile, Option<&DerivedTellState>), With<Npc>>,
mut conversation_buffer: Query<&mut ConversationEventBuffer, With<PlayerCharacter>>,
) {
let Some(voice_cache) = voice_cache else {
return;
};
let Ok(mut buffer) = conversation_buffer.single_mut() else {
return;
};
if buffer.events.is_empty() {
return;
}
let zone_id = player_zone_id(&player_query, zone_map.as_deref());
for event in &mut buffer.events {
let speaker_entity = registry.to_entity(&StableId(event.speaker_id));
let Some(entity) = speaker_entity else {
continue;
};
let Ok((voice_profile, tell_state_opt)) = npc_voice_query.get(entity) else {
continue;
};
let tell_state = tell_state_opt.and_then(|t| t.category);
// Conversation lines don't have a stable line_id; derive content_index
// from the occluded line text. The full cache key is
// (culture_id, npc_stable_id, content_type, content_index, tell_state),
// so a u16 hash collision requires two different lines from the same
// speaker with the same tell state to hash identically — at ~65K
// possible values this is extremely rare, and the worst outcome is
// a stale voiced line being served instead of base text. Acceptable.
let content_index = content_index_from_line_id(&event.occluded_line);
let voiced = voiced_behavior(
&voice_cache.cache,
zone_id,
&voice_profile.culture_id,
event.speaker_id,
ContentType::Dialogue,
content_index,
tell_state,
&event.occluded_line,
false,
);
event.occluded_line = voiced;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
+2 -2
View File
@@ -63,8 +63,6 @@ fn snapshot_roundtrip_over_unix_socket() {
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
character_pressure: None,
rng_seed: None,
@@ -78,6 +76,8 @@ fn snapshot_roundtrip_over_unix_socket() {
sim_errors: vec![],
current_ticker: None,
settings_response: None,
economy_snapshot: None,
bookmark_catalog: None,
};
bridge
+2 -2
View File
@@ -49,8 +49,6 @@ fn snapshot_roundtrip_over_tcp() {
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
character_pressure: None,
rng_seed: None,
@@ -64,6 +62,8 @@ fn snapshot_roundtrip_over_tcp() {
sim_errors: vec![],
current_ticker: None,
settings_response: None,
economy_snapshot: None,
bookmark_catalog: None,
};
bridge
+2 -2
View File
@@ -288,8 +288,6 @@ fn snapshot_with_sim_errors_roundtrips() {
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
character_pressure: None,
rng_seed: None,
@@ -307,6 +305,8 @@ fn snapshot_with_sim_errors_roundtrips() {
}],
current_ticker: None,
settings_response: None,
economy_snapshot: None,
bookmark_catalog: None,
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
+36 -6
View File
@@ -39,8 +39,6 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
character_pressure: None,
rng_seed: None,
@@ -54,6 +52,8 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
sim_errors: vec![],
current_ticker: None,
settings_response: None,
economy_snapshot: None,
bookmark_catalog: None,
}
}
@@ -236,8 +236,6 @@ fn generate_msgpack_fixtures() {
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
character_pressure: None,
rng_seed: None,
@@ -251,6 +249,8 @@ fn generate_msgpack_fixtures() {
sim_errors: vec![],
current_ticker: None,
settings_response: None,
economy_snapshot: None,
bookmark_catalog: None,
};
write_fixture(
"snapshot_v2_full",
@@ -375,8 +375,6 @@ fn generate_msgpack_fixtures() {
blocked_entities: vec![5, 6],
scan_events: vec![],
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
character_pressure: None,
rng_seed: Some(0xDEADBEEF),
@@ -418,6 +416,8 @@ fn generate_msgpack_fixtures() {
sim_errors: vec![],
current_ticker: None,
settings_response: None,
economy_snapshot: None,
bookmark_catalog: None,
};
write_fixture(
"snapshot_full",
@@ -502,3 +502,33 @@ fn generate_msgpack_fixtures() {
write_fixture(name, &rmp_serde::to_vec_named(&snapshot).unwrap());
}
}
/// Generate a snapshot fixture with a populated `BookmarkCatalog`.
///
/// Client (#618) uses this to validate GDScript MessagePack decode against
/// real `rmp_serde` output — field order and string encoding may diverge
/// from GDScript-constructed data.
#[test]
#[ignore] // Run manually: cargo test --test gen_fixtures -- --ignored
fn generate_snapshot_with_bookmark_catalog() {
let catalog = BookmarkCatalog {
bookmarks: vec![BookmarkWire {
id: "tycoon".into(),
title: "Tycoon".into(),
subtitle: "Small business owner on the make.".into(),
flavor: String::new(),
default_location: "GJ 35".into(),
allowed_locations: vec!["GJ 35".into()],
allowed_locations_cultures: vec!["frontier_industrial".into()],
career: CareerKindWire::Tycoon,
starting_capital_tractus: 5_000,
}],
};
let mut snapshot = fixture_snapshot(0, vec![]);
snapshot.bookmark_catalog = Some(catalog);
let bytes =
rmp_serde::to_vec_named(&snapshot).expect("serialize snapshot_with_bookmark_catalog");
write_fixture("snapshot_with_bookmark_catalog", &bytes);
}
@@ -5,8 +5,6 @@
3
],
"character_pressure": null,
"conversation_ended": [],
"conversation_events": [],
"current_monologue": null,
"dialogue_response": null,
"entities": [
+7 -8
View File
@@ -27,8 +27,6 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
character_pressure: None,
rng_seed: None,
@@ -42,6 +40,8 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
sim_errors: vec![],
current_ticker: None,
settings_response: None,
economy_snapshot: None,
bookmark_catalog: None,
}
}
@@ -291,8 +291,6 @@ fn snapshot_v2_fields_roundtrip() {
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
character_pressure: None,
rng_seed: None,
@@ -306,6 +304,8 @@ fn snapshot_v2_fields_roundtrip() {
sim_errors: vec![],
current_ticker: None,
settings_response: None,
economy_snapshot: None,
bookmark_catalog: None,
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
@@ -401,8 +401,7 @@ fn all_facing_direction_variants_roundtrip() {
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
character_pressure: None,
rng_seed: None,
@@ -416,6 +415,8 @@ fn all_facing_direction_variants_roundtrip() {
sim_errors: vec![],
current_ticker: None,
settings_response: None,
economy_snapshot: None,
bookmark_catalog: None,
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
@@ -1450,8 +1451,6 @@ fn serde_default_fields_fill_in_when_missing_from_wire() {
"blocked_entities": [],
"scan_events": [],
"sound_events": [],
"conversation_events": [],
"conversation_ended": [],
"follow_state": null,
"rng_seed": null
});
+20 -5
View File
@@ -40,6 +40,7 @@ CHAINS_TOML = REPO_ROOT / "wiki" / "economics" / "production_chains.toml"
SCHEMA_SQL = REPO_ROOT / "server" / "data" / "systems-schema.sql"
CORPORATIONS_DIR = REPO_ROOT / "wiki" / "corporations"
BRANDS_TOML = REPO_ROOT / "wiki" / "economics" / "corporations" / "brands.toml"
GENERATED_BRANDS_TOML = REPO_ROOT / "wiki" / "economics" / "corporations" / "generated_brands.toml"
class _ImportAborted(Exception):
@@ -748,22 +749,36 @@ VALID_CURRENCY_DENOMINATIONS = {"tractus", "mark", "mixed", "sol_adjacent"}
VALID_PRICE_TIERS = {"mass", "premium", "luxury", "flagship", "institutional"}
def _load_brand_file(path) -> tuple[list, list]:
"""Load brand_products and brand_inputs from a TOML file. Returns empty lists if missing."""
if not path.exists():
return [], []
with open(path, "rb") as f:
data = tomllib.load(f)
return data.get("brand_products", []), data.get("brand_inputs", [])
def import_brands(
conn: sqlite3.Connection, dry_run: bool
) -> tuple[int, int]:
"""Import brand_products and brand_inputs from wiki/economics/corporations/brands.toml.
"""Import brand_products and brand_inputs from brands.toml and generated_brands.toml.
Hand-authored brands (brands.toml) are imported first; generated brands
(generated_brands.toml, produced by `tooling/generate-brands`) are merged in.
Returns (n_products, n_inputs).
"""
if not BRANDS_TOML.exists():
print(" warning: brands.toml not found — brand layer skipped")
return 0, 0
with open(BRANDS_TOML, "rb") as f:
data = tomllib.load(f)
products_authored, inputs_authored = _load_brand_file(BRANDS_TOML)
products_generated, inputs_generated = _load_brand_file(GENERATED_BRANDS_TOML)
products = data.get("brand_products", [])
inputs = data.get("brand_inputs", [])
if products_generated:
print(f" merging {len(products_generated)} generated brand_products from generated_brands.toml")
products = products_authored + products_generated
inputs = inputs_authored + inputs_generated
product_rows = []
for p in products:
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Generate minor brand products for the Settled Reach economy.
#
# Usage:
# tooling/generate-brands
# tooling/generate-brands --seed 42 --min-brands 10000
# tooling/generate-brands --output wiki/economics/corporations/generated_brands.toml
#
# Builds on first run if binary doesn't exist.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BIN="$ROOT_DIR/server/target/debug/generate_brands"
# Build if needed
if [ ! -f "$BIN" ]; then
echo "Building generate_brands..." >&2
(cd "$ROOT_DIR/server" && cargo build --bin generate_brands 2>&1 | tail -3) >&2
fi
exec "$BIN" "$@"