diff --git a/.gitignore b/.gitignore index 8128fc939..0c363ef0c 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Makefile b/Makefile index 139f1dc71..8251587d6 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/decisions/perception.md b/decisions/perception.md index 1ab7bea96..23cbcfcc6 100644 --- a/decisions/perception.md +++ b/decisions/perception.md @@ -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` 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 diff --git a/docs/architecture/sprint-36-bincode-audit.md b/docs/architecture/sprint-36-bincode-audit.md new file mode 100644 index 000000000..98d50523c --- /dev/null +++ b/docs/architecture/sprint-36-bincode-audit.md @@ -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 5–9): +```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 = 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 = 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::(&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. diff --git a/docs/architecture/sprint-36-bookmark-spec.md b/docs/architecture/sprint-36-bookmark-spec.md new file mode 100644 index 000000000..f36e639e8 --- /dev/null +++ b/docs/architecture/sprint-36-bookmark-spec.md @@ -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. 2–4 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, + + /// 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, +} + +impl BookmarkRegistry { + pub fn get(&self, id: &str) -> Option<&BookmarkDefinition> { ... } + pub fn available(&self) -> impl Iterator { ... } + 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, + 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, + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BookmarkCatalog { + pub bookmarks: Vec, +} +``` + +**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, + pub starting_location_id: Option, +} +``` + +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 #` +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: "".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 `` 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). diff --git a/docs/architecture/sprint-36-culture-api-spec.md b/docs/architecture/sprint-36-culture-api-spec.md new file mode 100644 index 000000000..7ba8019fc --- /dev/null +++ b/docs/architecture/sprint-36-culture-api-spec.md @@ -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; +``` + +### 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` (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; +} + +/// Bevy Resource wrapper so systems can grab a `Res`. +#[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 +{ + 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, +} +``` + +Rationale: cultures are effectively static data shipped with `systems.db`. Resolving them server-side once and shipping the catalog saves: +- A second IPC round-trip (client picks a location, server tells it the culture). +- Error handling duplication (client would need a "culture lookup failed" path). + +**Trade-off:** catalog payload grows ~one short string per allowed location. For v0.2's single allowed location, the overhead is 8 bytes. Acceptable. + +**Option B (rejected):** dedicated `PlayerAction::ResolveCulture(location_id)` → `ObserverSnapshot.culture_response`. Works fine mechanically; just unnecessary given how static culture data is. + +Stig (client) should plan on reading `allowed_locations_cultures[i]` when the player highlights the `i`-th entry in the picker, then display the culture label inline (e.g. "Van Maanen's Star — south_reach culture"). + +## 10. Implementation plan (for Dudley) + +**Size:** ~0.5–1 day. Pure DB wrapper + tests. + +| Step | File | Work | +|------|------|------| +| 1 | `server/src/knowledge/culture.rs` (new) | Types + `CultureResolver` + `resolve_culture` | +| 2 | `server/src/knowledge/mod.rs` | `pub mod culture;` + re-exports | +| 3 | `server/src/main.rs` (App build) | Open `CultureResolverResource` against `server/data/systems.db` | +| 4 | `server/src/knowledge/fixtures/culture_test.db` | Tiny fixture for unit tests | +| 5 | Tests (§7) | 6 unit tests | +| 6 | Update bookmark catalog (§9 Option A) | Populate `allowed_locations_cultures` by resolving each allowed location at catalog-build time | + +## 11. Open questions + +1. **Scope of `location_id` at the bookmark boundary.** Is it `system_id` ("GJ 35") or a proper name ("Van Maanen's Star")? Convention so far is `system_id`. Confirm with Miri when she signs off on the tycoon default location in the bookmark spec. +2. **`bodies.cultural_corridor` column availability.** Schema has it (`server/data/systems-schema.sql` line 175). But does actual content populate it anywhere that differs from the parent system? If no, the body-level lookup still works — it just always falls back to the parent. Harmless. + +--- + +**Contract status:** Ready for #679 implementation. Client #680 can start against the bookmark catalog once #614 + #679 land the server-side resolution. diff --git a/server/Cargo.lock b/server/Cargo.lock index d15f9473c..b2f78ecbe 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -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", diff --git a/server/Cargo.toml b/server/Cargo.toml index 61cc8cc9f..b9b94759e 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -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" diff --git a/server/audit.toml b/server/audit.toml index cb6504099..b82c06c11 100644 --- a/server/audit.toml +++ b/server/audit.toml @@ -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"] diff --git a/server/content/npc-conversations/overheard.ron b/server/content/npc-conversations/overheard.ron deleted file mode 100644 index f68dac98f..000000000 --- a/server/content/npc-conversations/overheard.ron +++ /dev/null @@ -1,1629 +0,0 @@ -// Overheard Conversations Pool -// -// Schema: server/src/npc/overheard.rs :: OverheardPool (TBD — pending #663) -// Ticket: #664 (copy team, Sprint 27) -// Decision refs: D-078 (occlusion-resilient authoring), D-142 (zone-type architecture), -// D-122 (all NPCs generated), D-139 (behavior primitives model) -// -// FORMAT NOTE: This file introduces a new content pattern — generator-compatible overheard -// conversations parameterized by role pair and zone type. Pending D-record (see Q-WTF -// note at end of decisions/content.md). The format is authored independently of any -// specific NPC instance: role_pair draws from zone-type template role IDs; the generator -// resolves role → NPC at runtime. -// -// HOW THIS WORKS: -// At runtime, the generator finds two NPCs in proximity whose roles match `role_pair` -// and whose zone type matches an entry in `zone_types`. The conversation is assembled -// with per-word occlusion applied based on player distance, ambient noise level, and -// ListeningFocus stance (D-078). Culture modifier can be applied to the assembled text -// at the same layer as behavior primitive assembly. -// -// OCCLUSION-RESILIENT AUTHORING RULES (D-078 — apply to every line authored here): -// 1. Front-load key information — most important word in the first third of the line. -// 2. Short declarative sentences — one idea per turn. -// 3. No pronoun-first openers — first word has highest drop risk under occlusion. -// A dropped pronoun without antecedent is unresolvable. Use role nouns or subjects. -// 4. Each turn is self-contained — a player who hears only one side gets a complete thought. -// -// REGISTERS: -// Work — shift logistics, job gripes, operational notes -// Social — idle chat, personal news, relationship signals -// Gossip — third-party information with player-relevant knowledge payload -// -// RELATIONSHIP CONTEXT: -// Colleague — same-level co-workers -// Acquaintance — recognize each other, not close -// Friend — off-duty warmth, shared history signals -// Superior — one holds authority over the other (speaker_a is superior) -// Subordinate — one defers to the other (speaker_a is subordinate) -// -// KNOWLEDGE PAYLOAD: -// Describes what a player can infer from a fully-heard exchange, or the -// key fragment retained under partial occlusion. None if the exchange is -// social atmosphere with no investigative value. -// -// PRIMITIVES ARE CULTURE-NEUTRAL. No cultural attitudes in action or speech text. - -( - conversations: [ - - // ===================================================================== - // INDUSTRIAL FREIGHT - // ===================================================================== - - ( - id: "ovh_ifr_001", - zone_types: ["industrial_freight"], - role_pair: (a: "dock_worker", b: "dock_worker"), - register: Work, - relationship_context: Colleague, - topic: "equipment_fault", - lines: [ - ( speaker: A, text: "Loading arm three is grinding again. Filed the report last week." ), - ( speaker: B, text: "Maintenance says next cycle. Someone loses a hand before anything moves." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_ifr_002", - zone_types: ["industrial_freight"], - role_pair: (a: "dock_worker", b: "dock_worker"), - register: Work, - relationship_context: Colleague, - topic: "manifest_discrepancy", - lines: [ - ( speaker: A, text: "Weight manifest came in four hundred kilos short again. Third time this month." ), - ( speaker: B, text: "Four hundred kilos doesn't disappear from a calibration error. It goes somewhere." ), - ], - knowledge_payload: Some("Recurring manifest weight discrepancy. Dock workers suspicious of the official explanation. Potential signal of systematic removal."), - ), - - ( - id: "ovh_ifr_003", - zone_types: ["industrial_freight"], - role_pair: (a: "foreman", b: "dock_worker"), - register: Work, - relationship_context: Superior, - topic: "bay_assignment", - lines: [ - ( speaker: A, text: "Bay seven crew is two short tonight. Fill the gap from B-section." ), - ( speaker: B, text: "Bay seven again. Low traffic, less oversight. Funny how that keeps happening." ), - ], - knowledge_payload: Some("Bay seven has been given a reduced crew in a low-oversight slot. The subordinate speaker notes the pattern without directly accusing anyone."), - ), - - ( - id: "ovh_ifr_004", - zone_types: ["industrial_freight"], - role_pair: (a: "technician", b: "technician"), - register: Work, - relationship_context: Colleague, - topic: "camera_anomaly", - lines: [ - ( speaker: A, text: "Junction camera at C-corridor was repositioned. Nobody filed a maintenance request." ), - ( speaker: B, text: "Camera moved without logging is a compliance violation. Someone did it off-book." ), - ], - knowledge_payload: Some("A surveillance camera was moved without documentation. Suggests deliberate manipulation of oversight coverage."), - ), - - ( - id: "ovh_ifr_005", - zone_types: ["industrial_freight"], - role_pair: (a: "dock_worker", b: "dock_worker"), - register: Social, - relationship_context: Friend, - topic: "career_stasis", - lines: [ - ( speaker: A, text: "Transfer application went through. Third time. Got rejected again." ), - ( speaker: B, text: "Some people just belong to a place whether they want to or not." ), - ], - knowledge_payload: None, - ), - - // ===================================================================== - // ENTERTAINMENT HOSPITALITY - // ===================================================================== - - ( - id: "ovh_hos_001", - zone_types: ["entertainment_hospitality"], - role_pair: (a: "hospitality_staff", b: "hospitality_staff"), - register: Social, - relationship_context: Colleague, - topic: "guest_behavior", - lines: [ - ( speaker: A, text: "Room fourteen left every amenity in the bath untouched. For three nights." ), - ( speaker: B, text: "Some guests want the illusion of service, not the thing itself." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_hos_002", - zone_types: ["entertainment_hospitality"], - role_pair: (a: "concierge", b: "security"), - register: Work, - relationship_context: Colleague, - topic: "guest_anomaly", - lines: [ - ( speaker: A, text: "Guest in room nine hasn't left the room since check-in. Thirty-six hours." ), - ( speaker: B, text: "Tray service three times, no housekeeping, no movement. Worth a wellness check." ), - ], - knowledge_payload: Some("A guest has been isolated in their room for an extended period. Security and front desk are beginning to flag this as unusual."), - ), - - ( - id: "ovh_hos_003", - zone_types: ["entertainment_hospitality"], - role_pair: (a: "kitchen_worker", b: "kitchen_worker"), - register: Work, - relationship_context: Colleague, - topic: "supply_chain", - lines: [ - ( speaker: A, text: "Supplier changed the protein spec again. Third reformulation this cycle." ), - ( speaker: B, text: "Changed means cheaper. Guests won't notice until they do, and then they'll blame us." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_hos_004", - zone_types: ["entertainment_hospitality"], - role_pair: (a: "security", b: "security"), - register: Work, - relationship_context: Colleague, - topic: "access_pattern", - lines: [ - ( speaker: A, text: "Service corridor had three badge reads last night. All outside shift hours." ), - ( speaker: B, text: "Off-hours service access should be logged with management. Check if it was." ), - ], - knowledge_payload: Some("Service corridor is being accessed outside normal hours without clear authorization. Pattern may indicate unauthorized activity."), - ), - - // ===================================================================== - // RESIDENTIAL STATION - // ===================================================================== - - ( - id: "ovh_rst_001", - zone_types: ["residential_station"], - role_pair: (a: "resident", b: "resident"), - register: Social, - relationship_context: Acquaintance, - topic: "block_conditions", - lines: [ - ( speaker: A, text: "Air recycler on deck six has been cycling short again. Reported it twice." ), - ( speaker: B, text: "Block admin is backed up. Three units filed the same fault this cycle." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_rst_002", - zone_types: ["residential_station"], - role_pair: (a: "block_admin", b: "maintenance_tech"), - register: Work, - relationship_context: Superior, - topic: "welfare_concern", - lines: [ - ( speaker: A, text: "Unit forty-two hasn't answered welfare checks in four days. Filing it formal." ), - ( speaker: B, text: "Saw lights on two nights ago. Someone's in there. Not answering is a choice." ), - ], - knowledge_payload: Some("A residential unit occupant has been avoiding contact for days. Conflicting information: lights suggest presence, but no response to welfare checks."), - ), - - ( - id: "ovh_rst_003", - zone_types: ["residential_station"], - role_pair: (a: "resident", b: "resident"), - register: Gossip, - relationship_context: Friend, - topic: "unexplained_income", - lines: [ - ( speaker: A, text: "New furniture in unit thirty. Nice furniture. Station dock pay doesn't cover that." ), - ( speaker: B, text: "Extra shift somewhere, or extra something. Nobody asks too closely." ), - ], - knowledge_payload: Some("A block resident has acquired unexplained goods above their apparent income level. Other residents have noticed and are deliberately not inquiring further."), - ), - - // ===================================================================== - // RESIDENTIAL DISPERSED - // ===================================================================== - - ( - id: "ovh_dis_001", - zone_types: ["residential_dispersed"], - role_pair: (a: "supply_runner", b: "homesteader"), - register: Work, - relationship_context: Acquaintance, - topic: "missing_resident", - lines: [ - ( speaker: A, text: "Eastern claim hasn't put out a supply crate in six weeks. Door closed on three runs." ), - ( speaker: B, text: "Six weeks is a long time to be quiet out there. Someone should go properly." ), - ], - knowledge_payload: Some("A dispersed homestead has been unresponsive for six weeks. Supply runner has made three passes without contact. Welfare concern escalating."), - ), - - ( - id: "ovh_dis_002", - zone_types: ["residential_dispersed"], - role_pair: (a: "ranger", b: "settlement_administrator"), - register: Gossip, - relationship_context: Colleague, - topic: "off_grid_activity", - lines: [ - ( speaker: A, text: "New structure went up on the ridge claim. Not on any filed homestead record." ), - ( speaker: B, text: "Unfiled structure means someone doesn't want the location in the regional system." ), - ], - knowledge_payload: Some("An unregistered structure has appeared in a dispersed residential area. Both speakers recognize the deliberate omission from regional records as significant."), - ), - - // ===================================================================== - // RURAL AGRICULTURAL - // ===================================================================== - - ( - id: "ovh_rag_001", - zone_types: ["rural_agricultural"], - role_pair: (a: "farmer", b: "trader"), - register: Work, - relationship_context: Acquaintance, - topic: "supply_shortage", - lines: [ - ( speaker: A, text: "Irrigation fittings haven't come through in two cycles. Runner said the supplier changed terms." ), - ( speaker: B, text: "Supplier changed terms means the price went up or someone else got the contract." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_rag_002", - zone_types: ["rural_agricultural"], - role_pair: (a: "militia", b: "militia"), - register: Gossip, - relationship_context: Colleague, - topic: "visitor_pattern", - lines: [ - ( speaker: A, text: "Same vehicle came through the gate three times this week. Different plates each time." ), - ( speaker: B, text: "Different plates, same vehicle. That's not a coincidence, that's a method." ), - ], - knowledge_payload: Some("A vehicle has visited the settlement three times in one week under different registration plates. Both militia members recognize this as suspicious rather than operational."), - ), - - // ===================================================================== - // COMMERCIAL MARKET - // ===================================================================== - - ( - id: "ovh_cmk_001", - zone_types: ["commercial_market"], - role_pair: (a: "vendor", b: "buyer"), - register: Work, - relationship_context: Acquaintance, - topic: "floor_pricing", - lines: [ - ( speaker: A, text: "Today's floor price is twenty-two. That number isn't going to move." ), - ( speaker: B, text: "Floor prices move when the market is thin. Three empty stalls this morning." ), - ( speaker: A, text: "Market official sets the floor. Take it up with her if you want — she's at the north end." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_cmk_002", - zone_types: ["commercial_market"], - role_pair: (a: "vendor", b: "vendor"), - register: Social, - relationship_context: Colleague, - topic: "slow_market", - lines: [ - ( speaker: A, text: "Third slow morning this week. Same regulars, nothing moving past midday." ), - ( speaker: B, text: "Slow in the morning means busy at close, or it means nothing. Hard to tell which until it's done." ), - ( speaker: A, text: "Two spring stalls already gone. Rent's not patient." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_cmk_003", - zone_types: ["commercial_market"], - role_pair: (a: "market_official", b: "security_warden"), - register: Work, - relationship_context: Colleague, - topic: "stall_encroachment", - lines: [ - ( speaker: A, text: "Stall thirty-eight has pushed past his allocation again. Third week running." ), - ( speaker: B, text: "Third week means the two courtesy notices did nothing." ), - ( speaker: A, text: "Friday close I want it logged formally. Next step is loss of the allocation." ), - ], - knowledge_payload: Some("A stall holder has repeatedly encroached on a neighboring space and ignored two informal warnings. Formal escalation is being prepared."), - ), - - // ===================================================================== - // COMMERCIAL TRANSIT - // ===================================================================== - - ( - id: "ovh_ctx_001", - zone_types: ["commercial_transit"], - role_pair: (a: "service_worker", b: "service_worker"), - register: Work, - relationship_context: Colleague, - topic: "counter_fault", - lines: [ - ( speaker: A, text: "Counter three display has been wrong since the morning changeover. Showing yesterday's pricing." ), - ( speaker: B, text: "Hub maintenance says end of cycle. Same answer they gave yesterday." ), - ( speaker: A, text: "Running off the terminal list in the meantime. Slower, but at least the numbers are right." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_ctx_002", - zone_types: ["commercial_transit"], - role_pair: (a: "concourse_trader", b: "hub_security"), - register: Gossip, - relationship_context: Acquaintance, - topic: "informal_tolerance", - lines: [ - ( speaker: A, text: "Appreciated the slow pass this morning. Gave me the window I needed." ), - ( speaker: B, text: "No slow pass. Same timing as always. Coincidence it worked out for you." ), - ( speaker: A, text: "Of course. Same time tomorrow, then." ), - ], - knowledge_payload: Some("A concourse trader and hub security officer appear to have an informal arrangement around patrol timing. Both maintain plausible deniability."), - ), - - ( - id: "ovh_ctx_003", - zone_types: ["commercial_transit"], - role_pair: (a: "transit_official", b: "concourse_trader"), - register: Work, - relationship_context: Superior, - topic: "permit_lapse", - lines: [ - ( speaker: A, text: "Concourse permit for this section expired two weeks ago. Renewal was filed but not approved." ), - ( speaker: B, text: "Filed it on time. Not my fault the processing window ran long." ), - ( speaker: A, text: "Operating in the gap isn't covered. Close the display today and reapply through the standard channel." ), - ], - knowledge_payload: None, - ), - - // ===================================================================== - // RESIDENTIAL SURFACE - // ===================================================================== - - ( - id: "ovh_rsu_001", - zone_types: ["residential_surface"], - role_pair: (a: "resident", b: "resident"), - register: Social, - relationship_context: Friend, - topic: "neighborhood_change", - lines: [ - ( speaker: A, text: "New building on the corner looks like short-stay units. Six floors where the hardware shop was." ), - ( speaker: B, text: "Short-stay means turnover. Turnover means you stop knowing the names of the people on the street." ), - ( speaker: A, text: "Street's been changing for three years. This is just the part that shows." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_rsu_002", - zone_types: ["residential_surface"], - role_pair: (a: "shopkeeper", b: "municipal_worker"), - register: Work, - relationship_context: Acquaintance, - topic: "infrastructure_delay", - lines: [ - ( speaker: A, text: "Drain in front of the shop backs up every time it rains. Has for two seasons." ), - ( speaker: B, text: "On the works order. Third priority, which means second rain cycle at earliest." ), - ( speaker: A, text: "Two seasons of third priority is a decision, not a delay." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_rsu_003", - zone_types: ["residential_surface"], - role_pair: (a: "security", b: "resident"), - register: Gossip, - relationship_context: Acquaintance, - topic: "newcomer_behavior", - lines: [ - ( speaker: A, text: "New occupant in the end unit watches the street from the window. Watches a lot." ), - ( speaker: B, text: "Watching from a window isn't unusual. What time?" ), - ( speaker: A, text: "Off-peak hours. Early morning, quiet evening. Regular as a schedule." ), - ], - knowledge_payload: Some("A new block resident is observing the street at consistent, off-peak times. The pattern has been noticed by at least two neighbors."), - ), - - // ===================================================================== - // ENTERTAINMENT VENUE - // ===================================================================== - - ( - id: "ovh_evn_001", - zone_types: ["entertainment_venue"], - role_pair: (a: "stage_crew", b: "stage_crew"), - register: Work, - relationship_context: Colleague, - topic: "cue_failure", - lines: [ - ( speaker: A, text: "Fly cue seven ran four seconds early. Deck operator says he got a bad confirm from the headset." ), - ( speaker: B, text: "Four seconds early on that cue and the piece clears the performer by maybe a meter." ), - ( speaker: A, text: "Headset line goes to maintenance tonight. Not using that channel again until it's cleared." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_evn_002", - zone_types: ["entertainment_venue"], - role_pair: (a: "performer", b: "venue_staff"), - register: Social, - relationship_context: Colleague, - topic: "thin_house", - lines: [ - ( speaker: A, text: "House was thin tonight. Could feel it from the second act." ), - ( speaker: B, text: "Thin house doesn't usually show through. Tonight it did a little." ), - ( speaker: A, text: "Management wants to cut the weekday run. Attendance gives them the numbers to do it." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_evn_003", - zone_types: ["entertainment_venue"], - role_pair: (a: "security", b: "stage_crew"), - register: Gossip, - relationship_context: Acquaintance, - topic: "unauthorized_pass", - lines: [ - ( speaker: A, text: "Someone used a side stage pass for the backstage corridor after close. Badge doesn't match anyone on crew." ), - ( speaker: B, text: "Side stage passes come from the venue office. Shouldn't be extras floating around." ), - ( speaker: A, text: "Pulled the log. Access was clean on the system side. Someone signed a pass that wasn't supposed to exist." ), - ], - knowledge_payload: Some("A valid but unaccounted backstage access pass was used after the venue closed. The pass was system-valid but corresponds to no staff member, suggesting it was issued off-book."), - ), - - // ===================================================================== - // MEDICAL FACILITY - // ===================================================================== - - ( - id: "ovh_med_001", - zone_types: ["medical_facility"], - role_pair: (a: "medic", b: "medic"), - register: Work, - relationship_context: Colleague, - topic: "supply_shortage", - lines: [ - ( speaker: A, text: "Wound closure stock is down to two days. Supply request went in three days ago." ), - ( speaker: B, text: "Central depot has been running short on three lines this cycle. Not just us waiting." ), - ( speaker: A, text: "Administrator needs to know before tomorrow morning. Short supply changes the procedure list." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_med_002", - zone_types: ["medical_facility"], - role_pair: (a: "physician", b: "administrator"), - register: Work, - relationship_context: Superior, - topic: "capacity_reclassification", - lines: [ - ( speaker: A, text: "Overflow bay has been in use for six days straight. That's not an overflow bay anymore, that's a ward." ), - ( speaker: B, text: "Formal ward designation requires a staffing reclassification. That's a budget line I don't have." ), - ( speaker: A, text: "Running a ward without calling it one doesn't change what it costs to staff it safely." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_med_003", - zone_types: ["medical_facility"], - role_pair: (a: "administrator", b: "medic"), - register: Work, - relationship_context: Colleague, - topic: "intake_cluster", - lines: [ - ( speaker: A, text: "Four admissions this week with the same presentation. Facility across the district has seen three more." ), - ( speaker: B, text: "Seven with the same presentation in one week is a cluster. Someone should be notifying the health authority." ), - ( speaker: A, text: "Notification went up this morning. Waiting on the response." ), - ], - knowledge_payload: Some("Multiple facilities are seeing an unusual cluster of patients with identical presentations. A health authority notification has been filed."), - ), - - // ===================================================================== - // RESEARCH STATION - // ===================================================================== - - ( - id: "ovh_rsc_001", - zone_types: ["research_station"], - role_pair: (a: "researcher", b: "researcher"), - register: Work, - relationship_context: Colleague, - topic: "data_anomaly", - lines: [ - ( speaker: A, text: "Third run shows the same outlier at the seventeen-minute mark. Setup was recalibrated between each run." ), - ( speaker: B, text: "Consistent anomaly across three calibrated runs is a result, not noise." ), - ( speaker: A, text: "Section lead needs to see this before we log it. Changes what the project is looking at." ), - ], - knowledge_payload: Some("A research team has observed a consistent anomaly across multiple controlled runs and is treating it as a genuine finding, escalating before formal logging."), - ), - - ( - id: "ovh_rsc_002", - zone_types: ["research_station"], - role_pair: (a: "technician", b: "researcher"), - register: Work, - relationship_context: Colleague, - topic: "equipment_outage", - lines: [ - ( speaker: A, text: "Primary spectrometer is offline until Thursday. Bearing failure on the carousel drive." ), - ( speaker: B, text: "Thursday means two runs postponed. Sequence has to restart if there's a gap that long." ), - ( speaker: A, text: "Secondary unit is available, but throughput is half. Your call on the sequencing." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_rsc_003", - zone_types: ["research_station"], - role_pair: (a: "security", b: "technician"), - register: Gossip, - relationship_context: Acquaintance, - topic: "off_hours_access", - lines: [ - ( speaker: A, text: "Clean room had a badge read at zero-three-hundred this cycle. Not on any scheduled access." ), - ( speaker: B, text: "Zero-three-hundred access should be logged with a reason code. Was there one?" ), - ( speaker: A, text: "Badge was valid, clearance level correct, reason field empty. That's the part I'm looking at." ), - ], - knowledge_payload: Some("The research station clean room was accessed in the early hours without a logged reason. Credentials were valid, suggesting an authorized person went off-book."), - ), - - // ===================================================================== - // PORT SPACE - // ===================================================================== - - ( - id: "ovh_psp_001", - zone_types: ["port_space"], - role_pair: (a: "docking_handler", b: "docking_handler"), - register: Work, - relationship_context: Colleague, - topic: "berth_queue", - lines: [ - ( speaker: A, text: "Berth queue is backed up to fourteen. Inbound traffic ran a full cycle ahead of the cleared departures." ), - ( speaker: B, text: "Fourteen in queue with the east bay still in maintenance is a problem, not a delay." ), - ( speaker: A, text: "Port authority has the numbers. Waiting on the schedule adjustment from their end." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_psp_002", - zone_types: ["port_space"], - role_pair: (a: "customs_officer", b: "cargo_broker"), - register: Work, - relationship_context: Acquaintance, - topic: "manifest_hold", - lines: [ - ( speaker: A, text: "Container four-seven-two is held. Manifest weight doesn't match the transit declaration." ), - ( speaker: B, text: "Discrepancy size?" ), - ( speaker: A, text: "Eighty kilos over. Large enough that it isn't a scale error." ), - ], - knowledge_payload: Some("A cargo container has been held due to an 80kg weight discrepancy between the manifest and transit declaration — too large to be instrument error."), - ), - - ( - id: "ovh_psp_003", - zone_types: ["port_space"], - role_pair: (a: "port_security", b: "customs_officer"), - register: Gossip, - relationship_context: Colleague, - topic: "loitering_crew", - lines: [ - ( speaker: A, text: "Crew member off the Vellander Star has been in the transit lounge for six hours. No onward booking." ), - ( speaker: B, text: "Vellander Star was the vessel with the held container?" ), - ( speaker: A, text: "Same vessel. Started watching the inspection lanes about an hour ago." ), - ], - knowledge_payload: Some("A crew member from the vessel connected to a customs hold is lingering in the transit lounge and monitoring inspection activity. Both security and customs have noted the behavior."), - ), - - // ===================================================================== - // PORT MARITIME - // ===================================================================== - - ( - id: "ovh_pmt_001", - zone_types: ["port_maritime"], - role_pair: (a: "dock_crew", b: "dock_crew"), - register: Work, - relationship_context: Colleague, - topic: "weather_window", - lines: [ - ( speaker: A, text: "Weather window closes in two hours. Harbour master extended the morning run by one vessel." ), - ( speaker: B, text: "One extra means working the east crane through the wind pickup. Not ideal." ), - ( speaker: A, text: "Harbour master knows. Sent word down himself rather than leaving it in the log." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_pmt_002", - zone_types: ["port_maritime"], - role_pair: (a: "harbour_master", b: "chandler"), - register: Work, - relationship_context: Superior, - topic: "undeclared_vessel", - lines: [ - ( speaker: A, text: "Vessel berthed at pier seven overnight with no advance notice. Not in the harbour log at all." ), - ( speaker: B, text: "Berthed clean? No damage on the dock side?" ), - ( speaker: A, text: "Clean berth, experienced handling. Someone knew this port well. No declaration is still a violation." ), - ], - knowledge_payload: Some("A vessel berthed overnight without advance notice or harbour declaration. The competent handling suggests prior familiarity with the port, making the omission more deliberate."), - ), - - ( - id: "ovh_pmt_003", - zone_types: ["port_maritime"], - role_pair: (a: "dock_crew", b: "maritime_security"), - register: Social, - relationship_context: Acquaintance, - topic: "end_of_season", - lines: [ - ( speaker: A, text: "Last bulk cargo run of the season clears tomorrow. Quay goes quiet after that." ), - ( speaker: B, text: "Quiet means different work, not less. Maintenance backlog covers the slow months." ), - ( speaker: A, text: "Still. Quiet is better than three cranes running and someone always short." ), - ], - knowledge_payload: None, - ), - - // ===================================================================== - // PORT FISHING - // ===================================================================== - - ( - id: "ovh_pfh_001", - zone_types: ["port_fishing"], - role_pair: (a: "fisher", b: "fisher"), - register: Work, - relationship_context: Colleague, - topic: "light_catch", - lines: [ - ( speaker: A, text: "Northern run came back light again. Third time in four trips." ), - ( speaker: B, text: "Northern run light three times means something changed out there. Current shift, or something else." ), - ( speaker: A, text: "Old Tarassa charts a different line now. Hard to argue with the results." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_pfh_002", - zone_types: ["port_fishing"], - role_pair: (a: "gear_rigger", b: "fisher"), - register: Work, - relationship_context: Colleague, - topic: "untested_gear", - lines: [ - ( speaker: A, text: "Vedara net should not have gone out this morning. Splice on the port rail line wasn't tested." ), - ( speaker: B, text: "Splice looked solid when we loaded." ), - ( speaker: A, text: "Looking solid and being tested are two different things. Bring it back to me before the next run." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_pfh_003", - zone_types: ["port_fishing"], - role_pair: (a: "port_buyer", b: "fisher"), - register: Work, - relationship_context: Superior, - topic: "grade_dispute", - lines: [ - ( speaker: A, text: "Thirty percent of this haul is below grade two. Buyer's scale, not my judgment." ), - ( speaker: B, text: "Conditions were rough. Grade two fish take damage coming over the stern in that weather." ), - ( speaker: A, text: "Weather affects the price. Certified scale sets the grade. Both go in the record." ), - ], - knowledge_payload: None, - ), - - // ===================================================================== - // PORT SURFACE - // ===================================================================== - - ( - id: "ovh_psu_001", - zone_types: ["port_surface"], - role_pair: (a: "ground_handler", b: "ground_handler"), - register: Work, - relationship_context: Colleague, - topic: "weather_hold", - lines: [ - ( speaker: A, text: "Pad three is on weather hold. Wind speed over the limit for the inbound class." ), - ( speaker: B, text: "How long before the hold lifts?" ), - ( speaker: A, text: "Control says two hours, maybe three. Departure queue is going to back up behind this one." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_psu_002", - zone_types: ["port_surface"], - role_pair: (a: "customs_agent", b: "freight_dispatcher"), - register: Work, - relationship_context: Acquaintance, - topic: "secondary_review_pattern", - lines: [ - ( speaker: A, text: "Cargo from pad nine is under secondary review again. Won't clear until tomorrow morning." ), - ( speaker: B, text: "Tomorrow morning means the surface connection misses. Third delay this month from pad nine." ), - ( speaker: A, text: "Delays from secondary review aren't random. Something in that pad nine traffic is drawing attention." ), - ], - knowledge_payload: Some("Freight from pad nine is being repeatedly flagged for secondary customs review. A customs agent considers the pattern non-coincidental."), - ), - - ( - id: "ovh_psu_003", - zone_types: ["port_surface"], - role_pair: (a: "terminal_security", b: "customs_agent"), - register: Gossip, - relationship_context: Colleague, - topic: "repeat_traveler", - lines: [ - ( speaker: A, text: "Same transit traveler, third time through in eight days. Different destination declared each time." ), - ( speaker: B, text: "Different declared destination and no onward boarding record on the other side?" ), - ( speaker: A, text: "No record I can find. Arrivals here, yes. Departures from the stated destinations, nothing." ), - ], - knowledge_payload: Some("A traveler has passed through the terminal three times in eight days with a different declared destination each visit, and no verifiable onward travel from any of them."), - ), - - // ===================================================================== - // RURAL ORBITAL - // ===================================================================== - - ( - id: "ovh_ror_001", - zone_types: ["rural_orbital"], - role_pair: (a: "systems_maintainer", b: "habitat_farmer"), - register: Work, - relationship_context: Colleague, - topic: "pressure_variance", - lines: [ - ( speaker: A, text: "Grow section air loop flagged a pressure variance overnight. Not an alert yet, but heading there." ), - ( speaker: B, text: "How much variance?" ), - ( speaker: A, text: "Point-four below baseline. Small, but it's moved the same direction for three cycles." ), - ], - knowledge_payload: Some("The grow section air circulation system shows a slow but consistent pressure drop — below the alert threshold but trending in a concerning direction."), - ), - - ( - id: "ovh_ror_002", - zone_types: ["rural_orbital"], - role_pair: (a: "supply_coordinator", b: "watch_officer"), - register: Work, - relationship_context: Colleague, - topic: "resupply_gap", - lines: [ - ( speaker: A, text: "Resupply window was delayed by eight days. Current consumables run to day forty. Gap is twenty-two days." ), - ( speaker: B, text: "Twenty-two day gap means rationing or a contingency request to the transit authority." ), - ( speaker: A, text: "Contingency request is already filed. Waiting on a confirmation window." ), - ], - knowledge_payload: Some("A resupply delay has opened a potential 22-day supply gap on a rural orbital habitat. A contingency request has been submitted but not yet confirmed."), - ), - - ( - id: "ovh_ror_003", - zone_types: ["rural_orbital"], - role_pair: (a: "habitat_farmer", b: "systems_maintainer"), - register: Social, - relationship_context: Friend, - topic: "long_rotation", - lines: [ - ( speaker: A, text: "Seven months since the last rotation. Sometimes I have to think hard about what the surface smells like." ), - ( speaker: B, text: "Station smell gets into everything after a while. You stop noticing, which is the problem." ), - ( speaker: A, text: "Next rotation I'm going to stand outside in actual weather. Just stand there." ), - ], - knowledge_payload: None, - ), - - // ===================================================================== - // RURAL AQUACULTURE - // ===================================================================== - - ( - id: "ovh_raq_001", - zone_types: ["rural_aquaculture"], - role_pair: (a: "aquaculturist", b: "system_tech"), - register: Work, - relationship_context: Colleague, - topic: "tank_chemistry", - lines: [ - ( speaker: A, text: "Tank seven pH dropped point-three since yesterday. Feed rate hasn't changed." ), - ( speaker: B, text: "pH drop without a feed change points to the buffer system. Aerator or a slow line leak." ), - ( speaker: A, text: "Pulled the morning logs. Dissolved oxygen was normal, so the aerator's probably not the cause." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_raq_002", - zone_types: ["rural_aquaculture"], - role_pair: (a: "inspector", b: "aquaculturist"), - register: Work, - relationship_context: Superior, - topic: "records_gap", - lines: [ - ( speaker: A, text: "Tank three batch records show a gap from the fourteenth to the sixteenth. Two days unlogged." ), - ( speaker: B, text: "Water sensor went offline. Logged the fault but forgot to carry the manual readings across." ), - ( speaker: A, text: "Fault log covers the sensor outage, not the records gap. Records need completing before I sign off." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_raq_003", - zone_types: ["rural_aquaculture"], - role_pair: (a: "aquaculturist", b: "aquaculturist"), - register: Social, - relationship_context: Friend, - topic: "unexplained_stock_loss", - lines: [ - ( speaker: A, text: "North pen is down eighteen percent from last week's count. Water tests are clean." ), - ( speaker: B, text: "Clean water and an eighteen percent drop means the net, or something that got past the net." ), - ( speaker: A, text: "Net was checked two days ago. Whatever it is, we didn't see it coming." ), - ], - knowledge_payload: Some("An aquaculture pen has lost 18% of its stock with no apparent water quality explanation. The net was recently inspected and the cause remains unknown."), - ), - - // ===================================================================== - // RURAL PASTORAL - // ===================================================================== - - ( - id: "ovh_rps_001", - zone_types: ["rural_pastoral"], - role_pair: (a: "herder", b: "herder"), - register: Work, - relationship_context: Colleague, - topic: "missing_animals", - lines: [ - ( speaker: A, text: "Count came back short by two from the north paddock. Same section three nights running." ), - ( speaker: B, text: "Same section three nights means the fence, or something that knows the fence." ), - ( speaker: A, text: "Warden is checking the outer wire tonight. Taking the inside section myself." ), - ], - knowledge_payload: Some("Two animals have gone missing from the same paddock section on three consecutive nights, suggesting either a fence breach or a predator familiar with the layout."), - ), - - ( - id: "ovh_rps_002", - zone_types: ["rural_pastoral"], - role_pair: (a: "veterinarian", b: "herder"), - register: Work, - relationship_context: Superior, - topic: "disease_cluster", - lines: [ - ( speaker: A, text: "Third animal this week with the same respiratory presentation. None of them have been in contact with each other." ), - ( speaker: B, text: "Not in contact means it's not passing animal to animal." ), - ( speaker: A, text: "Could be environmental. Quarantining all three and filing a district referral today." ), - ], - knowledge_payload: Some("Three animals have developed identical symptoms without direct contact, suggesting an environmental rather than contagious cause. A district referral has been filed."), - ), - - ( - id: "ovh_rps_003", - zone_types: ["rural_pastoral"], - role_pair: (a: "warden", b: "trader"), - register: Gossip, - relationship_context: Acquaintance, - topic: "perimeter_vehicle", - lines: [ - ( speaker: A, text: "Unmarked transport was parked at the southeast gate access track two nights this week." ), - ( speaker: B, text: "Southeast gate is on the boundary road. Could be transiting, stopped for a rest." ), - ( speaker: A, text: "Two nights, no lights, no movement. Not transiting — waiting." ), - ], - knowledge_payload: Some("An unmarked vehicle has been stationary near a pastoral perimeter gate for two nights without lights or movement. The warden considers the pattern deliberate."), - ), - - // ===================================================================== - // ADMINISTRATIVE CIVIL - // ===================================================================== - - ( - id: "ovh_acv_001", - zone_types: ["administrative_civil"], - role_pair: (a: "clerk", b: "clerk"), - register: Work, - relationship_context: Colleague, - topic: "flagged_application", - lines: [ - ( speaker: A, text: "Application forty-seven has a secondary ID that doesn't match the address on the registration." ), - ( speaker: B, text: "Mismatch between ID and registration is a flag, not a rejection. Refer it up." ), - ( speaker: A, text: "Already in the administrator's tray. Noting it in case the applicant comes back to the window." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_acv_002", - zone_types: ["administrative_civil"], - role_pair: (a: "administrator", b: "clerk"), - register: Work, - relationship_context: Superior, - topic: "queue_backlog", - lines: [ - ( speaker: A, text: "Queue is running forty minutes behind again. Third morning this week." ), - ( speaker: B, text: "Morning intake has been heavier than forecast. Two clerks pulled to records this morning." ), - ( speaker: A, text: "Records pulls happen before window open, not during. Adjust the schedule from tomorrow." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_acv_003", - zone_types: ["administrative_civil"], - role_pair: (a: "inspector", b: "security"), - register: Gossip, - relationship_context: Acquaintance, - topic: "property_queries", - lines: [ - ( speaker: A, text: "Records hall had four separate permit history queries on the same property last cycle." ), - ( speaker: B, text: "Four queries on one property from the same office isn't standard procedure." ), - ( speaker: A, text: "Queries came from different terminals. Different users, same property code." ), - ], - knowledge_payload: Some("A single property has been queried four times through different terminals in one cycle. The pattern is unusual and has been noted by both inspection and security staff."), - ), - - // ===================================================================== - // ADMINISTRATIVE JUDICIAL - // ===================================================================== - - ( - id: "ovh_adj_001", - zone_types: ["administrative_judicial"], - role_pair: (a: "advocate", b: "clerk"), - register: Work, - relationship_context: Acquaintance, - topic: "transcript_discrepancy", - lines: [ - ( speaker: A, text: "Session record from the fourth has two lines out of sequence. Affects the admissibility timeline." ), - ( speaker: B, text: "Clerk error or transcription gap?" ), - ( speaker: A, text: "Transcription gap. Timestamp on exhibit three was entered after the exhibit was referenced, not before." ), - ], - knowledge_payload: Some("A session transcript contains a timing discrepancy that could affect evidence admissibility. An advocate has flagged it to the court clerk."), - ), - - ( - id: "ovh_adj_002", - zone_types: ["administrative_judicial"], - role_pair: (a: "clerk", b: "clerk"), - register: Work, - relationship_context: Colleague, - topic: "scheduling_conflict", - lines: [ - ( speaker: A, text: "Chamber two has two hearings marked against the same afternoon window. Both listed as firm." ), - ( speaker: B, text: "Both firm means one of the advocates hasn't been told yet." ), - ( speaker: A, text: "Flagging it to the adjudicator's office before the morning list goes up." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_adj_003", - zone_types: ["administrative_judicial"], - role_pair: (a: "bailiff", b: "advocate"), - register: Social, - relationship_context: Acquaintance, - topic: "unexpected_ruling", - lines: [ - ( speaker: A, text: "Ruling came out different from what the corridor was expecting." ), - ( speaker: B, text: "Corridor consensus isn't the record. Adjudicator reads the record." ), - ( speaker: A, text: "True. Still surprised a few people who should have read it more carefully." ), - ], - knowledge_payload: None, - ), - - // ===================================================================== - // SECURITY CHECKPOINT - // ===================================================================== - - ( - id: "ovh_sch_001", - zone_types: ["security_checkpoint"], - role_pair: (a: "supervisor", b: "officer"), - register: Work, - relationship_context: Superior, - topic: "unofficial_bypass", - lines: [ - ( speaker: A, text: "Sareth party comes through at seventeen hundred. Wave them without a full scan." ), - ( speaker: B, text: "Standing exception or one-time?" ), - ( speaker: A, text: "Standing. No logging." ), - ( speaker: B, text: "Log requires a reason code. What goes in the field?" ), - ( speaker: A, text: "Transit facilitation. Standard language. Don't make it complicated." ), - ], - knowledge_payload: Some("Certain parties receive undocumented checkpoint bypasses on verbal instruction from a supervisor. The officer is attempting to create a paper trail, which the supervisor is suppressing."), - ), - - ( - id: "ovh_sch_002", - zone_types: ["security_checkpoint"], - role_pair: (a: "inspector", b: "inspector"), - register: Work, - relationship_context: Colleague, - topic: "cargo_cleared_without_signoff", - lines: [ - ( speaker: A, text: "Bay three cargo from last night. Flagged for density, then cleared. No inspector sign-off in the log." ), - ( speaker: B, text: "Cleared by whom?" ), - ( speaker: A, text: "System override. Supervisor code." ), - ( speaker: B, text: "Supervisor code on a density flag should go upstairs. Did it?" ), - ( speaker: A, text: "Log says cleared. That's all the log says." ), - ], - knowledge_payload: Some("Flagged cargo was cleared via supervisor override without following escalation protocol or leaving an inspector signature. The documentation gap appears deliberate."), - ), - - ( - id: "ovh_sch_003", - zone_types: ["security_checkpoint"], - role_pair: (a: "technician", b: "officer"), - register: Social, - relationship_context: Acquaintance, - topic: "persistent_camera_gap", - lines: [ - ( speaker: A, text: "C-wing scanner is back from calibration. Coverage is fine." ), - ( speaker: B, text: "Gap between C-wing and the freight bay access is still there." ), - ( speaker: A, text: "That gap has been there since the refurbishment. No work order ever came through to close it." ), - ( speaker: B, text: "Three years. Someone would have noticed." ), - ( speaker: A, text: "Someone did notice. Still no work order." ), - ], - knowledge_payload: Some("A persistent camera blind spot between two sensitive checkpoint areas has never had a repair order filed, suggesting deliberate preservation of the coverage gap."), - ), - - // ===================================================================== - // MILITARY GARRISON - // ===================================================================== - - ( - id: "ovh_mga_001", - zone_types: ["military_garrison"], - role_pair: (a: "nco", b: "soldier"), - register: Work, - relationship_context: Superior, - topic: "incident_reclassification", - lines: [ - ( speaker: A, text: "Official log lists that as equipment malfunction. Not a personnel incident." ), - ( speaker: B, text: "Both of us were there." ), - ( speaker: A, text: "And the log says equipment malfunction. Both of us were present for an equipment malfunction." ), - ( speaker: B, text: "Understood." ), - ], - knowledge_payload: Some("An incident witnessed as a personnel event has been officially reclassified as equipment malfunction. The NCO is delivering an implicit instruction to conform to the official account."), - ), - - ( - id: "ovh_mga_002", - zone_types: ["military_garrison"], - role_pair: (a: "soldier", b: "soldier"), - register: Social, - relationship_context: Friend, - topic: "civilian_contact_cutoff", - lines: [ - ( speaker: A, text: "Deployment orders came in. East perimeter extension. Two rotations, no civilian contact window." ), - ( speaker: B, text: "No contact window means no communication with the settlement." ), - ( speaker: A, text: "That's what it says." ), - ( speaker: B, text: "Settlements use that window for resupply complaints. Who handles those now?" ), - ( speaker: A, text: "Someone else, apparently." ), - ], - knowledge_payload: Some("New deployment orders cut soldier contact with nearby civilian settlements, eliminating a communication channel those settlements depend on. Neither soldier knows who replaces that function."), - ), - - ( - id: "ovh_mga_003", - zone_types: ["military_garrison"], - role_pair: (a: "officer", b: "nco"), - register: Gossip, - relationship_context: Colleague, - topic: "supply_diversion", - lines: [ - ( speaker: A, text: "Second quartermaster requisition this month went to the Vensek depot. Not here." ), - ( speaker: B, text: "Vensek is under a different command bracket." ), - ( speaker: A, text: "Same requisition codes. Different delivery address. Filed from this building." ), - ( speaker: B, text: "Someone in this building has access to the requisition system and an address they prefer." ), - ], - knowledge_payload: Some("Garrison supplies are being requisitioned using legitimate codes but delivered to an unrelated depot. The requisitions originate within the garrison, suggesting internal diversion by someone with system access."), - ), - - // ===================================================================== - // DETENTION FACILITY - // ===================================================================== - - ( - id: "ovh_det_001", - zone_types: ["detention_facility"], - role_pair: (a: "guard", b: "guard"), - register: Work, - relationship_context: Colleague, - topic: "undocumented_night_transfer", - lines: [ - ( speaker: A, text: "Cell twelve is empty. Transfer order came in at zero two hundred." ), - ( speaker: B, text: "Middle-of-night transfer with no briefing note." ), - ( speaker: A, text: "Order was signed. Chain-of-custody form completed." ), - ( speaker: B, text: "Signed by whom?" ), - ( speaker: A, text: "Administrator code. She wasn't on shift." ), - ], - knowledge_payload: Some("A prisoner was transferred at 0200 using an administrator's credentials while that administrator was not on shift. No briefing note was left for incoming staff."), - ), - - ( - id: "ovh_det_002", - zone_types: ["detention_facility"], - role_pair: (a: "medic", b: "administrator"), - register: Work, - relationship_context: Colleague, - topic: "altered_intake_records", - lines: [ - ( speaker: A, text: "Three intake examinations this month. Injury dates on the filed forms don't match my examination log." ), - ( speaker: B, text: "Intake forms go through processing before filing." ), - ( speaker: A, text: "Processing shouldn't change a date." ), - ( speaker: B, text: "Flag it formally. I'll forward it up." ), - ( speaker: A, text: "Formal flag went in two weeks ago. Nothing came back." ), - ], - knowledge_payload: Some("Medical intake records are being altered between the medic's examination log and the final filed forms. A prior formal complaint about this pattern was submitted and not acted upon."), - ), - - ( - id: "ovh_det_003", - zone_types: ["detention_facility"], - role_pair: (a: "prisoner", b: "prisoner"), - register: Social, - relationship_context: Friend, - topic: "delayed_release", - lines: [ - ( speaker: A, text: "Merata's case cleared last cycle. Advocate confirmed the date." ), - ( speaker: B, text: "Merata is still in block C." ), - ( speaker: A, text: "Release order didn't come through. No reason in the file." ), - ( speaker: B, text: "Advocate comes Tuesday. That's what they say every week." ), - ], - knowledge_payload: Some("A prisoner whose release was confirmed by their legal advocate has not been released. No official reason has been filed. Other inmates recognize the pattern of recurring unmet release expectations."), - ), - - // ===================================================================== - // DIPLOMATIC ELITE - // ===================================================================== - - ( - id: "ovh_dip_001", - zone_types: ["diplomatic_elite"], - role_pair: (a: "aide", b: "aide"), - register: Social, - relationship_context: Colleague, - topic: "treaty_text_discrepancy", - lines: [ - ( speaker: A, text: "Annex three in the circulated draft is not the annex three from the working session." ), - ( speaker: B, text: "Third paragraph. Resource allocation floor. Changed from binding to advisory." ), - ( speaker: A, text: "Subtle enough to survive without someone checking the working draft." ), - ( speaker: B, text: "Someone is counting on that." ), - ], - knowledge_payload: Some("A treaty annex has been altered between the working session and the circulated draft, changing a binding resource commitment to advisory language. Both aides recognize the change as deliberate."), - ), - - ( - id: "ovh_dip_002", - zone_types: ["diplomatic_elite"], - role_pair: (a: "diplomat", b: "aide"), - register: Work, - relationship_context: Superior, - topic: "unscheduled_bilateral", - lines: [ - ( speaker: A, text: "Ambassador Kell met with the Sova trade attaché separately. No record in the session diary." ), - ( speaker: B, text: "Requesting a courtesy note from the attaché's side would confirm the contact." ), - ( speaker: A, text: "Do that. Quietly. No note from their side is its own answer." ), - ( speaker: B, text: "And if the question surfaces in today's session?" ), - ( speaker: A, text: "Let it surface. Watch how they respond with an audience in the room." ), - ], - knowledge_payload: Some("A senior diplomat held an unscheduled bilateral meeting kept off the official diary. Another delegation is attempting to surface the contact through procedural means during the formal session."), - ), - - ( - id: "ovh_dip_003", - zone_types: ["diplomatic_elite"], - role_pair: (a: "security_detail", b: "security_detail"), - register: Work, - relationship_context: Colleague, - topic: "principal_unescorted_contact", - lines: [ - ( speaker: A, text: "Principal left the building last night at one forty-five. Unescorted." ), - ( speaker: B, text: "Not in either of our logs." ), - ( speaker: A, text: "Twenty-minute absence. Contact outside the schedule." ), - ( speaker: B, text: "File it or hold it?" ), - ( speaker: A, text: "File it. A gap in our log is worse than what's in it." ), - ], - knowledge_payload: Some("A diplomat left the building alone at night for an undocumented contact, bypassing their security detail. Security personnel are debating whether to formally record the incident."), - ), - - // ===================================================================== - // INDUSTRIAL MANUFACTURING - // ===================================================================== - - ( - id: "ovh_mfg_001", - zone_types: ["industrial_manufacturing"], - role_pair: (a: "assembler", b: "assembler"), - register: Social, - relationship_context: Friend, - topic: "overtime_and_family", - lines: [ - ( speaker: A, text: "Third double shift this week. Daughter's recital is tomorrow evening." ), - ( speaker: B, text: "Swap with Krev on bay six. Krev needs the hours." ), - ( speaker: A, text: "Supervisor won't approve a bay swap mid-cycle. Already asked." ), - ( speaker: B, text: "Cover bay four for me Thursday. I'll run yours tomorrow. Off the books." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_mfg_002", - zone_types: ["industrial_manufacturing"], - role_pair: (a: "quality_inspector", b: "line_supervisor"), - register: Work, - relationship_context: Colleague, - topic: "disappearing_rejects", - lines: [ - ( speaker: A, text: "Reject tray from bay four was empty this morning. Forty-eight units were in it yesterday." ), - ( speaker: B, text: "Maintenance must have cleared it." ), - ( speaker: A, text: "Maintenance doesn't clear reject trays. That's not their task." ), - ( speaker: B, text: "Someone moved them. I'll look into it." ), - ( speaker: A, text: "Looking into it after they're back on the line is a different kind of looking." ), - ], - knowledge_payload: Some("Rejected units are disappearing from quality control trays overnight, presumably returning to the production line. The line supervisor is deflecting rather than escalating."), - ), - - ( - id: "ovh_mfg_003", - zone_types: ["industrial_manufacturing"], - role_pair: (a: "maintenance_tech", b: "assembler"), - register: Social, - relationship_context: Friend, - topic: "spec_change_discrepancy", - lines: [ - ( speaker: A, text: "Tolerance spec on the junction housing changed last week. Wider band now." ), - ( speaker: B, text: "Wider band means more variation passes inspection." ), - ( speaker: A, text: "More passes means more gets shipped." ), - ( speaker: B, text: "Changed in the system or on paper?" ), - ( speaker: A, text: "System only. Station placard still shows the old number." ), - ], - knowledge_payload: Some("Product tolerance specs have been widened in the system without updating physical documentation at workstations. Workers know this results in units shipping that would previously have been rejected."), - ), - - // ===================================================================== - // INDUSTRIAL PROCESSING - // ===================================================================== - - ( - id: "ovh_ipr_001", - zone_types: ["industrial_processing"], - role_pair: (a: "safety_officer", b: "process_operator"), - register: Work, - relationship_context: Superior, - topic: "near_miss_reclassified", - lines: [ - ( speaker: A, text: "Last night's pressure spike in line three was logged as within-tolerance variance." ), - ( speaker: B, text: "Called it in myself. Category was changed after I submitted." ), - ( speaker: A, text: "Changed from near-miss to variance. Who signed the change?" ), - ( speaker: B, text: "Shift lead signed it. Change wasn't authorized by me." ), - ( speaker: A, text: "Original call is in my notes. That's a second record now." ), - ], - knowledge_payload: Some("A near-miss event was reclassified as within-tolerance variance by the shift lead after the operator reported it. The safety officer is creating an independent record to preserve the original classification."), - ), - - ( - id: "ovh_ipr_002", - zone_types: ["industrial_processing"], - role_pair: (a: "shift_lead", b: "plant_technician"), - register: Work, - relationship_context: Colleague, - topic: "inspection_cancellation_pattern", - lines: [ - ( speaker: A, text: "External inspection scheduled for the fourteenth. Cancelled the night before." ), - ( speaker: B, text: "Rescheduled or cancelled outright?" ), - ( speaker: A, text: "Cancelled. No new date filed." ), - ( speaker: B, text: "Third cancellation this year." ), - ( speaker: A, text: "Same inspector named on each request. Someone keeps putting her name on it and pulling it." ), - ], - knowledge_payload: Some("Multiple external safety inspections have been cancelled before arrival. The same inspector is named on each cancelled request, suggesting deliberate targeting to prevent a specific inspector from accessing the facility."), - ), - - ( - id: "ovh_ipr_003", - zone_types: ["industrial_processing"], - role_pair: (a: "process_operator", b: "process_operator"), - register: Social, - relationship_context: Friend, - topic: "feedstock_substitution", - lines: [ - ( speaker: A, text: "Incoming feedstock this batch smells different. Not bad. Just different." ), - ( speaker: B, text: "Different supplier?" ), - ( speaker: A, text: "Manifest says same supplier, same grade certification." ), - ( speaker: B, text: "Grade certification is a piece of paper. The smell is what the material actually is." ), - ( speaker: A, text: "Logged it. Supervisor said write it as batch variation, within spec." ), - ], - knowledge_payload: Some("Processing workers have detected a feedstock change inconsistent with official supply documentation. A direct sensory observation was suppressed through official re-categorization."), - ), - - // ===================================================================== - // EXTRACTION SURFACE - // ===================================================================== - - ( - id: "ovh_exs_001", - zone_types: ["extraction_surface"], - role_pair: (a: "surface_miner", b: "surface_miner"), - register: Social, - relationship_context: Friend, - topic: "active_work_in_closed_section", - lines: [ - ( speaker: A, text: "Section F has been closed since month two. Surveyor said exhausted." ), - ( speaker: B, text: "Walked past it yesterday. Face looks fresh. Someone's been working it." ), - ( speaker: A, text: "Fresh face in a closed section means a different crew." ), - ( speaker: B, text: "No second crew on the roster." ), - ( speaker: A, text: "None on our roster." ), - ], - knowledge_payload: Some("A mine section officially classified as exhausted shows signs of recent active excavation. No second crew appears on accessible staffing records, suggesting an undisclosed operation."), - ), - - ( - id: "ovh_exs_002", - zone_types: ["extraction_surface"], - role_pair: (a: "site_surveyor", b: "camp_supervisor"), - register: Work, - relationship_context: Colleague, - topic: "grade_data_interception", - lines: [ - ( speaker: A, text: "Grade data from sections B and D shows the seam running significantly higher than the company estimate." ), - ( speaker: B, text: "Send the raw data to me before it goes to the company rep." ), - ( speaker: A, text: "That's a change from standard procedure." ), - ( speaker: B, text: "Send it to me first." ), - ], - knowledge_payload: Some("Site mineral grade is significantly higher than the company estimate. The supervisor is intercepting survey data before it reaches company representatives, deviating from standard reporting procedure."), - ), - - ( - id: "ovh_exs_003", - zone_types: ["extraction_surface"], - role_pair: (a: "equipment_operator", b: "surface_miner"), - register: Gossip, - relationship_context: Acquaintance, - topic: "question_then_transfer", - lines: [ - ( speaker: A, text: "Darra asked about section F in last week's debrief. Transferred two days later." ), - ( speaker: B, text: "Transfer or just gone?" ), - ( speaker: A, text: "Transfer. Different site, different district." ), - ( speaker: B, text: "Quick, for a miner halfway through a rotation." ), - ( speaker: A, text: "Quick and quiet. No goodbye shift." ), - ], - knowledge_payload: Some("A worker who publicly questioned an operational discrepancy was transferred to a different site within 48 hours. The speed and secrecy of the transfer are noted as unusual by remaining workers."), - ), - - // ===================================================================== - // EXTRACTION SPACE - // ===================================================================== - - ( - id: "ovh_esp_001", - zone_types: ["extraction_space"], - role_pair: (a: "miner", b: "miner"), - register: Social, - relationship_context: Friend, - topic: "rotation_extension", - lines: [ - ( speaker: A, text: "Third rotation extension this contract. Forty days instead of thirty." ), - ( speaker: B, text: "Extension pays the same daily rate." ), - ( speaker: A, text: "Same rate, ten extra days, no option to refuse." ), - ( speaker: B, text: "Operational necessity clause covers it. It's in the contract somewhere." ), - ( speaker: A, text: "Somewhere. Nobody finds it until after they need it." ), - ], - knowledge_payload: Some("Crew rotations are being extended unilaterally under a contract clause for operational necessity. Workers have identified the mechanism but have no practical recourse."), - ), - - ( - id: "ovh_esp_002", - zone_types: ["extraction_space"], - role_pair: (a: "drill_operator", b: "ore_processor"), - register: Work, - relationship_context: Colleague, - topic: "grade_reporting_discrepancy", - lines: [ - ( speaker: A, text: "Batch seven reported to the buyer at 4.2 percent grade." ), - ( speaker: B, text: "Batch seven processed at 6.1 percent. That's what I logged." ), - ( speaker: A, text: "Company transmits to the buyer. Internal logs stay internal." ), - ( speaker: B, text: "Different numbers in two different places." ), - ( speaker: A, text: "One of them goes to someone who pays based on the number." ), - ], - knowledge_payload: Some("The station is reporting lower ore grades to the buyer than actual processed grades. Workers have internal logs showing the discrepancy but no control over what is transmitted externally."), - ), - - ( - id: "ovh_esp_003", - zone_types: ["extraction_space"], - role_pair: (a: "miner", b: "station_chief"), - register: Work, - relationship_context: Subordinate, - topic: "restricted_sector", - lines: [ - ( speaker: A, text: "Bore pattern shows a significant seam running through sector seven. Worth extending the drill window." ), - ( speaker: B, text: "Sector seven is restricted." ), - ( speaker: A, text: "Restricted under what operational category?" ), - ( speaker: B, text: "Above the drilling schedule. Work the assigned sectors." ), - ( speaker: A, text: "Logged the data. Just flagging it." ), - ], - knowledge_payload: Some("A valuable mineral seam extends into a restricted sector. The station chief refuses to explain the restriction's basis. The miner formally logs the discovery, creating a record that the seam was identified."), - ), - - // ===================================================================== - // EXTRACTION PLATFORM - // ===================================================================== - - ( - id: "ovh_epl_001", - zone_types: ["extraction_platform"], - role_pair: (a: "rig_hand", b: "rig_hand"), - register: Social, - relationship_context: Friend, - topic: "rotation_end_plans", - lines: [ - ( speaker: A, text: "Forty-two days left on rotation. Counting every one." ), - ( speaker: B, text: "Last rotation you said the same thing at sixty days." ), - ( speaker: A, text: "Last rotation I re-signed at forty. Not this time." ), - ( speaker: B, text: "Platform pays better than anything dirtside." ), - ( speaker: A, text: "Better pay stops mattering at some point. Took me three rotations to find that point." ), - ], - knowledge_payload: None, - ), - - ( - id: "ovh_epl_002", - zone_types: ["extraction_platform"], - role_pair: (a: "platform_tech", b: "medic"), - register: Work, - relationship_context: Colleague, - topic: "injury_classification_downgrade", - lines: [ - ( speaker: A, text: "Rig hand Tanev had a serious hand injury Tuesday. Saw it at the equipment bay." ), - ( speaker: B, text: "Intake record says minor contusion, right hand." ), - ( speaker: A, text: "Tanev could barely close his fingers. That's not minor." ), - ( speaker: B, text: "Intake note said minor. Filed version says minor." ), - ( speaker: A, text: "Severity classification affects the platform incident rate." ), - ], - knowledge_payload: Some("An injury was classified as minor despite witness evidence and the medic's own assessment indicating greater severity. The downgraded classification improves the platform's safety incident rate metrics."), - ), - - ( - id: "ovh_epl_003", - zone_types: ["extraction_platform"], - role_pair: (a: "rig_hand", b: "platform_manager"), - register: Social, - relationship_context: Acquaintance, - topic: "ownership_change", - lines: [ - ( speaker: A, text: "Platform's parent company changed hands. Heard it from the supply tender crew." ), - ( speaker: B, text: "Operational contract runs through Vasara-Lenn. Same as before." ), - ( speaker: A, text: "Vasara-Lenn was the acquisition." ), - ( speaker: B, text: "Acquired companies keep their contracts. Nothing changes operationally." ), - ( speaker: A, text: "Nothing changes operationally." ), - ], - knowledge_payload: Some("The platform's parent company has changed hands. The manager's response is deliberately reassuring but non-committal. The rig hand's flat repetition of the reassurance signals disbelief."), - ), - - // ===================================================================== - // WILDERNESS FRONTIER - // ===================================================================== - - ( - id: "ovh_wfr_001", - zone_types: ["wilderness_frontier"], - role_pair: (a: "ranger", b: "prospector"), - register: Work, - relationship_context: Acquaintance, - topic: "unauthorized_survey_work", - lines: [ - ( speaker: A, text: "Northwest sector had active survey work last week. No licensed party on my patrol list." ), - ( speaker: B, text: "Northwest is in the claim buffer zone." ), - ( speaker: A, text: "Buffer zones have access restrictions." ), - ( speaker: B, text: "Someone with access, or someone who didn't know about the restrictions." ), - ( speaker: A, text: "Equipment I saw was professional grade. Not someone who didn't know." ), - ], - knowledge_payload: Some("Unauthorized professional survey work is occurring in a claim buffer zone. Equipment quality rules out accidental trespass. The identity of the party is unknown."), - ), - - ( - id: "ovh_wfr_002", - zone_types: ["wilderness_frontier"], - role_pair: (a: "ranger", b: "ranger"), - register: Gossip, - relationship_context: Colleague, - topic: "boundary_marker_moved", - lines: [ - ( speaker: A, text: "Western boundary marker was moved again. Three meters closer to the fence line." ), - ( speaker: B, text: "Moved or corrected?" ), - ( speaker: A, text: "Moved. Original survey pins are still in the ground. Checked." ), - ( speaker: B, text: "Original pins inside the fence, new markers outside. That's not a correction." ), - ( speaker: A, text: "That's an expansion of what's on the protected side." ), - ], - knowledge_payload: Some("Boundary markers near a restricted zone have been moved to increase the area on the protected side of the fence. Original survey pins prove the change was unauthorized rather than a legitimate correction."), - ), - - ( - id: "ovh_wfr_003", - zone_types: ["wilderness_frontier"], - role_pair: (a: "guide", b: "medic"), - register: Social, - relationship_context: Friend, - topic: "unexplained_operator", - lines: [ - ( speaker: A, text: "Client in sector four claimed to be mapping personal claim data. No claim registration in the registry." ), - ( speaker: B, text: "Mapping a place without registering a claim." ), - ( speaker: A, text: "Or documenting something other than ore." ), - ( speaker: B, text: "People come out here for different reasons." ), - ( speaker: A, text: "Most people who want to disappear pick somewhere with fewer patrols." ), - ], - knowledge_payload: Some("An individual in a wilderness sector provided a cover explanation that does not match verifiable records. Both speakers speculate the person may be involved in surveillance or documentation of something sensitive."), - ), - - // ===================================================================== - // ARCHAEOLOGICAL SITE - // ===================================================================== - - ( - id: "ovh_arc_001", - zone_types: ["archaeological_site"], - role_pair: (a: "archaeologist", b: "field_technician"), - register: Work, - relationship_context: Colleague, - topic: "catalog_discrepancy", - lines: [ - ( speaker: A, text: "Grid seven finds went into the central catalog this morning. Six items. Logged nine in the trench." ), - ( speaker: B, text: "Three items between the trench and the catalog." ), - ( speaker: A, text: "That's where they'd be, yes." ), - ( speaker: B, text: "Do we report the discrepancy or check the catalog first?" ), - ( speaker: A, text: "Check the catalog. Then report the discrepancy." ), - ], - knowledge_payload: Some("Artifacts are being lost between field logging and the central catalog. Three items are missing from a single grid section. The archaeologist's measured response suggests this may not be the first occurrence."), - ), - - ( - id: "ovh_arc_002", - zone_types: ["archaeological_site"], - role_pair: (a: "site_security", b: "logistics_coordinator"), - register: Gossip, - relationship_context: Colleague, - topic: "external_access_inquiries", - lines: [ - ( speaker: A, text: "Second group this month requesting access to the eastern trench section. Neither one from the institution." ), - ( speaker: B, text: "External researchers sometimes contact us directly." ), - ( speaker: A, text: "No academic affiliation listed. One vehicle had a portable assay kit." ), - ( speaker: B, text: "Assay kit is mining equipment." ), - ( speaker: A, text: "Someone wants to know what's down there and it isn't for the published record." ), - ], - knowledge_payload: Some("Unknown parties with mining equipment have been making unauthorized access inquiries to the site. Non-academic interest suggests valuable or unusual material may be present."), - ), - - ( - id: "ovh_arc_003", - zone_types: ["archaeological_site"], - role_pair: (a: "archaeologist", b: "site_security"), - register: Social, - relationship_context: Acquaintance, - topic: "significant_find_window", - lines: [ - ( speaker: A, text: "Founder structure in section C is fully intact. First recovered one at this depth." ), - ( speaker: B, text: "First one means the site's value just changed significantly." ), - ( speaker: A, text: "Publication will bring attention." ), - ( speaker: B, text: "Publication or the period before publication." ), - ( speaker: A, text: "The period before publication is the dangerous part. Yes." ), - ], - knowledge_payload: Some("A significant archaeological discovery has substantially increased the site's value. Both speakers recognize that the period before official publication — when the discovery is known but not public — is the window of highest risk."), - ), - - ], -) diff --git a/server/content/npc-conversations/overheard.yaml.deprecated b/server/content/npc-conversations/overheard.yaml.deprecated deleted file mode 100644 index 4de669bbd..000000000 --- a/server/content/npc-conversations/overheard.yaml.deprecated +++ /dev/null @@ -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] diff --git a/server/data/systems.db b/server/data/systems.db index d4ce7eddb..1100ee43d 100644 Binary files a/server/data/systems.db and b/server/data/systems.db differ diff --git a/server/deny.toml b/server/deny.toml new file mode 100644 index 000000000..46dc2059a --- /dev/null +++ b/server/deny.toml @@ -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" diff --git a/server/src/bin/generate_brands/main.rs b/server/src/bin/generate_brands/main.rs new file mode 100644 index 000000000..dd8099cc0 --- /dev/null +++ b/server/src/bin/generate_brands/main.rs @@ -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, + + /// Path to brand_templates.toml + #[arg(long)] + templates: Option, + + /// 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, + 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, + geographic_sector: Option, + currency_zone: Option, + 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, + base_premium_multiplier: f64, + premium_floor: f64, + #[serde(skip_serializing_if = "Option::is_none")] + origin_system: Option, + terroir_locked: bool, + currency_denomination: String, + shadow_viable: bool, + brand_tier: String, + #[serde(skip_serializing_if = "Option::is_none")] + halo_brand_id: Option, +} + +#[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::(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 { + 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>(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 { + 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::() + .split('-') + .filter(|p| !p.is_empty()) + .collect::>() + .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, + inputs_volume: Vec, +} + +fn generate_pair( + rng: &mut ChaCha8Rng, + corp: &Corp, + template_key: &str, + template: &BrandTemplate, + valid_commodities: &BTreeSet, +) -> Option { + 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::() * (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::() * (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 = 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::() * 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, 3–5× higher quantities + let vol_scale = 3.0 + rng.random::() * 2.0; + let inputs_volume: Vec = 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 = BTreeMap::new(); + let mut by_tier: BTreeMap = BTreeMap::new(); + let mut by_corp: BTreeMap = 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 = Vec::new(); + let mut inputs: Vec = Vec::new(); + let mut seen_ids: BTreeSet = 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"); +} diff --git a/server/src/bin/generate_brands/names.rs b/server/src/bin/generate_brands/names.rs new file mode 100644 index 000000000..1489b28f0 --- /dev/null +++ b/server/src/bin/generate_brands/names.rs @@ -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::() < 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); + } + } +} diff --git a/server/src/bookmark/mod.rs b/server/src/bookmark/mod.rs new file mode 100644 index 000000000..23e21b254 --- /dev/null +++ b/server/src/bookmark/mod.rs @@ -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, +} + +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 { + 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 = 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, + pub starting_location_id: Option, +} + +/// 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::() + .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, + mut buffer: ResMut, + culture: Option>, +) { + 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 (2–4 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())); + } +} diff --git a/server/src/bookmark/types.rs b/server/src/bookmark/types.rs new file mode 100644 index 000000000..485a41e72 --- /dev/null +++ b/server/src/bookmark/types.rs @@ -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. 2–4 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, + /// 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, +} diff --git a/server/src/bridge/debug.rs b/server/src/bridge/debug.rs index dd5287cab..ad3cbb4ba 100644 --- a/server/src/bridge/debug.rs +++ b/server/src/bridge/debug.rs @@ -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; diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index b37d5e24d..d26099ccc 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -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), diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index 50b98c623..bbe7c05d2 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -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")); diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index af45e09d2..d5d6a4b2d 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -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, - /// 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, - /// Conversations that ended this tick (#247, D-078). - /// Client dismisses the passive dialogue panel for these pairs. - #[serde(default)] - pub conversation_ended: Vec, /// 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, + /// 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, +} + +/// 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 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, + /// 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, + 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, } /// 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, /// Pending economy snapshot, consumed once by `compute_observer_snapshot` (#822). pub pending_economy_response: Option, + /// Pending bookmark catalog, consumed once by `compute_observer_snapshot` (#614). + pub pending_bookmark_catalog: Option, } #[cfg(test)] diff --git a/server/src/knowledge/culture.rs b/server/src/knowledge/culture.rs new file mode 100644 index 000000000..fb0df84b8 --- /dev/null +++ b/server/src/knowledge/culture.rs @@ -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` mirrors `SettingsStoreResource` — rusqlite `Connection` +/// is `!Sync`. All queries are indexed primary-key lookups: sub-microsecond. +pub struct CultureResolver { + conn: Arc>, +} + +impl CultureResolver { + /// Open a resolver. `SQLITE_OPEN_READ_ONLY` — purely query-side. + pub fn open(path: &Path) -> Result { + 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` 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 { + 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, CultureError> { + let result: rusqlite::Result>> = 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, CultureError> { + let result: rusqlite::Result>> = 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, CultureError> { + let result: rusqlite::Result>> = 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"); + } + } +} diff --git a/server/src/knowledge/events.rs b/server/src/knowledge/events.rs index e131bd6af..a43c56845 100644 --- a/server/src/knowledge/events.rs +++ b/server/src/knowledge/events.rs @@ -185,7 +185,7 @@ pub fn process_knowledge_events( mut queue: ResMut, mut contradiction_queue: ResMut, registry: Res, - 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(); diff --git a/server/src/knowledge/fixtures/culture_test.db b/server/src/knowledge/fixtures/culture_test.db new file mode 100644 index 000000000..b706a688b Binary files /dev/null and b/server/src/knowledge/fixtures/culture_test.db differ diff --git a/server/src/knowledge/mod.rs b/server/src/knowledge/mod.rs index 6bd8a80fb..46bd3eaf1 100644 --- a/server/src/knowledge/mod.rs +++ b/server/src/knowledge/mod.rs @@ -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, diff --git a/server/src/lib.rs b/server/src/lib.rs index 454a502d4..e2706c9cd 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -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; diff --git a/server/src/main.rs b/server/src/main.rs index c386848b6..c07ba57cc 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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 diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index eff81176b..950e2dd89 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -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, }); } diff --git a/server/src/simulation/conversation.rs b/server/src/simulation/conversation.rs deleted file mode 100644 index 4d1048041..000000000 --- a/server/src/simulation/conversation.rs +++ /dev/null @@ -1,1440 +0,0 @@ -//! NPC-to-NPC conversation system (#247, D-078). -//! -//! NPCs in the Active tier who are in proximity (≤3 tiles) and share a social -//! site occasionally enter conversations. Conversations emit Voice SoundEvents -//! and produce ConversationEvents with server-authoritative per-word occlusion -//! for the player's ObserverSnapshot. -//! -//! Per-word occlusion algorithm (D-078): -//! For each word, an independent Bernoulli trial determines whether the player -//! hears it. Drop probability = f(distance, ambient_noise, listening_focus). -//! Words that fail are replaced with "..." in `occluded_line`. -//! All arithmetic uses integer percentages (0-100) for D-010 determinism. - -use bevy_ecs::prelude::*; -use rand::Rng; -use serde::{Deserialize, Serialize}; - -use crate::knowledge::registry::StableEntityId; -use crate::knowledge::types::SoundRange; -use crate::knowledge::EntityRegistry; -use crate::knowledge::KnowledgeGraph; -use crate::npc::Npc; -use crate::simulation::dialogue::DialogueProfile; -use crate::simulation::listening::ListeningFocus; -use crate::simulation::movement::{PlayerCharacter, TilePosition}; -use crate::simulation::rng::SimRng; -use crate::simulation::sound::{SoundEvent, SoundEventEmitter, SoundEventKind}; -use crate::simulation::tier::ActiveSim; -use crate::simulation::time::SimulationTime; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/// Maximum tile distance for two NPCs to start a conversation. -const CONVERSATION_PROXIMITY: u32 = 3; - -/// Minimum conversation duration in ticks (3 game-minutes at 10 ticks/min). -const MIN_DURATION_TICKS: u64 = 30; - -/// Maximum conversation duration in ticks (12 game-minutes). -const MAX_DURATION_TICKS: u64 = 120; - -/// Cooldown ticks before an NPC can enter another conversation. -/// 5 game-minutes = 50 ticks. -pub(crate) const CONVERSATION_COOLDOWN_TICKS: u64 = 50; - -/// Chance (0-100) per tick that an eligible NPC pair starts a conversation. -/// Low to prevent every pair chatting every tick. ~2% per tick. -const CONVERSATION_CHANCE_PERCENT: u32 = 2; - -/// Voice sound range boundary in tiles (D-018 Medium = 8). -const VOICE_RANGE_TILES: u32 = 8; - -/// Ticks between conversation lines (~2 game-minutes at 10 ticks/min). -const LINE_INTERVAL_TICKS: u64 = 20; - -// --------------------------------------------------------------------------- -// Components -// --------------------------------------------------------------------------- - -/// Display name for an NPC, used on the wire for conversation events. -/// 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 in the -/// conversation log. 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. -/// 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, -} - -/// Cooldown preventing an NPC from entering another conversation too soon. -#[derive(Component, Debug)] -pub struct ConversationCooldown { - pub until_tick: u64, -} - -// --------------------------------------------------------------------------- -// Wire types (cross bridge boundary) -// --------------------------------------------------------------------------- - -/// Conversation event included in ObserverSnapshot when the player overhears -/// an NPC-to-NPC conversation (D-078). -/// -/// The server performs per-word occlusion before emission — the client receives -/// `occluded_line` and renders it verbatim. No stochastic logic on the client. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConversationEvent { - /// The dialogue line with dropped words replaced by "...". - pub occluded_line: String, - /// Wire-format entity ID of the speaking NPC. - pub speaker_id: u64, - /// Wire-format entity ID of the NPC being spoken to. - pub target_id: u64, - /// Display name of the speaker (real name if known to player, else role label). - #[serde(default)] - pub speaker_name: String, - /// Display name of the target (real name if known to player, else role label). - #[serde(default)] - pub target_name: String, - /// Color index (0-7) for the speaker's conversation log entry. - #[serde(default)] - pub speaker_color_index: u8, - /// Color index (0-7) for the target's conversation log entry. - #[serde(default)] - pub target_color_index: u8, -} - -/// End-of-conversation event. Client dismisses the passive dialogue panel. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConversationEndEvent { - /// Wire-format entity ID of speaker. - pub speaker_id: u64, - /// Wire-format entity ID of target. - pub target_id: u64, -} - -/// Buffer holding conversation events for snapshot inclusion. -/// Drained once per snapshot via `take()`. -#[derive(Component, Debug, Default)] -pub struct ConversationEventBuffer { - pub events: Vec, - pub ended: Vec, -} - -impl ConversationEventBuffer { - /// Drain and return all conversation events. - pub fn take_events(&mut self) -> Vec { - std::mem::take(&mut self.events) - } - - /// Drain and return all end events. - pub fn take_ended(&mut self) -> Vec { - std::mem::take(&mut self.ended) - } -} - -// --------------------------------------------------------------------------- -// Per-word occlusion (D-078) -// --------------------------------------------------------------------------- - -/// Compute the per-word drop probability as an integer percentage (0-100). -/// -/// Inputs: -/// - `distance`: Manhattan tile distance from player to speaker. -/// - `ambient_noise_pct`: Ambient noise at player position as 0-100 integer. -/// Maps to up to +30 percentage points of drop probability. -/// - `listening_focus`: Whether the player has ListeningFocus active (-20pp). -/// -/// Formula: -/// base = distance * 100 / VOICE_RANGE_TILES (linear 0→100 over range) -/// noise_bonus = ambient_noise_pct * 30 / 100 (up to +30) -/// focus_bonus = if listening_focus { -20 } else { 0 } -/// result = clamp(base + noise_bonus + focus_bonus, 0, 100) -/// -/// All integer arithmetic — no floats (D-010). -pub fn compute_drop_probability( - distance: u32, - ambient_noise_pct: u32, - listening_focus: bool, -) -> u32 { - // Linear distance decay: 0% at distance 0, 100% at VOICE_RANGE_TILES - let base = (distance.min(VOICE_RANGE_TILES) * 100) / VOICE_RANGE_TILES; - - // Ambient noise: scales 0-100 input to 0-30 contribution - let noise_bonus = (ambient_noise_pct.min(100) * 30) / 100; - - // ListeningFocus subtracts 20 - let focus_bonus: i32 = if listening_focus { -20 } else { 0 }; - - let raw = base as i32 + noise_bonus as i32 + focus_bonus; - raw.clamp(0, 100) as u32 -} - -/// Apply per-word occlusion to a dialogue line. -/// -/// Each word undergoes an independent Bernoulli trial: if a random value -/// in [0, 100) is less than `drop_pct`, the word is replaced with "...". -/// Consecutive dropped words collapse into a single "..." per the D-078 spec. -/// -/// Uses SimRng for deterministic replay (D-010). -pub fn occlude_line(line: &str, drop_pct: u32, rng: &mut impl Rng) -> String { - if drop_pct == 0 { - return line.to_string(); - } - if drop_pct >= 100 { - // All words dropped — single ellipsis - if line.split_whitespace().count() > 0 { - return "...".to_string(); - } - return String::new(); - } - - let mut result = Vec::new(); - let mut last_was_dropped = false; - - for word in line.split_whitespace() { - let roll: u32 = rng.random_range(0..100); - if roll < drop_pct { - // Drop this word — collapse consecutive drops - if !last_was_dropped { - result.push("..."); - last_was_dropped = true; - } - } else { - result.push(word); - last_was_dropped = false; - } - } - - result.join(" ") -} - -// --------------------------------------------------------------------------- -// Placeholder line selection -// --------------------------------------------------------------------------- - -/// Placeholder NPC-to-NPC conversation lines. -/// Content sourced from #536 (copy team) — these are development placeholders. -const NPC_CONVERSATION_LINES: &[&str] = &[ - "Heard anything from the night shift?", - "Cargo manifests don't add up again.", - "Keep your head down today.", - "The new arrival's been asking questions.", - "Terminal three has been acting up.", - "Did you see the Commission officer?", - "I need to talk to you about something.", - "Another long shift ahead.", -]; - -// --------------------------------------------------------------------------- -// Systems -// --------------------------------------------------------------------------- - -/// System: initiate new NPC-to-NPC conversations and tick existing ones. -/// -/// Phase 1: Check for eligible NPC pairs (ActiveSim, proximity ≤3, not already -/// in conversation, not on cooldown) and probabilistically start conversations. -/// -/// Phase 2: Tick active conversations — emit Voice SoundEvents and -/// ConversationEvents (with per-word occlusion) for the player's snapshot. -/// Terminate conversations when duration expires or NPCs move apart. -/// -/// System ordering: after validate_movement, before collect_sound_events. -#[allow(clippy::type_complexity, clippy::too_many_arguments)] -pub fn run_npc_conversations( - mut commands: Commands, - time: Res, - registry: Res, - mut rng: ResMut, - // All ActiveSim NPCs — candidates for conversation initiation - npc_query: Query< - ( - Entity, - &TilePosition, - Option<&NpcName>, - Option<&NpcConversation>, - Option<&ConversationCooldown>, - Option<&StableEntityId>, - Option<&NpcColorIndex>, - Option<&DialogueProfile>, - ), - (With, With), - >, - // Player query for occlusion computation - mut player_query: Query< - ( - &TilePosition, - Option<&ListeningFocus>, - &mut ConversationEventBuffer, - &KnowledgeGraph, - ), - With, - >, -) { - // --- Phase 1: Initiate new conversations --- - - // Collect eligible NPCs (not in conversation, not on cooldown). - // Sorted by StableId for deterministic pairing order (D-010). - let mut eligible: Vec<(Entity, TilePosition, u64)> = npc_query - .iter() - .filter(|(_, _, _, conv, cooldown, _, _, _)| { - conv.is_none() - && cooldown - .map(|cd| time.tick >= cd.until_tick) - .unwrap_or(true) - }) - .map(|(entity, pos, _, _, _, sid, _, _)| { - (entity, *pos, sid.map(|s| s.0 .0).unwrap_or(u64::MAX)) - }) - .collect(); - eligible.sort_by_key(|&(_, _, sid)| sid); - - // Try to pair eligible NPCs within proximity. - // O(N^2) pair scan — acceptable for v0.1 Active-tier counts (30-80 NPCs, D-026). - // If NPC population grows beyond ~200, consider spatial indexing. - // Only one new conversation per tick to avoid spam. - let mut started_this_tick = false; - - for i in 0..eligible.len() { - if started_this_tick { - break; - } - for j in (i + 1)..eligible.len() { - let (entity_a, pos_a, _) = eligible[i]; - let (entity_b, pos_b, _) = eligible[j]; - - let Some(distance) = pos_a.manhattan_distance(&pos_b) else { - continue; // different z-levels - }; - - if distance > CONVERSATION_PROXIMITY { - continue; - } - - // Probabilistic start - let roll: u32 = rng.rng.random_range(0..100); - if roll >= CONVERSATION_CHANCE_PERCENT { - continue; - } - - // Start conversation - let duration = rng - .rng - .random_range(MIN_DURATION_TICKS..=MAX_DURATION_TICKS); - commands.entity(entity_a).insert(NpcConversation { - partner: entity_b, - started_tick: time.tick, - end_tick: time.tick + duration, - ticks_since_last_line: 0, - }); - - started_this_tick = true; - tracing::debug!( - "NPC conversation started: {:?} ↔ {:?}, duration={} ticks", - entity_a, - entity_b, - duration, - ); - break; - } - } - - // --- Phase 2: Tick active conversations --- - - // Collect active conversations — need mutable access later, so collect first. - // Tuple: (entity, conv, pos, npc_real_name, npc_role, npc_color_index) - let active_conversations: Vec<( - Entity, - NpcConversation, - TilePosition, - Option, - Option, - Option, - )> = npc_query - .iter() - .filter_map(|(entity, pos, name, conv, _, _, color_idx, profile)| { - conv.map(|c| { - ( - entity, - NpcConversation { - partner: c.partner, - started_tick: c.started_tick, - end_tick: c.end_tick, - ticks_since_last_line: c.ticks_since_last_line, - }, - *pos, - name.map(|n| n.0.clone()), - profile.map(|p| p.role.clone()), - color_idx.map(|ci| ci.0), - ) - }) - }) - .collect(); - - for (speaker_entity, conv, speaker_pos, speaker_name, speaker_role, speaker_color) in - &active_conversations - { - let speaker_entity = *speaker_entity; - - // Check termination: duration expired - if time.tick >= conv.end_tick { - terminate_conversation( - &mut commands, - ®istry, - &mut player_query, - speaker_entity, - conv.partner, - time.tick, - ); - continue; - } - - // Check termination: partner moved away or no longer ActiveSim - let partner_ok = npc_query - .get(conv.partner) - .ok() - .map(|(_, pos, _, _, _, _, _, _)| { - speaker_pos - .manhattan_distance(pos) - .map(|d| d <= CONVERSATION_PROXIMITY) - .unwrap_or(false) - }); - - if partner_ok != Some(true) { - terminate_conversation( - &mut commands, - ®istry, - &mut player_query, - speaker_entity, - conv.partner, - time.tick, - ); - continue; - } - - // Emit Voice SoundEvent at speaker position - if let Some(speaker_sid) = registry.to_stable(speaker_entity) { - let voice_event = SoundEvent::at( - speaker_pos, - SoundEventKind::Voice, - 0.6, - SoundRange::Medium, - Some(speaker_sid.0), - ); - commands - .entity(speaker_entity) - .insert(SoundEventEmitter::new(voice_event)); - } - - // Emit conversation line periodically - if conv.ticks_since_last_line >= LINE_INTERVAL_TICKS || conv.ticks_since_last_line == 0 { - // Select a placeholder line - let line_idx = rng.rng.random_range(0..NPC_CONVERSATION_LINES.len()); - let line_text = NPC_CONVERSATION_LINES[line_idx]; - - // Collect partner display info (real name, role, color) once — used - // per-observer below to resolve display names against each observer's KG. - let (partner_real_name, partner_role, partner_color) = npc_query - .get(conv.partner) - .ok() - .map(|(_, _, pname, _, _, _, pcolor, pprofile)| { - ( - pname.map(|n| n.0.clone()), - pprofile.map(|p| p.role.clone()), - pcolor.map(|ci| ci.0).unwrap_or(0u8), - ) - }) - .unwrap_or((None, None, 0u8)); - - let speaker_sid = registry.to_stable(speaker_entity); - let target_sid = registry.to_stable(conv.partner); - - // Compute per-observer occlusion (D-078, D-010 principle 3). - // Iterates all observers — supports future multi-observer scenarios (D-027). - for (player_pos, listening_focus_opt, mut conv_buffer, player_kg) in - player_query.iter_mut() - { - let distance = speaker_pos - .manhattan_distance(player_pos) - .unwrap_or(u32::MAX); - - // Only emit if within Voice range - if distance <= VOICE_RANGE_TILES { - let listening = listening_focus_opt - .map(|lf| lf.is_eavesdropping()) - .unwrap_or(false); - - // Zone ambient noise — stubbed at 0 until zone-conspicuousness - // (D-071) wires in. Function signature already accepts the value. - let ambient_noise_pct = 0u32; - - let drop_pct = compute_drop_probability(distance, ambient_noise_pct, listening); - let occluded = occlude_line(line_text, drop_pct, &mut rng.rng); - - if let (Some(s_sid), Some(t_sid)) = (speaker_sid, target_sid) { - // Resolve speaker display name per this observer's KG. - let speaker_display = { - let known = player_kg - .entity_knowledge(&s_sid) - .map(|e| e.known_attributes.contains_key("name")) - .unwrap_or(false); - if known { - speaker_name - .clone() - .unwrap_or_else(|| "Unknown".to_string()) - } else { - speaker_role - .as_deref() - .map(display_label_for_role) - .unwrap_or_else(|| "Bystander".to_string()) - } - }; - - // Resolve target display name per this observer's KG. - let target_display = { - let known = player_kg - .entity_knowledge(&t_sid) - .map(|e| e.known_attributes.contains_key("name")) - .unwrap_or(false); - if known { - partner_real_name - .clone() - .unwrap_or_else(|| "Unknown".to_string()) - } else { - partner_role - .as_deref() - .map(display_label_for_role) - .unwrap_or_else(|| "Bystander".to_string()) - } - }; - - conv_buffer.events.push(ConversationEvent { - occluded_line: occluded, - speaker_id: s_sid.0, - target_id: t_sid.0, - speaker_name: speaker_display, - target_name: target_display, - speaker_color_index: speaker_color.unwrap_or(0), - target_color_index: partner_color, - }); - } - } - } - - // Reset line timer - commands.entity(speaker_entity).insert(NpcConversation { - partner: conv.partner, - started_tick: conv.started_tick, - end_tick: conv.end_tick, - ticks_since_last_line: 0, - }); - } else { - // Increment line timer - commands.entity(speaker_entity).insert(NpcConversation { - partner: conv.partner, - started_tick: conv.started_tick, - end_tick: conv.end_tick, - ticks_since_last_line: conv.ticks_since_last_line + 1, - }); - } - } -} - -/// Terminate a conversation: remove NpcConversation, apply cooldowns, emit end event. -fn terminate_conversation( - commands: &mut Commands, - registry: &EntityRegistry, - player_query: &mut Query< - ( - &TilePosition, - Option<&ListeningFocus>, - &mut ConversationEventBuffer, - &KnowledgeGraph, - ), - With, - >, - speaker: Entity, - partner: Entity, - current_tick: u64, -) { - commands.entity(speaker).remove::(); - - // Apply cooldown to both participants - let until = current_tick + CONVERSATION_COOLDOWN_TICKS; - commands - .entity(speaker) - .insert(ConversationCooldown { until_tick: until }); - commands - .entity(partner) - .insert(ConversationCooldown { until_tick: until }); - - // Emit conversation_end event to all observers (D-010 principle 3). - let speaker_sid = registry.to_stable(speaker); - let target_sid = registry.to_stable(partner); - - if let (Some(s_sid), Some(t_sid)) = (speaker_sid, target_sid) { - for (_, _, mut conv_buffer, _) in player_query.iter_mut() { - conv_buffer.ended.push(ConversationEndEvent { - speaker_id: s_sid.0, - target_id: t_sid.0, - }); - } - } - - tracing::debug!("NPC conversation ended: {:?} ↔ {:?}", speaker, partner,); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::knowledge::KnowledgeGraph; - use rand::SeedableRng; - use rand_chacha::ChaCha20Rng; - - // -- Per-word occlusion tests ------------------------------------------- - - #[test] - fn occlusion_drops_words_with_distance() { - // At max range (8 tiles), drop probability is 100% — all words dropped - let drop = compute_drop_probability(VOICE_RANGE_TILES, 0, false); - assert_eq!(drop, 100); - - let mut rng = ChaCha20Rng::seed_from_u64(42); - let result = occlude_line("Hello there friend", drop, &mut rng); - assert_eq!(result, "..."); - } - - #[test] - fn occlusion_preserves_all_words_at_zero_distance() { - let drop = compute_drop_probability(0, 0, false); - assert_eq!(drop, 0); - - let mut rng = ChaCha20Rng::seed_from_u64(42); - let result = occlude_line("Hello there friend", drop, &mut rng); - assert_eq!(result, "Hello there friend"); - } - - #[test] - fn occlusion_suppressed_by_listening_focus() { - // At distance 2 (25% base), no noise, with focus (-20%) → 5% - let without_focus = compute_drop_probability(2, 0, false); - let with_focus = compute_drop_probability(2, 0, true); - - assert!( - with_focus < without_focus, - "focus should reduce drop probability" - ); - assert_eq!(without_focus, 25); // 2 * 100 / 8 = 25 - assert_eq!(with_focus, 5); // 25 - 20 = 5 - } - - #[test] - fn occlusion_deterministic_with_same_seed() { - let line = "The cargo manifests don't add up at all"; - let drop_pct = 50; - - let mut rng1 = ChaCha20Rng::seed_from_u64(42); - let mut rng2 = ChaCha20Rng::seed_from_u64(42); - - let result1 = occlude_line(line, drop_pct, &mut rng1); - let result2 = occlude_line(line, drop_pct, &mut rng2); - - assert_eq!(result1, result2, "same seed must produce same occlusion"); - } - - #[test] - fn occlusion_ambient_noise_adds_up_to_30() { - // Max ambient noise (100%) adds 30 percentage points - let no_noise = compute_drop_probability(0, 0, false); - let max_noise = compute_drop_probability(0, 100, false); - - assert_eq!(no_noise, 0); - assert_eq!(max_noise, 30); - } - - #[test] - fn occlusion_clamps_to_zero() { - // Very close + listening focus → should clamp at 0, not go negative - let drop = compute_drop_probability(0, 0, true); - assert_eq!(drop, 0); // 0 - 20 clamped to 0 - } - - #[test] - fn occlusion_clamps_to_100() { - // Far away + max noise → should cap at 100 - let drop = compute_drop_probability(VOICE_RANGE_TILES, 100, false); - assert_eq!(drop, 100); // 100 + 30 clamped to 100 - } - - #[test] - fn occlusion_consecutive_drops_collapse() { - // Ensure consecutive dropped words become a single "..." - let mut rng = ChaCha20Rng::seed_from_u64(0); - // At 100% drop, everything collapses - let result = occlude_line("one two three four five", 100, &mut rng); - assert_eq!(result, "..."); - } - - #[test] - fn occlusion_empty_line() { - let mut rng = ChaCha20Rng::seed_from_u64(42); - let result = occlude_line("", 50, &mut rng); - assert_eq!(result, ""); - } - - #[test] - fn occlusion_linear_distance_scaling() { - // Distance 4 out of 8 = 50% - assert_eq!(compute_drop_probability(4, 0, false), 50); - // Distance 1 out of 8 = 12% (integer division: 1*100/8 = 12) - assert_eq!(compute_drop_probability(1, 0, false), 12); - // Distance 6 out of 8 = 75% - assert_eq!(compute_drop_probability(6, 0, false), 75); - } - - // -- Conversation lifecycle tests (ECS) -------------------------------- - - fn setup_conversation_world() -> bevy_ecs::world::World { - let mut world = bevy_ecs::world::World::new(); - world.init_resource::(); - world.insert_resource(SimRng::new(42)); - world.init_resource::(); - world - } - - #[test] - fn npc_conversation_emits_voice_event_when_in_range() { - let mut world = setup_conversation_world(); - - let npc_a = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - NpcName("Alice".to_string()), - NpcConversation { - partner: Entity::PLACEHOLDER, - started_tick: 0, - end_tick: 100, - ticks_since_last_line: 0, - }, - )) - .id(); - world.resource_mut::().register(npc_a); - - let npc_b = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 6, 0), - NpcName("Bob".to_string()), - )) - .id(); - world.resource_mut::().register(npc_b); - - // Fix the partner reference - world.get_mut::(npc_a).unwrap().partner = npc_b; - - // Spawn player within voice range - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 8, 0), // distance 3 from speaker - ConversationEventBuffer::default(), - KnowledgeGraph::new(), - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - schedule.run(&mut world); - world.flush(); - - // Check that a SoundEventEmitter with Voice was attached to the speaker - let emitter = world.get::(npc_a); - assert!( - emitter.is_some(), - "Speaker should have a SoundEventEmitter after conversation tick" - ); - assert_eq!(emitter.unwrap().pending[0].kind, SoundEventKind::Voice); - - // Check that a ConversationEvent was buffered for the player - let buffer = world.get::(player).unwrap(); - assert_eq!( - buffer.events.len(), - 1, - "Player in range should receive a conversation event" - ); - // Player KG has no "name" attribute for either NPC, and NPCs have no - // DialogueProfile, so both should fall back to the Bystander label. - assert_eq!(buffer.events[0].speaker_name, "Bystander"); - assert_eq!(buffer.events[0].target_name, "Bystander"); - // Color index defaults to 0 when NpcColorIndex is not attached. - assert_eq!(buffer.events[0].speaker_color_index, 0); - assert_eq!(buffer.events[0].target_color_index, 0); - } - - #[test] - fn npc_conversation_terminates_when_apart() { - let mut world = setup_conversation_world(); - - let npc_a = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - NpcName("Alice".to_string()), - NpcConversation { - partner: Entity::PLACEHOLDER, - started_tick: 0, - end_tick: 100, - ticks_since_last_line: 0, - }, - )) - .id(); - world.resource_mut::().register(npc_a); - - // Partner is far away (>3 tiles) - let npc_b = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(20, 20, 0), - NpcName("Bob".to_string()), - )) - .id(); - world.resource_mut::().register(npc_b); - - world.get_mut::(npc_a).unwrap().partner = npc_b; - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - ConversationEventBuffer::default(), - KnowledgeGraph::new(), - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - schedule.run(&mut world); - world.flush(); - - // Conversation should be removed - assert!( - world.get::(npc_a).is_none(), - "Conversation should terminate when NPCs are apart" - ); - - // End event should be emitted - let buffer = world.get::(player).unwrap(); - assert_eq!( - buffer.ended.len(), - 1, - "conversation_end event should be emitted" - ); - } - - #[test] - fn conversation_terminates_on_duration_expiry() { - let mut world = setup_conversation_world(); - world.resource_mut::().tick = 101; // Past end_tick - - let npc_a = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - NpcConversation { - partner: Entity::PLACEHOLDER, - started_tick: 0, - end_tick: 100, - ticks_since_last_line: 0, - }, - )) - .id(); - world.resource_mut::().register(npc_a); - - let npc_b = world - .spawn((Npc, ActiveSim, TilePosition::new(5, 6, 0))) - .id(); - world.resource_mut::().register(npc_b); - - world.get_mut::(npc_a).unwrap().partner = npc_b; - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - ConversationEventBuffer::default(), - KnowledgeGraph::new(), - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - schedule.run(&mut world); - world.flush(); - - assert!( - world.get::(npc_a).is_none(), - "Conversation should terminate when duration expires" - ); - } - - #[test] - fn player_out_of_range_gets_no_event() { - let mut world = setup_conversation_world(); - - let npc_a = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - NpcName("Alice".to_string()), - NpcConversation { - partner: Entity::PLACEHOLDER, - started_tick: 0, - end_tick: 100, - ticks_since_last_line: 0, - }, - )) - .id(); - world.resource_mut::().register(npc_a); - - let npc_b = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 6, 0), - NpcName("Bob".to_string()), - )) - .id(); - world.resource_mut::().register(npc_b); - - world.get_mut::(npc_a).unwrap().partner = npc_b; - - // Player far away (distance > 8 = VOICE_RANGE_TILES) - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(30, 30, 0), - ConversationEventBuffer::default(), - KnowledgeGraph::new(), - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - schedule.run(&mut world); - world.flush(); - - let buffer = world.get::(player).unwrap(); - assert!( - buffer.events.is_empty(), - "Player out of voice range should not receive conversation events" - ); - } - - #[test] - fn drop_probability_formula_matches_spec() { - // D-078 spec: linear decay from 0.0 at 0 tiles to 1.0 at range boundary - assert_eq!(compute_drop_probability(0, 0, false), 0); - assert_eq!(compute_drop_probability(VOICE_RANGE_TILES, 0, false), 100); - - // Ambient noise adds up to 0.3 (30pp) - assert_eq!(compute_drop_probability(0, 100, false), 30); - assert_eq!(compute_drop_probability(0, 50, false), 15); - - // ListeningFocus subtracts 0.2 (20pp) - assert_eq!(compute_drop_probability(4, 0, true), 30); // 50 - 20 - } - - // -- Additional QA coverage (Hoshe, Sprint 14) -------------------------- - - #[test] - fn cooldown_applied_to_both_npcs_after_distance_termination() { - // When a conversation terminates (NPCs drift apart), both NPCs must - // receive ConversationCooldown to prevent immediate re-pairing. - let mut world = setup_conversation_world(); - world.resource_mut::().tick = 100; - - let npc_a = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - NpcName("Alice".to_string()), - NpcConversation { - partner: Entity::PLACEHOLDER, - started_tick: 50, - end_tick: 200, - ticks_since_last_line: 0, - }, - )) - .id(); - world.resource_mut::().register(npc_a); - - // Partner far away — conversation should terminate this tick - let npc_b = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(20, 20, 0), - NpcName("Bob".to_string()), - )) - .id(); - world.resource_mut::().register(npc_b); - - world.get_mut::(npc_a).unwrap().partner = npc_b; - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - ConversationEventBuffer::default(), - KnowledgeGraph::new(), - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - schedule.run(&mut world); - world.flush(); - - // Both NPCs must have ConversationCooldown applied - let cooldown_a = world.get::(npc_a); - assert!( - cooldown_a.is_some(), - "Speaker (npc_a) must get ConversationCooldown after termination" - ); - assert_eq!( - cooldown_a.unwrap().until_tick, - 100 + CONVERSATION_COOLDOWN_TICKS, - "Cooldown until_tick must be current_tick + CONVERSATION_COOLDOWN_TICKS" - ); - - let cooldown_b = world.get::(npc_b); - assert!( - cooldown_b.is_some(), - "Partner (npc_b) must get ConversationCooldown after termination" - ); - assert_eq!( - cooldown_b.unwrap().until_tick, - 100 + CONVERSATION_COOLDOWN_TICKS, - "Both NPCs receive the same cooldown duration" - ); - } - - #[test] - fn cooldown_applied_after_duration_expiry() { - // Termination by duration should also apply cooldowns. - let mut world = setup_conversation_world(); - world.resource_mut::().tick = 200; - - let npc_a = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - NpcConversation { - partner: Entity::PLACEHOLDER, - started_tick: 0, - end_tick: 100, // expired - ticks_since_last_line: 0, - }, - )) - .id(); - world.resource_mut::().register(npc_a); - - let npc_b = world - .spawn((Npc, ActiveSim, TilePosition::new(5, 6, 0))) - .id(); - world.resource_mut::().register(npc_b); - - world.get_mut::(npc_a).unwrap().partner = npc_b; - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - ConversationEventBuffer::default(), - KnowledgeGraph::new(), - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - schedule.run(&mut world); - world.flush(); - - assert!( - world.get::(npc_a).is_some(), - "Speaker must get cooldown after duration expiry" - ); - assert!( - world.get::(npc_b).is_some(), - "Partner must get cooldown after duration expiry" - ); - } - - #[test] - fn npc_on_active_cooldown_cannot_start_conversation() { - // An NPC with ConversationCooldown (until_tick > current_tick) must - // not be eligible for new conversation initiation. - let mut world = setup_conversation_world(); - world.resource_mut::().tick = 50; - - // NPC on cooldown (expires at tick 100, current is 50) - world.spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - ConversationCooldown { until_tick: 100 }, - )); - - world.spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 6, 0), - ConversationCooldown { until_tick: 100 }, - )); - - world.spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - ConversationEventBuffer::default(), - KnowledgeGraph::new(), - )); - - // Run many ticks — no conversation should ever start because all NPCs are on cooldown - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - - for _ in 0..50 { - schedule.run(&mut world); - world.flush(); - } - - // Verify no NpcConversation was created - let mut conv_query = world.query::<&NpcConversation>(); - assert!( - conv_query.iter(&world).count() == 0, - "NPCs on cooldown must not enter conversations" - ); - } - - #[test] - fn expired_cooldown_allows_conversation_initiation() { - // A cooldown whose until_tick <= current_tick should not block the NPC. - let mut world = setup_conversation_world(); - // Set tick high enough that the cooldown has expired - world.resource_mut::().tick = 200; - - // Both NPCs have cooldowns that expired at tick 100 - world.spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - ConversationCooldown { until_tick: 100 }, // expired at 200 - )); - world.spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 6, 0), - ConversationCooldown { until_tick: 100 }, // expired at 200 - )); - world.spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - ConversationEventBuffer::default(), - KnowledgeGraph::new(), - )); - - // With 2% chance per tick, over 300 ticks a conversation is extremely likely. - // Use a fresh world per attempt but share the schedule. - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - - // Run until we see a conversation or hit max attempts - let mut found = false; - for _ in 0..300 { - schedule.run(&mut world); - world.flush(); - - let mut conv_query = world.query::<&NpcConversation>(); - if conv_query.iter(&world).count() > 0 { - found = true; - break; - } - } - - assert!( - found, - "Expired cooldown should allow conversation initiation (2% per tick, 300 attempts)" - ); - } - - // -- Name masking tests (Sprint 15) -------------------------------------- - - #[test] - fn conversation_uses_role_label_when_name_not_in_player_kg() { - // Player KG has an entry for the NPC but no "name" attribute. - // ConversationEvent.speaker_name should be the role label. - let mut world = setup_conversation_world(); - - let npc_a = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - NpcName("Alice".to_string()), - NpcConversation { - partner: Entity::PLACEHOLDER, - started_tick: 0, - end_tick: 100, - ticks_since_last_line: 0, - }, - DialogueProfile { - location: "the-terminal".to_string(), - role: "dock-worker".to_string(), - }, - )) - .id(); - let npc_a_sid = world.resource_mut::().register(npc_a); - - let npc_b = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 6, 0), - NpcName("Bob".to_string()), - DialogueProfile { - location: "the-terminal".to_string(), - role: "courier".to_string(), - }, - )) - .id(); - let npc_b_sid = world.resource_mut::().register(npc_b); - - world.get_mut::(npc_a).unwrap().partner = npc_b; - - // Player KG observes both NPCs but has NO "name" attribute for either. - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_a_sid, TilePosition::new(5, 5, 0), 0); - kg.observe_entity(npc_b_sid, TilePosition::new(5, 6, 0), 0); - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - ConversationEventBuffer::default(), - kg, - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - schedule.run(&mut world); - world.flush(); - - let buffer = world.get::(player).unwrap(); - assert_eq!(buffer.events.len(), 1); - // No "name" attribute → falls back to role label - assert_eq!( - buffer.events[0].speaker_name, "Dock Worker", - "speaker with no KG name attribute should show role label" - ); - assert_eq!( - buffer.events[0].target_name, "Courier", - "target with no KG name attribute should show role label" - ); - } - - #[test] - fn conversation_uses_real_name_when_name_in_player_kg() { - // Player KG has a "name" attribute for the speaker. - // ConversationEvent.speaker_name should use NpcName.0. - let mut world = setup_conversation_world(); - - let npc_a = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - NpcName("Alice".to_string()), - NpcConversation { - partner: Entity::PLACEHOLDER, - started_tick: 0, - end_tick: 100, - ticks_since_last_line: 0, - }, - DialogueProfile { - location: "the-terminal".to_string(), - role: "dock-worker".to_string(), - }, - )) - .id(); - let npc_a_sid = world.resource_mut::().register(npc_a); - - let npc_b = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 6, 0), - NpcName("Bob".to_string()), - DialogueProfile { - location: "the-terminal".to_string(), - role: "courier".to_string(), - }, - )) - .id(); - let npc_b_sid = world.resource_mut::().register(npc_b); - - world.get_mut::(npc_a).unwrap().partner = npc_b; - - // Player KG has "name" attribute for both NPCs (name has been revealed). - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_a_sid, TilePosition::new(5, 5, 0), 0); - kg.entities - .get_mut(&npc_a_sid) - .unwrap() - .known_attributes - .insert("name".to_string(), "Alice".to_string()); - kg.observe_entity(npc_b_sid, TilePosition::new(5, 6, 0), 0); - kg.entities - .get_mut(&npc_b_sid) - .unwrap() - .known_attributes - .insert("name".to_string(), "Bob".to_string()); - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - ConversationEventBuffer::default(), - kg, - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - schedule.run(&mut world); - world.flush(); - - let buffer = world.get::(player).unwrap(); - assert_eq!(buffer.events.len(), 1); - // "name" attribute present → use NpcName.0 - assert_eq!( - buffer.events[0].speaker_name, "Alice", - "speaker with KG name attribute should show real name" - ); - assert_eq!( - buffer.events[0].target_name, "Bob", - "target with KG name attribute should show real name" - ); - } - - #[test] - fn buffer_take_events_drains_and_returns_events() { - let mut buffer = ConversationEventBuffer::default(); - buffer.events.push(ConversationEvent { - occluded_line: "Hello".to_string(), - speaker_id: 1, - target_id: 2, - speaker_name: "Alice".to_string(), - target_name: "Bob".to_string(), - speaker_color_index: 0, - target_color_index: 1, - }); - buffer.events.push(ConversationEvent { - occluded_line: "World".to_string(), - speaker_id: 1, - target_id: 2, - speaker_name: "Alice".to_string(), - target_name: "Bob".to_string(), - speaker_color_index: 0, - target_color_index: 1, - }); - - let taken = buffer.take_events(); - assert_eq!(taken.len(), 2, "take_events should return all events"); - assert!( - buffer.events.is_empty(), - "Buffer should be empty after take_events" - ); - - // Second call returns empty - let taken2 = buffer.take_events(); - assert!( - taken2.is_empty(), - "Second take_events call should return empty vec" - ); - } - - #[test] - fn buffer_take_ended_drains_and_returns_end_events() { - let mut buffer = ConversationEventBuffer::default(); - buffer.ended.push(ConversationEndEvent { - speaker_id: 10, - target_id: 20, - }); - - let taken = buffer.take_ended(); - assert_eq!(taken.len(), 1, "take_ended should return all end events"); - assert!( - buffer.ended.is_empty(), - "ended buffer should be empty after take_ended" - ); - - // Second call returns empty - assert!(buffer.take_ended().is_empty()); - } -} diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index acfef774a..3ebd46c8f 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -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; diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 74b0a1795..dbcb3e746 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -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>, + pub selected: Option>, + pub snapshot_buf: Option>, + pub sim_error_buf: Option>, + pub culture: Option>, +} + /// 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>, 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>, + selected_bookmark: &mut Option>, + sim_error_buf: &mut Option>, + 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::(); + world.init_resource::(); + world.init_resource::(); + + 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::().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::().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::() + .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::().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::().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::().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::().drain(); + assert!( + errors.is_empty(), + "no errors expected for valid ConfirmBookmark" + ); + + let sel = world.resource::(); + 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::().push(PlayerInput { + tick: 0, + action: PlayerAction::ConfirmBookmark { + bookmark_id: "test_bookmark".into(), + starting_location_id: "Loc A".into(), + }, + }); + world.resource_mut::().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::().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::(); + assert_eq!(sel.starting_location_id.as_deref(), Some("Loc A")); + } } diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 944e65e78..b32c752d2 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -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; diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index 8b345de9f..e9680777d 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -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, @@ -441,9 +429,7 @@ pub fn trigger_event_monologue( // Saved for NPC attribution (engagement tracking #570) and trigger detection. let post_conv_npcs: Vec = 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::(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::(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::() - .events - .push(SoundEvent::at( - &TilePosition::new(11, 10, 0), - SoundEventKind::Alert, - 1.0, - crate::knowledge::types::SoundRange::Medium, - None, - )); - - // Conversation event - world - .get_mut::(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::(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"); diff --git a/server/src/simulation/npc_components.rs b/server/src/simulation/npc_components.rs new file mode 100644 index 000000000..4cb3c83be --- /dev/null +++ b/server/src/simulation/npc_components.rs @@ -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, +} diff --git a/server/src/simulation/npc_knowledge_transfer.rs b/server/src/simulation/npc_knowledge_transfer.rs index 34fd7d84c..ea9c9b273 100644 --- a/server/src/simulation/npc_knowledge_transfer.rs +++ b/server/src/simulation/npc_knowledge_transfer.rs @@ -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, @@ -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; diff --git a/server/src/simulation/social_plugin.rs b/server/src/simulation/social_plugin.rs index f142c9c71..765ceb190 100644 --- a/server/src/simulation/social_plugin.rs +++ b/server/src/simulation/social_plugin.rs @@ -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 diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index b70705d0a..196d0ac25 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -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. diff --git a/server/src/voice/integration.rs b/server/src/voice/integration.rs index 91fa08704..0c4f7801e 100644 --- a/server/src/voice/integration.rs +++ b/server/src/voice/integration.rs @@ -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>, - registry: Res, - zone_map: Option>, - player_query: Query<&TilePosition, With>, - npc_voice_query: Query<(&NpcVoiceProfile, Option<&DerivedTellState>), With>, - mut conversation_buffer: Query<&mut ConversationEventBuffer, With>, -) { - 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 // --------------------------------------------------------------------------- diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index ebd2c55de..2d47d4c72 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -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 diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 32062441a..299cf24e0 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -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 diff --git a/server/tests/error_handling.rs b/server/tests/error_handling.rs index 7db6a3a55..bf1086e40 100644 --- a/server/tests/error_handling.rs +++ b/server/tests/error_handling.rs @@ -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"); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 854a6492f..f8079fff7 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -39,8 +39,6 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> 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) -> 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); +} diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json index 77a1c37e3..3e4ec9109 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -5,8 +5,6 @@ 3 ], "character_pressure": null, - "conversation_ended": [], - "conversation_events": [], "current_monologue": null, "dialogue_response": null, "entities": [ diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 3d275b2ee..b783c5d2d 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -27,8 +27,6 @@ fn test_snapshot(tick: u64, entities: Vec) -> 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) -> 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 }); diff --git a/tooling/economy-db/import_economics.py b/tooling/economy-db/import_economics.py index 4cc2421d2..1a519b1d8 100755 --- a/tooling/economy-db/import_economics.py +++ b/tooling/economy-db/import_economics.py @@ -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: diff --git a/tooling/generate-brands b/tooling/generate-brands new file mode 100755 index 000000000..fd9109206 --- /dev/null +++ b/tooling/generate-brands @@ -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" "$@"