Merge remote-tracking branch 'origin/sprint-36/server'
# Conflicts: # server/data/systems.db
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -283,9 +283,9 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
|
||||
### D-061: Dialogue box — unified conversation log, bottom screen, max 20% height, no portraits
|
||||
- **Date:** 2026-02-13
|
||||
- **Decision:** Dialogue occupies the bottom of the screen, max 20% height, max-width 1200px ([D-076](#d-076-dialogue-box-max-width--1200px-oq-29-resolution)). NO portraits — the NPC is on screen, a portrait is redundant. Monologue floats ABOVE the dialogue box on z-layer 7 — spatial separation allows monologue to contradict dialogue visually (character thinks one thing while NPC says another). Walk-away via WASD, dialogue fades over 300ms, no close button ([D-064](content.md#d-064-walk-away--three-phase-consequences)). Auto-pause in single-player when implant UI is open; overlay design for multiplayer readiness.
|
||||
- **Unified conversation log (Sprint 14, #535):** The dialogue box is a single chronological log — not separate panels for active vs passive dialogue. Player-NPC conversations and overheard NPC-NPC conversations ([D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter)) flow into the same scrolling log. Each entry shows `Speaker → Target: text` with per-character name colours (hash-indexed from configurable palette in `data/dialogue-theme.yaml`). Player response options render below the log; max 3 visible. Locked options invisible ([D-062](content.md#d-062-invisible-locked-dialogue-options)).
|
||||
- **Unified conversation log (Sprint 14, #535):** The dialogue box is a single chronological log for player-NPC conversations. Each entry shows `Speaker → Target: text` with per-character name colours (hash-indexed from configurable palette in `data/dialogue-theme.yaml`). Player response options render below the log; max 3 visible. Locked options invisible ([D-062](content.md#d-062-invisible-locked-dialogue-options)).
|
||||
- **Entry lifecycle:** All entries share the same timeout (15s + 3s fade, configurable via theme YAML). Walk-away clears response options but preserves log entries — earned information is fair game. Panel auto-hides when all entries expire and no active conversation is in progress.
|
||||
- **Passive overheard lines:** Rendered at reduced opacity (0.9) per [D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter). No response options for overheard content. Walk-away does not fire for passive-only display — the panel dismisses naturally when entries expire or the player walks out of earshot.
|
||||
- **Passive overheard lines:** D-078 (overheard NPC conversations) was scrapped per R-012. Passive dialogue display will be redesigned after Phase 5 walkable environment.
|
||||
- **Rationale:** Game world stays live above the dialogue box — player sees NPC body language while talking. Monologue above + dialogue below = the character can think one thing while saying another. Max 3 options + invisible locks = player never knows what they're missing. No portrait because the NPC IS on screen. A single unified log avoids a separate UI element for overheard content and makes the flow of conversation feel natural — active and passive dialogue interleave chronologically.
|
||||
- **Cross-reference:** Invisible locks ([D-062](content.md#d-062-invisible-locked-dialogue-options)), confrontation ([D-063](content.md#d-063-confrontation--same-box-different-weight)), walk-away ([D-064](content.md#d-064-walk-away--three-phase-consequences)), z-stack ([D-049](#d-049-z-level-rendering-stack-8-layers)), max-width ([D-076](#d-076-dialogue-box-max-width--1200px-oq-29-resolution)), overheard NPC conversation ([D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter))
|
||||
- **Source:** Control & Interaction Workshop (2026-02-13). Amended Sprint 14 (#535): unified log architecture.
|
||||
@@ -424,7 +424,8 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
|
||||
- **Raised by:** Workshop — unanimous
|
||||
- **Dissent:** None
|
||||
- **Implements:** Ticket #548
|
||||
- **Cross-reference:** [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-083](#d-083-contradiction-detection-pipeline), [D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter)
|
||||
- **Cross-reference:** [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-083](#d-083-contradiction-detection-pipeline), [D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter) (D-078 scrapped per R-012; `transfer_npc_knowledge` retained for Phase 5 rewire — see Amendment 2026-04-19)
|
||||
- **Amendment (2026-04-19, R-012 / #848):** D-078 was scrapped (R-012) and the `run_npc_conversations` system was deleted in #848. The `transfer_npc_knowledge` system is **retained in-tree for Phase 5 rewire** but no longer fires in production — its `Added<NpcConversation>` trigger is now only inserted by test fixtures. The design (trust-gated transfer, dual-mutable KG access, `KnowsOf` confidence cap, `disclosure_blocked` honoring) is preserved; Phase 5 will wire a new proximity/dialogue trigger in its place. Until then, treat the system as dormant and guard against assuming it runs.
|
||||
|
||||
### D-081: Unprompted Disclosure Design
|
||||
- **Date:** 2026-02-24
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
title: "Bincode v1 → v2 Migration — Risk Audit (Sprint 36)"
|
||||
description: "Audit of bincode usage in the server crate, risk assessment, and migration recommendation for ticket #636."
|
||||
type: architecture
|
||||
status: final
|
||||
ticket: "#636"
|
||||
author: "Tyre"
|
||||
created: 2026-04-19
|
||||
updated: 2026-04-19
|
||||
---
|
||||
|
||||
# Bincode Migration — Risk Audit
|
||||
|
||||
**Ticket:** #636 (Migrate bincode v1.x to v2.x)
|
||||
**Advisory:** RUSTSEC-2025-0141 (bincode v1.3.3 unmaintained)
|
||||
**Author:** Tyre
|
||||
**TL;DR:** **bincode is an orphan dependency — nothing in the server crate actually calls it. Remove it outright. The "migration" is a four-line change.**
|
||||
|
||||
---
|
||||
|
||||
## 1. What the audit found
|
||||
|
||||
Grep-audit of the **entire repository**, not just `server/src/`:
|
||||
|
||||
```bash
|
||||
grep -rn "use bincode\|bincode::" server/ tests/ tooling/ --include="*.rs"
|
||||
# → 0 hits
|
||||
```
|
||||
|
||||
Cargo manifest references:
|
||||
|
||||
```bash
|
||||
grep -rn "bincode" server/ --include="*.toml" --include="*.lock"
|
||||
# → server/Cargo.toml:19 bincode = "1"
|
||||
# → server/Cargo.lock:329 [[package]] name = "bincode" version = "1.3.3"
|
||||
# → server/Cargo.lock:1311 " bincode"," — under settled-reach-server deps
|
||||
# → server/audit.toml:6 ignore = ["RUSTSEC-2025-0141"]
|
||||
```
|
||||
|
||||
No Rust source file in **any** crate (`server/`, `tests/`, `tooling/`) contains the string `bincode`. The lockfile entry under `tooling/test-client` shows bincode transiting through `rmp-serde` or a sibling — **not** from direct use.
|
||||
|
||||
**Conclusion:** `bincode = "1"` in `server/Cargo.toml` was added in anticipation of save-load / Rust-Rust sync (see `docs/workshops/v01-gap-analysis/round1-tyre.md`, `docs/workshops/save-load-architecture/workshop-brief.md`) but **the implementation path chose `rmp-serde` / MessagePack instead** (see `server/src/bridge/types.rs` — tests use `rmp_serde::to_vec_named` and `rmp_serde::from_slice`, line 1052 onward).
|
||||
|
||||
Bincode is a dead dependency.
|
||||
|
||||
## 2. Recommended migration: DELETE, don't bump
|
||||
|
||||
### 2.1 The actual changes
|
||||
|
||||
**server/Cargo.toml** — remove line 19:
|
||||
```diff
|
||||
-bincode = "1"
|
||||
```
|
||||
|
||||
**server/audit.toml** — remove the ignore (lines 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<u8> = bincode::serialize(&value)?;
|
||||
let value: MyType = bincode::deserialize(&bytes)?;
|
||||
```
|
||||
|
||||
**v2 (stable):**
|
||||
```rust
|
||||
// New "Encode"/"Decode" derives, explicit config.
|
||||
use bincode::{config, encode_to_vec, decode_from_slice};
|
||||
|
||||
let cfg = config::standard();
|
||||
let bytes: Vec<u8> = encode_to_vec(&value, cfg)?;
|
||||
let (value, _used): (MyType, usize) = decode_from_slice(&bytes, cfg)?;
|
||||
```
|
||||
|
||||
**Derive change:** v2 introduced its own `#[derive(bincode::Encode, bincode::Decode)]` traits. If the type must stay serde-compatible (required for us — we use `rmp-serde` and `ron` side-by-side), use the compat shim:
|
||||
|
||||
```rust
|
||||
use bincode::serde::{encode_to_vec, decode_from_slice};
|
||||
let bytes = encode_to_vec(&value, config::standard())?;
|
||||
let (value, _) = decode_from_slice::<MyType, _>(&bytes, config::standard())?;
|
||||
```
|
||||
|
||||
This keeps `#[derive(Serialize, Deserialize)]` as the only derives on the data types — no dual-derive required. That matters because the same types cross the MessagePack boundary via `rmp-serde`.
|
||||
|
||||
### 3.2 Config
|
||||
|
||||
v2 makes encoding config explicit. `config::standard()` uses variable-int, little-endian — matches v1 default for our types (no floats in the save shape today, so endian parity is not critical). For perfectly-byte-identical output to v1, use `config::legacy()`. **Any new adoption should use `config::standard()`** — don't inherit v1 quirks.
|
||||
|
||||
### 3.3 Known gotchas (for future reference)
|
||||
|
||||
- v2 does **not** auto-handle untagged serde enums in the compat layer (pre-v2.0.1); if we adopt it and hit an untagged enum, use `bincode::serde::Compat`.
|
||||
- v2's `decode_from_slice` returns the byte count consumed — v1 silently ignored trailing bytes. Useful for streaming multi-message frames; irrelevant for one-shot save files.
|
||||
- The `bincode::options()` builder from v1 (`with_fixint_encoding()` etc.) is gone — replaced by `config::Configuration`.
|
||||
- Binary format is **not** compatible across v1 ↔ v2. Any v1-written blob is unreadable by v2. (This is moot for us — we have none.)
|
||||
|
||||
### 3.4 Touch points if we were actually migrating
|
||||
|
||||
None. Literally no source file imports or uses it.
|
||||
|
||||
---
|
||||
|
||||
## 4. For Dudley — execution checklist
|
||||
|
||||
1. Delete `bincode = "1"` from `server/Cargo.toml`.
|
||||
2. Delete the `RUSTSEC-2025-0141` ignore block from `server/audit.toml`.
|
||||
3. `cargo check --workspace` — regenerates `Cargo.lock`.
|
||||
4. `cargo test --workspace` — must pass.
|
||||
5. `cargo audit` — must not print RUSTSEC-2025-0141 anymore.
|
||||
6. Commit:
|
||||
```
|
||||
fix(deps): remove unused bincode dependency (#636)
|
||||
|
||||
RUSTSEC-2025-0141 no longer relevant — bincode was declared but
|
||||
never imported. Drop the crate and the audit ignore. Future
|
||||
save-load work that wants bincode should adopt v2 fresh.
|
||||
```
|
||||
|
||||
**Estimated effort:** ~15 minutes including verification.
|
||||
|
||||
## 5. What this means for docs
|
||||
|
||||
One doc to update: `docs/sprints/sprint-27/server.md` line 93 mentions the audit ignore. Either leave it (it's historical notes) or strike through. Not blocking.
|
||||
|
||||
---
|
||||
|
||||
**Audit status:** Complete. Recommendation: remove bincode entirely. If the team prefers "migrate now, don't remove" (symbolic commitment to the migration path), say the word and I'll spec that instead — but it costs more with zero benefit given the usage survey.
|
||||
@@ -0,0 +1,334 @@
|
||||
---
|
||||
title: "Bookmark Definition — Contract Spec (Sprint 36)"
|
||||
description: "Struct shape, module placement, and bridge protocol for the CK3-style bookmark system. Contract between server #614 and client #618."
|
||||
type: architecture
|
||||
status: draft
|
||||
ticket: "#614"
|
||||
decision_refs: [D-115, D-117, D-118, D-128, D-146]
|
||||
author: "Tyre"
|
||||
created: 2026-04-19
|
||||
updated: 2026-04-19
|
||||
---
|
||||
|
||||
# Bookmark Definition — Contract Spec
|
||||
|
||||
**Tickets:** server #614 (implementation), client #618 (consumer)
|
||||
**Decisions:** D-115 (creation = skills + bookmark), D-117 (tycoon is the v0.2 bookmark), D-118 (start = small business owner), D-128 (culture implicit in location), D-146 (tile-scale preview — not in contract)
|
||||
**Scope:** Minimum viable bookmark enumeration + selection. Skills live in a sibling system (#618 territory). Culture is derived from `starting_location_id` via the #679 API — NOT a field on `BookmarkDefinition`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
A bookmark is a **named starting scenario** the player chooses at character creation. It bundles:
|
||||
- A display identity (title, subtitle, flavor blurb) — what the player reads.
|
||||
- A starting-state seed (location, career, a small set of seed parameters) — what the simulation consumes.
|
||||
|
||||
The client enumerates available bookmarks on the character-creation screen and emits a selected `bookmark_id` + `starting_location_id` when the player confirms.
|
||||
|
||||
For v0.2 there is exactly one bookmark: `tycoon`. The system is built for one but must not hard-code one — future bookmarks (explorer, homesteader, etc.) plug in as additional static entries.
|
||||
|
||||
## 2. Module placement
|
||||
|
||||
Per server-team convention (see `server/src/settings/`, `server/src/knowledge/`), bookmarks get their own top-level module:
|
||||
|
||||
```
|
||||
server/src/bookmark/
|
||||
├── mod.rs # Plugin, registry resource, public API
|
||||
└── types.rs # BookmarkDefinition, BookmarkId, wire types
|
||||
```
|
||||
|
||||
Registered as a `BookmarkPlugin` and added to the `App` alongside `SettingsPlugin` and `KnowledgePlugin`. Exports flow through `server/src/lib.rs`:
|
||||
|
||||
```rust
|
||||
pub mod bookmark; // new
|
||||
```
|
||||
|
||||
Rationale: parallel to settings/knowledge — bookmarks are a first-class domain, not simulation state. A sub-module under `settings/` would be wrong (settings are player prefs; bookmarks are content).
|
||||
|
||||
## 3. Rust types
|
||||
|
||||
### 3.1 BookmarkId (stable string key)
|
||||
|
||||
```rust
|
||||
/// Stable identifier for a bookmark definition.
|
||||
///
|
||||
/// String-backed (not an enum) so new bookmarks can be added without bumping
|
||||
/// the protocol version. v0.2 ships exactly one: `"tycoon"`.
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||
pub struct BookmarkId(pub String);
|
||||
|
||||
impl BookmarkId {
|
||||
pub const TYCOON: &'static str = "tycoon";
|
||||
pub fn as_str(&self) -> &str { &self.0 }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 BookmarkDefinition (server-internal)
|
||||
|
||||
```rust
|
||||
/// Full static definition of a bookmark. Loaded once at startup, immutable
|
||||
/// at runtime. Lives server-side; a projection (`BookmarkWire`) crosses the
|
||||
/// bridge.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BookmarkDefinition {
|
||||
/// Stable ID (e.g. `"tycoon"`).
|
||||
pub id: BookmarkId,
|
||||
|
||||
/// Short display title for the bookmark card (≤32 chars).
|
||||
/// e.g. "Tycoon" — shown as the tab/tile header.
|
||||
pub title: String,
|
||||
|
||||
/// One-line subtitle under the title (≤64 chars).
|
||||
/// e.g. "Small business owner on the make."
|
||||
pub subtitle: String,
|
||||
|
||||
/// Flavor blurb shown on selection. 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<String>,
|
||||
|
||||
/// Career seed — determines initial skills weighting, inventory, and
|
||||
/// starting business. Enum so downstream systems (skill seeder,
|
||||
/// apartment generator, monologue pool selector) can pattern-match.
|
||||
pub career: CareerKind,
|
||||
|
||||
/// Starting capital in Tractus (D-118 small-business scale — not mogul).
|
||||
pub starting_capital_tractus: i64,
|
||||
|
||||
/// Visible in the character-creation screen. Use `false` to author
|
||||
/// work-in-progress bookmarks without exposing them to the client.
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
/// Career seed. v0.2: `Tycoon` only; extensible.
|
||||
/// Used server-side to route into career-specific initialization
|
||||
/// (monologue pool, apartment generator seed, starting inventory).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum CareerKind {
|
||||
/// Tycoon career — small business owner, D-117/D-118.
|
||||
Tycoon,
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 BookmarkRegistry (Bevy resource)
|
||||
|
||||
```rust
|
||||
/// Immutable at runtime: built once during `BookmarkPlugin::build`.
|
||||
/// BTreeMap for deterministic iteration (D-010 principle 4, D-041).
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct BookmarkRegistry {
|
||||
entries: BTreeMap<String, BookmarkDefinition>,
|
||||
}
|
||||
|
||||
impl BookmarkRegistry {
|
||||
pub fn get(&self, id: &str) -> Option<&BookmarkDefinition> { ... }
|
||||
pub fn available(&self) -> impl Iterator<Item = &BookmarkDefinition> { ... }
|
||||
pub fn contains(&self, id: &str) -> bool { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Wire protocol (client-facing)
|
||||
|
||||
### 4.1 `BookmarkWire` — the projection that crosses the bridge
|
||||
|
||||
Drop server-only fields (none today, but keep the two types separate so future additions — e.g. a `validation` closure — don't leak through serde). Locates in `server/src/bridge/types.rs` next to the other `*Wire` structs.
|
||||
|
||||
```rust
|
||||
/// Bookmark projection for the client. Sent as a catalog in
|
||||
/// `ObserverSnapshot.bookmark_catalog` immediately after handshake
|
||||
/// (and re-sent once if the client re-requests via PlayerAction).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BookmarkWire {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub flavor: String,
|
||||
pub default_location: String,
|
||||
pub allowed_locations: Vec<String>,
|
||||
pub career: CareerKindWire, // mirror of CareerKind, #[serde(rename_all = "snake_case")]
|
||||
pub starting_capital_tractus: i64,
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Delivery — where bookmarks show up on the wire
|
||||
|
||||
**Option A (chosen): piggyback on `ObserverSnapshot`.** Add a new field:
|
||||
|
||||
```rust
|
||||
// In ObserverSnapshot (server/src/bridge/types.rs)
|
||||
//
|
||||
// v22 adds: bookmark_catalog (#614, D-115/D-117).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bookmark_catalog: Option<BookmarkCatalog>,
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BookmarkCatalog {
|
||||
pub bookmarks: Vec<BookmarkWire>,
|
||||
}
|
||||
```
|
||||
|
||||
**Semantics:**
|
||||
- Populated for **exactly one tick** after the handshake completes, and for **exactly one tick** after a `PlayerAction::RequestBookmarkCatalog` arrives. `None` otherwise.
|
||||
- Same pattern as `settings_response` (#627) and `economy_snapshot` (#822) — one-shot catalog responses live in their own `Option<...>` field, not in the main snapshot every tick.
|
||||
- Bumps `PROTOCOL_VERSION`. **Coordination note:** #848 (conversation-system retirement) also lands a wire-format change this sprint. Whichever PR lands first takes the next version number (22); the second rebases and takes the one after. Update the protocol comment in `bridge/types.rs` with the correct ticket ref when you land.
|
||||
|
||||
**Rejected:** a separate out-of-band message type. The bridge already framed MessagePack as `ObserverSnapshot`-shaped (`HandshakeMessage` + `StartupMessage` are the only exceptions and both exist for lifecycle reasons). A third top-level message type would need handling in `local.rs` + `tcp.rs` + test harness without buying anything over a snapshot field.
|
||||
|
||||
### 4.3 New PlayerAction variants
|
||||
|
||||
```rust
|
||||
// In PlayerAction
|
||||
/// Client requests the full bookmark catalog (#614).
|
||||
/// Server responds with `ObserverSnapshot.bookmark_catalog` in the next tick.
|
||||
RequestBookmarkCatalog,
|
||||
|
||||
/// Player confirms character creation with a chosen bookmark (#614, #618).
|
||||
/// `bookmark_id` must match a `BookmarkId` the server emitted in
|
||||
/// BookmarkCatalog. `starting_location_id` must be in
|
||||
/// `BookmarkDefinition.allowed_locations` for that bookmark.
|
||||
///
|
||||
/// On invalid `bookmark_id` or `starting_location_id`: server pushes a
|
||||
/// `SimError { kind: ProtocolError, ... }` — client should treat as a
|
||||
/// fatal character-creation error (can't start the game).
|
||||
ConfirmBookmark {
|
||||
bookmark_id: String,
|
||||
starting_location_id: String,
|
||||
},
|
||||
```
|
||||
|
||||
Note: `ConfirmBookmark` is the **trigger** for transitioning from the character-creation screen into the live world. The downstream chain (apartment generation, starting knowledge seed, culture resolution via #679) fires on this action. Details of that chain are out of scope for this spec — #614 just delivers the action into the input queue and records the selection on a new resource.
|
||||
|
||||
### 4.4 Server-side selection state
|
||||
|
||||
```rust
|
||||
/// The confirmed bookmark selection for the current session.
|
||||
/// Populated when `ConfirmBookmark` is processed. `None` during the
|
||||
/// character-creation phase (before confirm) and always `None` in a
|
||||
/// fresh session.
|
||||
///
|
||||
/// **v0.2 scope: transient only.** Not serialized — save/load of
|
||||
/// `SelectedBookmark` is deferred to Sprint 37 (follow-up ticket
|
||||
/// filed alongside #614). Add `Serialize`/`Deserialize` derives and
|
||||
/// wire into `SaveState` when that ticket is claimed.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct SelectedBookmark {
|
||||
pub bookmark_id: Option<String>,
|
||||
pub starting_location_id: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
Downstream systems (apartment generator, skill seeder) read from this
|
||||
resource.
|
||||
|
||||
**Save/load scope (v0.2 deferred):** `SelectedBookmark` is transient for
|
||||
v0.2 — it lives in-memory from `ConfirmBookmark` through session end and
|
||||
is not persisted. A reload after quit returns the player to the
|
||||
character-creation screen. Promotion to persistent state (adding
|
||||
`Serialize`/`Deserialize` and threading into `SaveState` / #553) is
|
||||
tracked in a follow-up ticket for Sprint 37. `SelectedBookmark` must
|
||||
carry an inline `// TODO(sprint-37): serialize — see #<follow-up ticket>`
|
||||
comment in `server/src/bookmark/mod.rs` pointing at the follow-up so the
|
||||
omission is greppable.
|
||||
|
||||
## 5. Content source — how bookmarks get into the registry
|
||||
|
||||
v0.2 scope: **hard-coded in `server/src/bookmark/mod.rs`**. A single entry:
|
||||
|
||||
```rust
|
||||
fn register_default_bookmarks(registry: &mut BookmarkRegistry) {
|
||||
registry.insert(BookmarkDefinition {
|
||||
id: BookmarkId("tycoon".to_string()),
|
||||
title: "Tycoon".into(),
|
||||
subtitle: "Small business owner on the make.".into(),
|
||||
flavor: "<Mellanie to author — 2–4 sentences>".into(),
|
||||
default_location: "GJ 35".into(), // TBD — confirm with Miri
|
||||
allowed_locations: vec!["GJ 35".into()],
|
||||
career: CareerKind::Tycoon,
|
||||
starting_capital_tractus: 5_000,
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Why not TOML/YAML from disk?**
|
||||
- One bookmark. File IO adds failure modes (missing file, parse errors) with no upside.
|
||||
- When the second bookmark lands (Sprint 38+?), promote to `content/bookmarks/*.toml` — 1 day of work, pattern already established by `content/brands/`.
|
||||
|
||||
**Flavor text:** `flavor` is `<Mellanie to author>` on first pass. Ping her when #614 lands so she can fill it in before #618 renders it.
|
||||
|
||||
**Default location:** Van Maanen's Star's `system_id`. Worth double-checking with Miri that it's `GJ 35` vs another entry in `server/data/systems.db`. Tagged as TBD in the code until confirmed.
|
||||
|
||||
## 6. Handshake-time flow
|
||||
|
||||
```
|
||||
client server
|
||||
| |
|
||||
|-- TCP/stdio connect ---------------->|
|
||||
| |
|
||||
|<----------- HandshakeMessage --------|
|
||||
| |
|
||||
|-- StartupMessage (seed, archetype)-->|
|
||||
| |
|
||||
| [server initializes BookmarkRegistry]
|
||||
| |
|
||||
|<-- ObserverSnapshot(tick=0) ---------| <-- bookmark_catalog: Some(...)
|
||||
| | (and nothing else interesting;
|
||||
| | no entities, no player)
|
||||
| |
|
||||
| [client renders character creation screen]
|
||||
| |
|
||||
|-- PlayerAction::ConfirmBookmark ---->|
|
||||
| |
|
||||
| [server reads SelectedBookmark,
|
||||
| spawns player entity, runs
|
||||
| apartment generator, etc.]
|
||||
| |
|
||||
|<-- ObserverSnapshot(tick=1...) ------| <-- normal gameplay begins
|
||||
```
|
||||
|
||||
The client MAY send `PlayerAction::RequestBookmarkCatalog` explicitly (e.g. if it missed tick 0) — server re-sends the same catalog.
|
||||
|
||||
## 7. What this spec does NOT cover
|
||||
|
||||
- **Skills.** D-115 includes skills in character creation, but skill selection is a parallel system; the bookmark just seeds the initial weighting via `career`. See #618 and a separate spec (not yet written).
|
||||
- **Culture resolution.** Culture is NOT on `BookmarkDefinition`. The client calls the `resolve_culture(location_id)` function from the #679 contract. See `sprint-36-culture-api-spec.md`.
|
||||
- **Apartment/starting-state generation.** Downstream of `ConfirmBookmark`. Fires when `SelectedBookmark` is populated. Out of scope for this ticket.
|
||||
- **Bookmark preview asset.** D-146 (tile-scale preview) is rendered client-side from the player's own character descriptor, not a server asset on `BookmarkDefinition`.
|
||||
- **Save-format integration.** `SelectedBookmark` is a resource; save/load (#553, D-085) already serializes resources — adding two fields is a line-item for the save-state author.
|
||||
|
||||
## 8. Implementation plan (for Dudley)
|
||||
|
||||
**Size:** ~1 day. Straightforward Bevy plugin + bridge types + one hard-coded entry.
|
||||
|
||||
| Step | File | Work |
|
||||
|------|------|------|
|
||||
| 1 | `server/src/bookmark/mod.rs` (new) | `BookmarkPlugin`, `BookmarkRegistry` resource, `SelectedBookmark` resource, `register_default_bookmarks` fn |
|
||||
| 2 | `server/src/bookmark/types.rs` (new) | `BookmarkId`, `BookmarkDefinition`, `CareerKind` |
|
||||
| 3 | `server/src/lib.rs` | `pub mod bookmark;` |
|
||||
| 4 | `server/src/bridge/types.rs` | `BookmarkWire`, `CareerKindWire`, `BookmarkCatalog`, `PROTOCOL_VERSION` bump to the next available number (coordinate with #848 — see §4.2), `bookmark_catalog` field on `ObserverSnapshot`, `RequestBookmarkCatalog` + `ConfirmBookmark` `PlayerAction` variants |
|
||||
| 5 | `server/src/main.rs` (or wherever `App` is built) | `App.add_plugins(BookmarkPlugin)` |
|
||||
| 6 | `server/src/perception/observer.rs` (`compute_observer_snapshot`) | Drain a `PendingBookmarkCatalog` flag — populate `bookmark_catalog` for one tick after handshake OR after `RequestBookmarkCatalog` |
|
||||
| 7 | `server/src/simulation/input.rs` (or wherever PlayerAction is dispatched) | Handle `RequestBookmarkCatalog` (set the flag) and `ConfirmBookmark` (validate against registry, write `SelectedBookmark`, push `SimError` on invalid) |
|
||||
| 8 | Unit tests in `server/src/bookmark/mod.rs` | Registry construction, round-trip serialization of `BookmarkWire`, `ConfirmBookmark` validation |
|
||||
| 9 | Update `server/src/bridge/types.rs` module doc comment | "v22 adds: bookmark_catalog (#614, D-115/D-117)" |
|
||||
|
||||
## 9. Open questions
|
||||
|
||||
1. **Default `starting_location_id` for tycoon** — is it `"GJ 35"`, `"GJ 144"`, or a station-level ID? Need Miri's call. Flag as TODO in code; doesn't block implementation.
|
||||
2. **Flavor text** — Mellanie to write once #614 lands. Temporarily use a placeholder; client handles empty strings gracefully.
|
||||
3. **Later bookmarks** — when a second bookmark is planned, promote to TOML. Not a v0.2 concern.
|
||||
|
||||
---
|
||||
|
||||
**Contract status:** Ready for #614 implementation. Client #618 can start against this spec once the server ticket opens a PR with the bridge types landed (any Sprint 36 mid-point is fine).
|
||||
@@ -0,0 +1,293 @@
|
||||
---
|
||||
title: "Location → Culture Resolution — Contract Spec (Sprint 36)"
|
||||
description: "Function signature, module placement, and error semantics for the culture resolver. Contract between server #679 and client #680."
|
||||
type: architecture
|
||||
status: draft
|
||||
ticket: "#679"
|
||||
decision_refs: [D-010, D-041, D-121, D-128]
|
||||
author: "Tyre"
|
||||
created: 2026-04-19
|
||||
updated: 2026-04-19
|
||||
---
|
||||
|
||||
# Location → Culture Resolution — Contract Spec
|
||||
|
||||
**Tickets:** server #679 (implementation), client #680 (consumer), downstream #621 NPC personality, #681 apartment generator
|
||||
**Decisions:** D-128 (culture implicit in starting location), D-121 (voice is culture-driven), D-010 (info boundaries), D-041 (BTreeMap mandate)
|
||||
**Scope:** A pure read-only lookup function: `location_id → culture_tag`. No mutation, no IPC, no generation. This is the **ground truth** that downstream pipelines (voice, NPC blueprint, apartment generator, visual grammar) will pull from.
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
D-128 established that culture is implicit in the starting bookmark location — Van Maanen's Star start = Van Maanen's Star culture. To keep that decision load-bearing rather than aspirational, the server needs one canonical function every downstream consumer calls. Without that single function, each consumer re-implements lookup against `systems.db`, drifts apart, and D-128 becomes a handshake instead of a contract.
|
||||
|
||||
This spec is that function.
|
||||
|
||||
## 2. Module placement
|
||||
|
||||
```
|
||||
server/src/knowledge/culture.rs (new)
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- Culture is a **world-knowledge** property (what the world is), not a simulation tick system (what the world is doing). It belongs under `knowledge/` alongside the knowledge graph — both are *what is true about the world*, read-only from most callers.
|
||||
- NOT `server/src/settings/culture.rs`: `settings/` is player-config storage; putting world data there confuses the domain.
|
||||
- NOT a new top-level `server/src/culture/`: the resolver is ~150 LOC, and a dedicated top-level module is heavier than it deserves. If the culture system grows (rules engine, inheritance, overrides for named cities), promote to top-level later — cheap refactor.
|
||||
|
||||
**Wiring:**
|
||||
- Expose at `crate::knowledge::culture::{CultureTag, CultureError, resolve_culture}`.
|
||||
- Re-export from `server/src/knowledge/mod.rs` for ergonomics:
|
||||
```rust
|
||||
pub mod culture;
|
||||
pub use culture::{CultureTag, CultureError, resolve_culture};
|
||||
```
|
||||
|
||||
## 3. Public API
|
||||
|
||||
### 3.1 Types
|
||||
|
||||
```rust
|
||||
/// Canonical culture identifier.
|
||||
///
|
||||
/// String-backed (NOT an enum) — cultures expand with content, not code.
|
||||
/// Value space matches `star_systems.cultural_corridor` in systems.db:
|
||||
/// "core", "sol-gateway-axis", "north_reach", "south_reach",
|
||||
/// "east_reach", "west_reach", "deep_frontier".
|
||||
///
|
||||
/// Wire format: passed as plain `String` over IPC (mirrors system_id handling).
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Hash, Ord, PartialOrd,
|
||||
Serialize, Deserialize)]
|
||||
pub struct CultureTag(pub String);
|
||||
|
||||
impl CultureTag {
|
||||
pub fn as_str(&self) -> &str { &self.0 }
|
||||
}
|
||||
|
||||
/// Error from culture resolution. See `resolve_culture`.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CultureError {
|
||||
/// `location_id` does not resolve to any known row in systems.db.
|
||||
/// Either a typo / stale bookmark, or the DB is out of sync with code.
|
||||
#[error("unknown location: `{0}`")]
|
||||
UnknownLocation(String),
|
||||
|
||||
/// The location matched a row but its culture column is NULL.
|
||||
/// This is a **data bug** — every inhabited row should have a culture.
|
||||
/// Callers should log loudly; see §5 on fallback policy.
|
||||
#[error("no culture assigned to location `{0}` in systems.db")]
|
||||
NoCulture(String),
|
||||
|
||||
/// Underlying SQLite error. Wraps `rusqlite::Error` to avoid leaking
|
||||
/// the rusqlite type to crates that don't depend on it.
|
||||
#[error("database error: {0}")]
|
||||
Db(String),
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Function signature
|
||||
|
||||
```rust
|
||||
/// Resolve a location identifier to the culture that location implies (D-128).
|
||||
///
|
||||
/// # Input
|
||||
/// `location_id` — a location string. Accepted forms:
|
||||
/// 1. `system_id` — e.g. `"GJ 35"`. Matched against `star_systems.system_id`.
|
||||
/// 2. `body_id` — e.g. `"GJ 35-2"`. Matched against `bodies.body_id`.
|
||||
/// If the body has `cultural_corridor` set, that wins; otherwise the
|
||||
/// parent system's `cultural_corridor` is used.
|
||||
/// 3. `station_id` — e.g. `"sova-transit"`. Matched against
|
||||
/// `stations.station_id`. Falls through to parent system.
|
||||
///
|
||||
/// The resolver tries each table in order and returns the first match.
|
||||
/// For v0.2 (bookmark selection), callers will pass `system_id` — but the
|
||||
/// function is body/station-aware from day one so downstream systems
|
||||
/// (apartment generator, NPC spawn) don't need a second lookup.
|
||||
///
|
||||
/// # Output
|
||||
/// `Ok(CultureTag)` — the canonical culture for the location.
|
||||
///
|
||||
/// # Errors
|
||||
/// - `CultureError::UnknownLocation` — `location_id` not in any table.
|
||||
/// - `CultureError::NoCulture` — row found but culture column NULL.
|
||||
/// - `CultureError::Db` — SQLite I/O failure.
|
||||
///
|
||||
/// # Determinism
|
||||
/// Pure function of `(location_id, snapshot of systems.db)`. No RNG, no tick
|
||||
/// state. `systems.db` is shipped read-only with the game (see schema
|
||||
/// comment), so repeated calls always return the same value.
|
||||
///
|
||||
/// # Performance
|
||||
/// Caller provides the `&CultureResolver` (caches connection + prepared
|
||||
/// statements). A single resolution is a single indexed lookup —
|
||||
/// sub-microsecond. Safe to call per-tick if needed, though for bookmark
|
||||
/// selection this is a one-shot.
|
||||
pub fn resolve_culture(
|
||||
resolver: &CultureResolver,
|
||||
location_id: &str,
|
||||
) -> Result<CultureTag, CultureError>;
|
||||
```
|
||||
|
||||
### 3.3 `CultureResolver` (the handle)
|
||||
|
||||
```rust
|
||||
/// Handle that owns the DB connection + prepared statements.
|
||||
/// Constructed once at startup, cheap to clone-reference across callers.
|
||||
/// Internally uses `Mutex<Connection>` (mirrors `SettingsStoreResource`).
|
||||
pub struct CultureResolver { /* private */ }
|
||||
|
||||
impl CultureResolver {
|
||||
/// Open the resolver against `server/data/systems.db` (default) or a
|
||||
/// test fixture. Read-only — opens with `SQLITE_OPEN_READ_ONLY`.
|
||||
pub fn open(path: &Path) -> Result<Self, CultureError>;
|
||||
}
|
||||
|
||||
/// Bevy Resource wrapper so systems can grab a `Res<CultureResolverResource>`.
|
||||
#[derive(Resource)]
|
||||
pub struct CultureResolverResource(pub CultureResolver);
|
||||
```
|
||||
|
||||
**Why not a top-level free function reading `systems.db` on every call?**
|
||||
A connection-per-call serializes SQLite open latency (few ms × N callers) and forces error handling at every call site. Single owned handle = one place to configure, one place to fail-fast at startup.
|
||||
|
||||
## 4. Lookup algorithm (implementation sketch)
|
||||
|
||||
```rust
|
||||
fn resolve_culture(resolver: &CultureResolver, loc: &str)
|
||||
-> Result<CultureTag, CultureError>
|
||||
{
|
||||
let conn = resolver.0.lock().map_err(|e| CultureError::Db(e.to_string()))?;
|
||||
|
||||
// 1. Try as system_id.
|
||||
if let Some(c) = query_system_culture(&conn, loc)? {
|
||||
return Ok(CultureTag(c));
|
||||
}
|
||||
|
||||
// 2. Try as body_id — body override OR parent system.
|
||||
if let Some(c) = query_body_culture(&conn, loc)? {
|
||||
return Ok(CultureTag(c));
|
||||
}
|
||||
|
||||
// 3. Try as station_id — currently always falls through to parent system.
|
||||
if let Some(c) = query_station_culture(&conn, loc)? {
|
||||
return Ok(CultureTag(c));
|
||||
}
|
||||
|
||||
Err(CultureError::UnknownLocation(loc.to_string()))
|
||||
}
|
||||
```
|
||||
|
||||
Each helper distinguishes *row not found* (return `Ok(None)`, fall through) from *row found but NULL culture* (return `Err(CultureError::NoCulture)` — this is a data bug, not a miss).
|
||||
|
||||
SQL:
|
||||
|
||||
```sql
|
||||
-- query_system_culture
|
||||
SELECT cultural_corridor FROM star_systems WHERE system_id = ?;
|
||||
|
||||
-- query_body_culture
|
||||
SELECT COALESCE(b.cultural_corridor, s.cultural_corridor)
|
||||
FROM bodies b
|
||||
JOIN star_systems s ON s.system_id = b.system_id
|
||||
WHERE b.body_id = ?;
|
||||
|
||||
-- query_station_culture
|
||||
SELECT s.cultural_corridor
|
||||
FROM stations st
|
||||
JOIN star_systems s ON s.system_id = st.system_id
|
||||
WHERE st.station_id = ?;
|
||||
```
|
||||
|
||||
(If `bodies` or `stations` don't expose `cultural_corridor` at schema level by the time #679 lands, start with system-only and add body/station passes in a follow-up. The function signature is stable either way — it already takes an opaque `location_id`.)
|
||||
|
||||
## 5. Error handling for callers
|
||||
|
||||
| Scenario | Server response | Client expectation |
|
||||
|----------|----------------|--------------------|
|
||||
| `UnknownLocation` during bookmark flow | Push `SimError::ProtocolError` via `SimErrorBuffer` and reject `ConfirmBookmark` | Character creation error — disallow confirm, re-enable picker |
|
||||
| `NoCulture` during bookmark flow | Same as above, plus `tracing::error!` — this is a DB bug | Same (user-facing) — but file a bug; should not happen |
|
||||
| `Db` during bookmark flow | Server shuts down (same as any fatal storage failure) | Session terminates |
|
||||
|
||||
**No silent fallback.** D-128 is load-bearing: if we fall back to a default culture on unknown location, we erase the signal and every downstream pipeline gets corrupted input. Loud error > quiet wrong answer.
|
||||
|
||||
**Special case — test worlds:** The Gauntlet and other test maps use synthetic location IDs (e.g. `"gauntlet:room-7"`) that aren't in `systems.db`. The resolver's caller handles this: at test-world init we insert a `SelectedBookmark { starting_location_id: "gauntlet:hub" }` and route those through a hard-coded `"core"` culture assignment before the resolver is consulted. The resolver itself stays pure.
|
||||
|
||||
## 6. Determinism and thread safety
|
||||
|
||||
- `rusqlite::Connection` is `!Sync` — wrapped in `Mutex` exactly like `SettingsStoreResource`.
|
||||
- All queries use indexed primary keys — deterministic per-input.
|
||||
- Pure function of `(location_id, systems.db contents)`. `systems.db` is shipped read-only with the game build, so the mapping is pinned at release time.
|
||||
- Safe to call concurrently from multiple Bevy systems; the mutex serializes at sub-microsecond cost.
|
||||
|
||||
Satisfies D-010 principle 4 (deterministic simulation).
|
||||
|
||||
## 7. Test plan (#679 acceptance)
|
||||
|
||||
Unit tests in `server/src/knowledge/culture.rs`:
|
||||
|
||||
1. `resolves_known_system` — opens a fixture DB, resolves `"GJ 35"` to `"south_reach"`.
|
||||
2. `resolves_known_body` — body override wins over system default.
|
||||
3. `resolves_station_to_parent_system` — station falls through to its parent's corridor.
|
||||
4. `unknown_location_returns_err` — unknown string returns `UnknownLocation`.
|
||||
5. `null_culture_returns_err` — fixture with NULL `cultural_corridor` → `NoCulture`.
|
||||
6. `concurrent_reads_are_safe` — spawn two threads, each resolving 1000 times; results match.
|
||||
|
||||
Fixture DB at `server/src/knowledge/fixtures/culture_test.db` — seeded in a build.rs or committed as a tiny blob. Opt for committed fixture — zero-effort for CI.
|
||||
|
||||
## 8. Consumers (for coordination)
|
||||
|
||||
| Consumer | Ticket | How it calls |
|
||||
|----------|--------|--------------|
|
||||
| Character creation (client) | #680 | Via IPC — see §9 |
|
||||
| Apartment generator | #681 | Direct `resolve_culture()` when `SelectedBookmark` populated |
|
||||
| NPC generator | (deferred, was #621) | Direct — passes culture into NpcBlueprint |
|
||||
| Voice pipeline / Gemma | already integrated via `server/src/voice/` | Reads culture from NPC blueprint (no direct call) |
|
||||
| Cultural visual grammar | (sprint 38+) | Direct — reads from `SelectedBookmark` → resolver |
|
||||
|
||||
## 9. Client exposure — how #680 sees culture
|
||||
|
||||
The client does NOT call `resolve_culture()` — it calls it indirectly via the bookmark flow:
|
||||
|
||||
**Option A (simplest):** server sends resolved culture as a field on the bookmark catalog per allowed_location.
|
||||
|
||||
```rust
|
||||
// On BookmarkCatalog (see sprint-36-bookmark-spec.md §4.1)
|
||||
pub struct BookmarkWire {
|
||||
// ... existing fields ...
|
||||
/// Parallel to `allowed_locations`: same index → same location.
|
||||
/// Pre-resolved on the server. Saves the client a round trip.
|
||||
pub allowed_locations_cultures: Vec<String>,
|
||||
}
|
||||
```
|
||||
|
||||
Rationale: cultures are effectively static data shipped with `systems.db`. Resolving them server-side once and shipping the catalog saves:
|
||||
- A second IPC round-trip (client picks a location, server tells it the culture).
|
||||
- Error handling duplication (client would need a "culture lookup failed" path).
|
||||
|
||||
**Trade-off:** catalog payload grows ~one short string per allowed location. For v0.2's single allowed location, the overhead is 8 bytes. Acceptable.
|
||||
|
||||
**Option B (rejected):** dedicated `PlayerAction::ResolveCulture(location_id)` → `ObserverSnapshot.culture_response`. Works fine mechanically; just unnecessary given how static culture data is.
|
||||
|
||||
Stig (client) should plan on reading `allowed_locations_cultures[i]` when the player highlights the `i`-th entry in the picker, then display the culture label inline (e.g. "Van Maanen's Star — south_reach culture").
|
||||
|
||||
## 10. Implementation plan (for Dudley)
|
||||
|
||||
**Size:** ~0.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.
|
||||
Generated
+1
-11
@@ -325,15 +325,6 @@ dependencies = [
|
||||
"thread_local",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.10.0"
|
||||
@@ -1303,12 +1294,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.34"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
"bevy_tasks",
|
||||
"bincode",
|
||||
"clap",
|
||||
"crossbeam-channel",
|
||||
"econ-sim",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
# cargo audit configuration for settled-reach-server.
|
||||
# Known advisories that are tracked but not yet resolved are listed here.
|
||||
# New advisories NOT in this list will fail CI.
|
||||
|
||||
[advisories]
|
||||
# RUSTSEC-2025-0141: bincode v1.3.3 is unmaintained.
|
||||
# Migration to bincode v2 or an alternative is tracked in ticket #636.
|
||||
# This ignore can be removed once #636 is resolved.
|
||||
ignore = ["RUSTSEC-2025-0141"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,855 +0,0 @@
|
||||
# DEPRECATED — archived by ticket #664 (copy team, Sprint 27)
|
||||
#
|
||||
# This file is NOT served at runtime. It is preserved for authoring reference only.
|
||||
#
|
||||
# WHY DEPRECATED: Every entry in this file references named NPCs from the Sova Transit
|
||||
# District vertical slice (Kael Davan, Sera Venn, Naia Tamm, Voss, Torek, Renn, Maret,
|
||||
# Lera, Nils, Drin, Olin, etc.). Per D-122 (all NPCs generated, no hand-authored
|
||||
# characters), all named character references in this file are invalid against the
|
||||
# generated NPC population. The drama module system (ticket #158) that these entries
|
||||
# were authored for was superseded before implementation.
|
||||
#
|
||||
# WHAT REPLACED IT: content/npc-conversations/overheard.ron
|
||||
# Generator-compatible RON pool. Parameterized by role pair and zone type.
|
||||
# No named NPC references. Culture-neutral text accepting culture modifier at assembly.
|
||||
#
|
||||
# WHAT IS STRUCTURALLY WORTH PRESERVING (applied in overheard.ron):
|
||||
# - Overheard conversations as ambient information layer (D-078)
|
||||
# - Register taxonomy: social / work / gossip
|
||||
# - Occlusion-resilient authoring rules (D-078): front-load key info, short
|
||||
# declarative sentences, no pronoun-first openers, each turn self-contained
|
||||
# - knowledge_payload pattern for investigative inference
|
||||
#
|
||||
# Original file header below:
|
||||
# NPC-to-NPC Overheard Dialogue Pool
|
||||
# Ticket: #536 | Author: Mellanie | Sprint: 14
|
||||
# Decision refs: D-078, D-018, D-071, D-035
|
||||
#
|
||||
# LEGACY CONTENT — v0.1 hand-authored pool. DO NOT DELETE.
|
||||
# All entries in this file reference named NPCs from the Sova Transit District
|
||||
# vertical slice (Kael Davan, Sera Venn, Naia Tamm, Voss, Nils, Maret Korr, etc.).
|
||||
# These named NPCs were authored for the drama module system (ticket #158), which
|
||||
# was superseded before implementation. The named references in this file are
|
||||
# therefore broken — they reference characters that do not exist in the generated
|
||||
# NPC population.
|
||||
# Ticket #664 tracks replacement of this file with a zone-type-aware overheard
|
||||
# generator that produces conversations from role pairs rather than named characters.
|
||||
# Until #664 ships, this file is not served at runtime.
|
||||
#
|
||||
# These are conversations the player can overhear when stationary near two
|
||||
# NPCs. The passive dialogue panel (D-078) displays them with per-word
|
||||
# occlusion based on distance, ambient noise, and ListeningFocus stance.
|
||||
#
|
||||
# OCCLUSION-RESILIENT AUTHORING RULES (D-078):
|
||||
# 1. Front-load key information — most important word in first third.
|
||||
# 2. Short declarative sentences — one idea per turn.
|
||||
# 3. No pronoun-first openers — first word has highest drop risk; a dropped
|
||||
# pronoun without antecedent is unresolvable. Use names or nouns.
|
||||
# 4. Each turn is self-contained — a player who hears only one side gets
|
||||
# a complete thought.
|
||||
#
|
||||
# SCHEMA NOTE:
|
||||
# `relationship_type` and `knowledge_payload` are custom extensions to the
|
||||
# D-035 schema for this content type. NPC-sourced lines use `role: npc`,
|
||||
# `access` and `trust` apply normally. Monologue-specific tags (`character`,
|
||||
# `trigger`, `prerequisite`) are not used here.
|
||||
# Schema confirmation with Gestalt (#168) before CI validation.
|
||||
#
|
||||
# REGISTERS:
|
||||
# social — idle chat, personal news, relationship talk
|
||||
# work — shift logistics, job gripes, operational notes
|
||||
# gossip — third-party information with player-relevant knowledge payload
|
||||
#
|
||||
# RELATIONSHIP TYPES:
|
||||
# colleague, friend, hostile, romantic
|
||||
#
|
||||
# KNOWLEDGE PAYLOAD FORMAT:
|
||||
# Plain English description of what the player can infer from hearing this
|
||||
# exchange clearly — or the key fragment they retain under partial occlusion.
|
||||
# null if the exchange is social noise with no investigative value.
|
||||
|
||||
pairs:
|
||||
|
||||
# ===================================================================
|
||||
# SOCIAL register — personal news, idle chat, relationships
|
||||
# ===================================================================
|
||||
|
||||
- id: overheard_001
|
||||
register: social
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Kael brought food for the whole bay yesterday. Just showed up with it."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Naia must have made him feel guilty about something. Again."
|
||||
relationship_type: colleague
|
||||
topic: personal-gossip
|
||||
location_hints: [the-terminal, the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Kael and Naia have a relationship dynamic. Naia has leverage or emotional pull over Kael's behavior."
|
||||
tags: [kael, naia, social, atmosphere]
|
||||
|
||||
- id: overheard_002
|
||||
register: social
|
||||
speaker_a:
|
||||
role: bar-regular
|
||||
text: "Lera's keeping the kitchen open late this week. Span gate delay — crews stuck here."
|
||||
speaker_b:
|
||||
role: bar-regular
|
||||
text: "Good for Lera. Bad for everyone waiting on the gate."
|
||||
relationship_type: friend
|
||||
topic: station-life
|
||||
location_hints: [the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: null
|
||||
tags: [lera, span-gate, atmosphere, social]
|
||||
|
||||
- id: overheard_003
|
||||
register: social
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Drin's applying for the transfer. Again. Third time he's tried."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Drin's never getting off Sova. Some people just belong to a place."
|
||||
relationship_type: colleague
|
||||
topic: career-gossip
|
||||
location_hints: [the-terminal, the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Drin wants out of Sova Transit. Unhappy enough to pursue transfer requests."
|
||||
tags: [drin, transfer, social, atmosphere]
|
||||
|
||||
- id: overheard_004
|
||||
register: social
|
||||
speaker_a:
|
||||
role: bar-regular
|
||||
text: "Naia and Kael had a fight. Loud enough that Lera had to step in."
|
||||
speaker_b:
|
||||
role: bar-regular
|
||||
text: "Kael looked rough this morning. That tracks."
|
||||
relationship_type: friend
|
||||
topic: relationship-drama
|
||||
location_hints: [the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Kael and Naia are under relationship strain. Kael's mood is affected. Cross-reference: behavioral.kael_behavioral_change."
|
||||
tags: [kael, naia, relationship, friend-arc-adjacent, dual-lens]
|
||||
|
||||
- id: overheard_005
|
||||
register: social
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Renn's finally getting his lattice service done. Been putting it off for years."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Commission clinic wait time is eight months. Renn found someone faster."
|
||||
relationship_type: colleague
|
||||
topic: lattice-access
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Renn found a non-Commission lattice service provider. Points toward unlicensed lattice components market."
|
||||
tags: [renn, lattice, contraband-adjacent, atmosphere]
|
||||
|
||||
- id: overheard_006
|
||||
register: social
|
||||
speaker_a:
|
||||
role: bar-regular
|
||||
text: "Maret's promotion came through. Third level supervisor."
|
||||
speaker_b:
|
||||
role: bar-regular
|
||||
text: "Good. Maret actually knows what she's doing. Unlike some."
|
||||
relationship_type: colleague
|
||||
topic: career-news
|
||||
location_hints: [the-last-shift, the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: null
|
||||
tags: [maret, promotion, atmosphere, social]
|
||||
|
||||
- id: overheard_007
|
||||
register: social
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Voss has been in a mood all week. Something from upstairs."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Voss is always in a mood. Week ends in -day, Voss is in a mood."
|
||||
relationship_type: colleague
|
||||
topic: supervisor-gossip
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Voss is under external pressure from 'upstairs.' Something is stressing management."
|
||||
tags: [voss, management, atmosphere, social]
|
||||
|
||||
- id: overheard_008
|
||||
register: social
|
||||
speaker_a:
|
||||
role: bar-regular
|
||||
text: "Sera's been coming here every evening this week. Thought Commission people didn't drink."
|
||||
speaker_b:
|
||||
role: bar-regular
|
||||
text: "Sera's different. She fits here better than she fits over there."
|
||||
relationship_type: colleague
|
||||
topic: social-observation
|
||||
location_hints: [the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Sera Venn is a regular at The Last Shift, unusual for a Commission employee. She has social roots in the district."
|
||||
tags: [sera, commission, atmosphere, social, dual-lens]
|
||||
|
||||
- id: overheard_009
|
||||
register: social
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Nils covered Kael's morning slot yesterday. No explanation, just a roster note."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Kael's been doing that a lot lately. Taking sick leave, then showing up mid-shift."
|
||||
relationship_type: colleague
|
||||
topic: roster-anomaly
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Kael's schedule is irregular. Unexplained absences and late arrivals. Cross-reference: observe_anomaly triggers for Kael."
|
||||
tags: [kael, nils, roster, friend-arc-adjacent]
|
||||
|
||||
- id: overheard_010
|
||||
register: social
|
||||
speaker_a:
|
||||
role: maintenance-tech
|
||||
text: "Olin's kid got into the Commission cadet program. Starts next cycle."
|
||||
speaker_b:
|
||||
role: maintenance-tech
|
||||
text: "Olin must be pleased. Cost of living here, Commission pay makes sense."
|
||||
relationship_type: colleague
|
||||
topic: family-news
|
||||
location_hints: [maintenance-corridors, the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: null
|
||||
tags: [olin, commission, atmosphere, social]
|
||||
|
||||
# ===================================================================
|
||||
# WORK register — shift logistics, operational gripes, job talk
|
||||
# ===================================================================
|
||||
|
||||
- id: overheard_011
|
||||
register: work
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Maret shifted the roster again. Bay four to bay seven, no reason given."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Bay seven's the low-traffic slot. Somebody wanted less oversight over there."
|
||||
relationship_type: colleague
|
||||
topic: roster-change
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Bay seven has been given a low-oversight crew deliberately. This is operationally significant."
|
||||
tags: [maret, roster, bay-seven, investigation-adjacent]
|
||||
|
||||
- id: overheard_012
|
||||
register: work
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Loading arm three is grinding again. Filed the report last week."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Maintenance says next cycle. Means nothing gets done until someone loses a hand."
|
||||
relationship_type: colleague
|
||||
topic: equipment-maintenance
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: null
|
||||
tags: [equipment, maintenance, atmosphere, work]
|
||||
|
||||
- id: overheard_013
|
||||
register: work
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Container 4471's been in temp for three days. Someone should move it."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Routing says hold. Don't ask me, I just process what the board says."
|
||||
relationship_type: colleague
|
||||
topic: container-routing
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Container 4471 is being deliberately held in temp storage. 'Routing says hold' — someone modified the routing instruction."
|
||||
tags: [container-4471, routing, contraband-adjacent, investigation-payload]
|
||||
|
||||
- id: overheard_014
|
||||
register: work
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Voss cut the B-section crew by two. Says it's budget. Doesn't feel like budget."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Less crew in B-section means less oversight. Could be budget. Could be something else."
|
||||
relationship_type: colleague
|
||||
topic: crew-reduction
|
||||
location_hints: [the-terminal, maintenance-corridors]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "B-section is understaffed. The speaker suspects this is deliberate. Cross-reference: ring oversight windows."
|
||||
tags: [voss, b-section, oversight, investigation-payload]
|
||||
|
||||
- id: overheard_015
|
||||
register: work
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Span gate's backed up again. Forty-minute delay on the freight queue."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Forty minutes is nothing. Last month it was three hours. Patience."
|
||||
relationship_type: colleague
|
||||
topic: span-gate-delay
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: null
|
||||
tags: [span-gate, freight, atmosphere, work]
|
||||
|
||||
- id: overheard_016
|
||||
register: work
|
||||
speaker_a:
|
||||
role: maintenance-tech
|
||||
text: "Junction C-2's camera was repositioned. Nobody filed a maintenance request."
|
||||
speaker_b:
|
||||
role: maintenance-tech
|
||||
text: "Someone moved it without logging it. That's a compliance violation."
|
||||
relationship_type: colleague
|
||||
topic: camera-anomaly
|
||||
location_hints: [maintenance-corridors]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Camera at junction C-2 was moved without documentation. Suggests deliberate surveillance manipulation. Cross-reference: awareness.surveillance_change."
|
||||
tags: [camera, surveillance, c-2, investigation-payload]
|
||||
|
||||
- id: overheard_017
|
||||
register: work
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Commission wants another inspection. Fourth one this quarter."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Inspections mean overtime. Inspections mean everyone's watching everyone."
|
||||
relationship_type: colleague
|
||||
topic: commission-inspection
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Commission is conducting elevated inspections of the terminal. Something has drawn their attention."
|
||||
tags: [commission, inspection, atmosphere, investigation-adjacent]
|
||||
|
||||
- id: overheard_018
|
||||
register: work
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Weight manifest on the morning freight came in five hundred kilos short. Recalibration error."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Five hundred kilos doesn't disappear from a calibration error. It disappears from somewhere else."
|
||||
relationship_type: colleague
|
||||
topic: weight-discrepancy
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Manifest weight discrepancy exists and dock workers are suspicious of the official explanation."
|
||||
tags: [manifest, weight-discrepancy, contraband-adjacent, investigation-payload]
|
||||
|
||||
- id: overheard_019
|
||||
register: work
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Shift handover's going to be rough tonight. Torek's team hasn't filed."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Torek's team never files on time. Let Maret handle it."
|
||||
relationship_type: colleague
|
||||
topic: shift-handover
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: null
|
||||
tags: [torek, maret, shift, atmosphere, work]
|
||||
|
||||
- id: overheard_020
|
||||
register: work
|
||||
speaker_a:
|
||||
role: maintenance-tech
|
||||
text: "Sub-level access has been requested twice this week. Both times outside shift hours."
|
||||
speaker_b:
|
||||
role: maintenance-tech
|
||||
text: "Scheduled maintenance happens in-shift. Off-hours access needs sign-off."
|
||||
relationship_type: colleague
|
||||
topic: sub-level-access
|
||||
location_hints: [maintenance-corridors]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Someone has been accessing sub-level spaces outside normal hours. No sign-off implies unauthorized use. Cross-reference: smuggling-hold activity."
|
||||
tags: [sub-level, access, maintenance, investigation-payload]
|
||||
|
||||
- id: overheard_021
|
||||
register: work
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Bay four's been sealed for inspection since yesterday morning."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Commission authorized it. Not Voss. Commission went over his head."
|
||||
relationship_type: colleague
|
||||
topic: bay-inspection
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Bay four inspection bypassed Voss's authority. Commission is operating independently of local management."
|
||||
tags: [bay-four, commission, voss, inspection, investigation-payload]
|
||||
|
||||
- id: overheard_022
|
||||
register: work
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Kael's running Renn's route today. Renn's supposed to be on that."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Kael volunteered. Said Renn had something come up."
|
||||
relationship_type: colleague
|
||||
topic: route-substitution
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Kael is voluntarily taking routes outside his assignment. Could be covering for Renn, could be operational flexibility needed by the ring."
|
||||
tags: [kael, renn, route, ring-adjacent]
|
||||
|
||||
- id: overheard_023
|
||||
register: work
|
||||
speaker_a:
|
||||
role: bar-regular
|
||||
text: "Lera's dealing with the supply chain issue again. Grain spirit supplier changed terms."
|
||||
speaker_b:
|
||||
role: bar-regular
|
||||
text: "Lera will figure it out. Lera always figures it out."
|
||||
relationship_type: friend
|
||||
topic: supply-chain
|
||||
location_hints: [the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: null
|
||||
tags: [lera, supply, atmosphere, work]
|
||||
|
||||
- id: overheard_024
|
||||
register: work
|
||||
speaker_a:
|
||||
role: maintenance-tech
|
||||
text: "Condensation in the B-corridor's gotten worse. Someone's running heat-generation equipment down there."
|
||||
speaker_b:
|
||||
role: maintenance-tech
|
||||
text: "Heat-gen in a maintenance corridor. That's either a storage issue or a very bad idea."
|
||||
relationship_type: colleague
|
||||
topic: corridor-anomaly
|
||||
location_hints: [maintenance-corridors]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Heat-generating equipment is being used in maintenance corridors unofficially. Points toward the smuggling hold or ring operations."
|
||||
tags: [b-corridor, heat, maintenance, smuggling-adjacent]
|
||||
|
||||
- id: overheard_025
|
||||
register: work
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Torek's been doing double manifests for a month. Every container logged twice."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Double logging means one version goes somewhere it shouldn't."
|
||||
relationship_type: colleague
|
||||
topic: manifest-anomaly
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Torek is maintaining duplicate manifest records. One version is falsified. Direct evidence of ring operation at the administrative level."
|
||||
tags: [torek, manifest, contraband, investigation-payload, high-value]
|
||||
|
||||
# ===================================================================
|
||||
# GOSSIP register — third-party knowledge with investigative payload
|
||||
# ===================================================================
|
||||
|
||||
- id: overheard_026
|
||||
register: gossip
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Kael was in corridor B-7 last night. Saw him myself. Off shift, wrong time, wrong place."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Kael lives near B-section. Could have been heading home the long way."
|
||||
relationship_type: colleague
|
||||
topic: kael-location
|
||||
location_hints: [the-terminal, the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Kael was in corridor B-7 off-shift. Eyewitness account."
|
||||
tags: [kael, corridor-b7, investigation-payload, friend-arc, high-value]
|
||||
|
||||
- id: overheard_027
|
||||
register: gossip
|
||||
speaker_a:
|
||||
role: bar-regular
|
||||
text: "Kael was talking to someone near B-7 last night. Person I didn't recognize."
|
||||
speaker_b:
|
||||
role: bar-regular
|
||||
text: "Unknown people at junction B-7 at night. That's not normal."
|
||||
relationship_type: friend
|
||||
topic: kael-unknown-contact
|
||||
location_hints: [the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Kael met with an unidentified person at corridor B-7 during off-hours."
|
||||
tags: [kael, unknown-contact, corridor-b7, friend-arc, high-value, investigation-payload]
|
||||
|
||||
- id: overheard_028
|
||||
register: gossip
|
||||
speaker_a:
|
||||
role: bar-regular
|
||||
text: "Torek spent three thousand credits at Lera's last week. Three thousand. On a dock worker's salary."
|
||||
speaker_b:
|
||||
role: bar-regular
|
||||
text: "Torek's either very lucky or very stupid. Either way, someone's going to notice."
|
||||
relationship_type: friend
|
||||
topic: torek-spending
|
||||
location_hints: [the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Torek is spending significantly above his salary at The Last Shift."
|
||||
tags: [torek, spending, lera, investigation-payload, ring-adjacent]
|
||||
|
||||
- id: overheard_029
|
||||
register: gossip
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Renn got new boots. Commission-grade. Renn can't afford Commission-grade boots."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Renn's been running extra shifts. Or extra something."
|
||||
relationship_type: colleague
|
||||
topic: renn-spending
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Renn has unexplained extra income. Cross-reference: ring membership payments."
|
||||
tags: [renn, income, ring-adjacent, investigation-adjacent]
|
||||
|
||||
- id: overheard_030
|
||||
register: gossip
|
||||
speaker_a:
|
||||
role: bar-regular
|
||||
text: "Sera Venn avoids Torek every time he's here. Every single time. Noticed it three weeks straight."
|
||||
speaker_b:
|
||||
role: bar-regular
|
||||
text: "Torek does that to people. He talks too much and says things he shouldn't."
|
||||
relationship_type: colleague
|
||||
topic: sera-torek-avoidance
|
||||
location_hints: [the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Sera Venn has a consistent avoidance pattern toward Torek Lintar. Cross-reference: behavioral.sera_avoidance_pattern."
|
||||
tags: [sera, torek, avoidance, friend-arc, investigation-payload, high-value]
|
||||
|
||||
- id: overheard_031
|
||||
register: gossip
|
||||
speaker_a:
|
||||
role: bar-regular
|
||||
text: "Commission officer's been asking questions at The Terminal. Polite questions. Thorough ones."
|
||||
speaker_b:
|
||||
role: bar-regular
|
||||
text: "Polite and thorough is the worst combination. That's someone who has time."
|
||||
relationship_type: colleague
|
||||
topic: commission-investigation
|
||||
location_hints: [the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "A Commission officer is conducting a quiet investigation at The Terminal. Cross-reference: awareness.detective_presence."
|
||||
tags: [commission, detective, investigation-payload, awareness]
|
||||
|
||||
- id: overheard_032
|
||||
register: gossip
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Lattice components went through last month. Unlicensed grade. Manifest said 'mechanical parts.'"
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Mechanical parts. Right. People find ways when the Commission won't."
|
||||
relationship_type: colleague
|
||||
topic: contraband-transit
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Unlicensed lattice components are moving through The Terminal under falsified manifests. Cross-reference: contraband operation confirmed."
|
||||
tags: [lattice, contraband, manifest, investigation-payload, high-value]
|
||||
|
||||
- id: overheard_033
|
||||
register: gossip
|
||||
speaker_a:
|
||||
role: maintenance-tech
|
||||
text: "Sub-level temp storage has been accessed four times this week. Door log shows it."
|
||||
speaker_b:
|
||||
role: maintenance-tech
|
||||
text: "Four times and no maintenance ticket filed. Someone's using it off-book."
|
||||
relationship_type: colleague
|
||||
topic: unauthorized-access
|
||||
location_hints: [maintenance-corridors]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "The sub-level storage (smuggling hold) has heavy unauthorized use documented in door logs. Direct evidence of ring operations."
|
||||
tags: [sub-level, access-log, ring, investigation-payload, high-value]
|
||||
|
||||
- id: overheard_034
|
||||
register: gossip
|
||||
speaker_a:
|
||||
role: bar-regular
|
||||
text: "Voss changed the B-section rotation. Kael and Renn are both on the overnight slot now."
|
||||
speaker_b:
|
||||
role: bar-regular
|
||||
text: "Kael and Renn on overnight in B-section. That's a very specific combination."
|
||||
relationship_type: colleague
|
||||
topic: roster-combination
|
||||
location_hints: [the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Kael and Renn have been placed on the same overnight B-section rotation. This creates the oversight gap the ring needs."
|
||||
tags: [kael, renn, voss, roster, ring-adjacent, investigation-payload]
|
||||
|
||||
- id: overheard_035
|
||||
register: gossip
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Sera asked me about container routing last week. Wanted to know who approves temp storage extensions."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Commission tech asking about temp storage approvals. That's outside her job scope."
|
||||
relationship_type: colleague
|
||||
topic: sera-investigation
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Sera Venn is conducting her own investigation into temp storage approvals, outside her Commission mandate. Cross-reference: behavioral.sera_kiosk_pattern."
|
||||
tags: [sera, storage, investigation-payload, friend-arc, dual-lens, high-value]
|
||||
|
||||
- id: overheard_036
|
||||
register: gossip
|
||||
speaker_a:
|
||||
role: bar-regular
|
||||
text: "Naia told me Kael hasn't been sleeping. Says he's up late, doesn't explain where."
|
||||
speaker_b:
|
||||
role: bar-regular
|
||||
text: "Naia's worried about him. Kael won't talk about whatever it is."
|
||||
relationship_type: friend
|
||||
topic: kael-behavioral-change
|
||||
location_hints: [the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Kael's behavior has changed at home. Sleepless, secretive, won't explain to Naia. Cross-reference: behavioral.kael_behavioral_change."
|
||||
tags: [kael, naia, behavior, friend-arc, investigation-payload]
|
||||
|
||||
- id: overheard_037
|
||||
register: gossip
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Someone flagged a manifest discrepancy on the morning freight. Voss cleared the flag himself."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Voss doesn't clear flags. That's Maret's job. Or Commission's."
|
||||
relationship_type: colleague
|
||||
topic: manifest-flag-cleared
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Voss manually cleared a manifest discrepancy flag, bypassing protocol. Suggests Voss is actively covering for ring activity."
|
||||
tags: [voss, manifest, flag-cleared, investigation-payload, high-value]
|
||||
|
||||
- id: overheard_038
|
||||
register: gossip
|
||||
speaker_a:
|
||||
role: bar-regular
|
||||
text: "Kael's been asking about exit options. Not just talk — actually asking Lera if she knows anyone who could help someone disappear quietly."
|
||||
speaker_b:
|
||||
role: bar-regular
|
||||
text: "Kael wants out of something. That's the only reason people ask that kind of question."
|
||||
relationship_type: friend
|
||||
topic: kael-exit-attempt
|
||||
location_hints: [the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Kael is actively trying to leave 'something' — most likely the ring. He's researching options."
|
||||
tags: [kael, exit, ring-adjacent, friend-arc, investigation-payload, high-value]
|
||||
|
||||
- id: overheard_039
|
||||
register: gossip
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Medical lattice upgrades are showing up on the black-side. Commission-grade, no paperwork."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "People who need them can't wait on Commission approval. Someone's doing a service."
|
||||
relationship_type: colleague
|
||||
topic: black-market-lattice
|
||||
location_hints: [the-terminal, the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Medical-grade lattice components are available outside Commission channels. Confirms the moral framing of the contraband operation — it serves real medical need."
|
||||
tags: [lattice, medical, black-market, contraband, moral-framing]
|
||||
|
||||
- id: overheard_040
|
||||
register: gossip
|
||||
speaker_a:
|
||||
role: bar-regular
|
||||
text: "Torek told someone he had a meeting last night. Past midnight. In maintenance."
|
||||
speaker_b:
|
||||
role: bar-regular
|
||||
text: "Torek having midnight maintenance meetings. Sure. That's completely normal."
|
||||
relationship_type: colleague
|
||||
topic: torek-late-meeting
|
||||
location_hints: [the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Torek met with someone in the maintenance corridors late at night."
|
||||
tags: [torek, maintenance, midnight, ring-adjacent, investigation-payload]
|
||||
|
||||
# ===================================================================
|
||||
# BONUS PAIRS — social and work depth
|
||||
# ===================================================================
|
||||
|
||||
- id: overheard_041
|
||||
register: social
|
||||
speaker_a:
|
||||
role: bar-regular
|
||||
text: "Lera's thinking about expanding. Back room could seat twenty more."
|
||||
speaker_b:
|
||||
role: bar-regular
|
||||
text: "Back room's the only reason this place has any privacy. Expand it and we lose that."
|
||||
relationship_type: friend
|
||||
topic: bar-expansion
|
||||
location_hints: [the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: null
|
||||
tags: [lera, bar, atmosphere, social]
|
||||
|
||||
- id: overheard_042
|
||||
register: work
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Shift change is late again. Maret says weather on Velen is delaying the span gate."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Weather on Velen. Which means fog. Which means the morning run is going to be rough."
|
||||
relationship_type: colleague
|
||||
topic: weather-delay
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: null
|
||||
tags: [velen, fog, span-gate, weather, atmosphere]
|
||||
|
||||
- id: overheard_043
|
||||
register: social
|
||||
speaker_a:
|
||||
role: bar-regular
|
||||
text: "Olin's getting a commendation from Commission. Ten years of service."
|
||||
speaker_b:
|
||||
role: bar-regular
|
||||
text: "Ten years. Commission gives you a piece of paper and a handshake."
|
||||
relationship_type: colleague
|
||||
topic: commission-recognition
|
||||
location_hints: [the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: null
|
||||
tags: [olin, commission, atmosphere, social]
|
||||
|
||||
- id: overheard_044
|
||||
register: work
|
||||
speaker_a:
|
||||
role: maintenance-tech
|
||||
text: "Power fluctuation in sub-level B last night. Lasted three minutes."
|
||||
speaker_b:
|
||||
role: maintenance-tech
|
||||
text: "Three minutes is enough to blind the cameras if you know the timing."
|
||||
relationship_type: colleague
|
||||
topic: power-fluctuation
|
||||
location_hints: [maintenance-corridors]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "A power fluctuation in sub-level B temporarily disabled cameras. Could be timed to enable unobserved access."
|
||||
tags: [power, cameras, sub-level, timing, investigation-adjacent]
|
||||
|
||||
- id: overheard_045
|
||||
register: gossip
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Commission officer asked Drin about cargo manifest irregularities. Drin told him everything he knows, which isn't much."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Drin doesn't know much by design. That's why Drin's on inspection detail."
|
||||
relationship_type: colleague
|
||||
topic: commission-inquiry
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "The Commission officer is asking dock workers directly about manifest irregularities. Drin has been interviewed. Cross-reference: awareness.detective_presence."
|
||||
tags: [drin, commission, detective, interview, investigation-payload]
|
||||
|
||||
- id: overheard_046
|
||||
register: gossip
|
||||
speaker_a:
|
||||
role: bar-regular
|
||||
text: "Sera's been keeping a list. Someone told Naia. Private list, personal data."
|
||||
speaker_b:
|
||||
role: bar-regular
|
||||
text: "Sera keeping a list of what? And why would Naia know?"
|
||||
relationship_type: friend
|
||||
topic: sera-documentation
|
||||
location_hints: [the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Sera is documenting something privately — possibly her own investigation or evidence she's sitting on."
|
||||
tags: [sera, naia, list, evidence, friend-arc, investigation-payload, dual-lens]
|
||||
|
||||
- id: overheard_047
|
||||
register: social
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Sova's been home for twenty years. Still don't know if I love it or just got used to it."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Twenty years is the same thing."
|
||||
relationship_type: friend
|
||||
topic: station-life
|
||||
location_hints: [the-terminal, the-last-shift]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: null
|
||||
tags: [sova, atmosphere, personal, social]
|
||||
|
||||
- id: overheard_048
|
||||
register: work
|
||||
speaker_a:
|
||||
role: dock-worker
|
||||
text: "Voss approved overtime for two extra heads on the B-section night shift. Unusual."
|
||||
speaker_b:
|
||||
role: dock-worker
|
||||
text: "Overtime plus extra bodies in B at night. Either something's wrong or something's being made to look right."
|
||||
relationship_type: colleague
|
||||
topic: overtime-approval
|
||||
location_hints: [the-terminal]
|
||||
access: [public]
|
||||
trust: surface
|
||||
knowledge_payload: "Voss approved extra overnight staffing in B-section — possibly to create cover or provide legitimate-looking explanation for movement in that area."
|
||||
tags: [voss, overtime, b-section, night-shift, investigation-adjacent]
|
||||
Binary file not shown.
@@ -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"
|
||||
@@ -0,0 +1,801 @@
|
||||
//! Generate ~10,000 minor brand products for the Settled Reach economy.
|
||||
//!
|
||||
//! Reads brand archetype templates from `wiki/economics/archetypes/brand_templates.toml`,
|
||||
//! queries `systems.db` for the 48 hand-authored Tier-1/2 corporations, then generates
|
||||
//! minor brand products (halo + volume tier pairs) assigned to those corporations.
|
||||
//!
|
||||
//! Output: `wiki/economics/corporations/generated_brands.toml`
|
||||
//! Format: [[brand_products]] and [[brand_inputs]] TOML arrays compatible with
|
||||
//! `import_economics.py`'s `import_brands()` function.
|
||||
//!
|
||||
//! Each (corp, template) eligible pair produces 2 brand_product rows:
|
||||
//! - halo tier — `{corp_id}-{archetype}-{scale}-halo`
|
||||
//! - volume tier — `{corp_id}-{archetype}-{scale}-vol`
|
||||
//!
|
||||
//! Decision references: D-189 (brand layer architecture), D-190 (volume calibration)
|
||||
//!
|
||||
//! # Usage
|
||||
//! ```sh
|
||||
//! cargo run --bin generate_brands
|
||||
//! cargo run --bin generate_brands -- --seed 42 --min-brands 10000
|
||||
//! cargo run --bin generate_brands -- --db server/data/systems.db --output wiki/economics/corporations/generated_brands.toml
|
||||
//! ```
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
|
||||
use clap::Parser;
|
||||
use rand::prelude::*;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod names;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "generate_brands",
|
||||
about = "Generate minor brand products (halo+volume pairs) from archetype templates"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Path to systems.db
|
||||
#[arg(long)]
|
||||
db: Option<PathBuf>,
|
||||
|
||||
/// Path to brand_templates.toml
|
||||
#[arg(long)]
|
||||
templates: Option<PathBuf>,
|
||||
|
||||
/// Output TOML path
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "wiki/economics/corporations/generated_brands.toml"
|
||||
)]
|
||||
output: PathBuf,
|
||||
|
||||
/// PRNG seed for deterministic generation.
|
||||
/// Seed 1 is canonical — the committed output in db/ was produced at seed=1.
|
||||
/// Use a different seed only for experimentation; committed output must stay seed=1.
|
||||
#[arg(long, default_value = "1")]
|
||||
seed: u64,
|
||||
|
||||
/// Minimum brand_product rows to generate (approximate target)
|
||||
#[arg(long, default_value = "10000")]
|
||||
min_brands: usize,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brand template deserialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CommodityInputTemplate {
|
||||
commodity_id: String,
|
||||
quantity_min: f64,
|
||||
quantity_max: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BrandTemplate {
|
||||
archetype_group: String,
|
||||
brand_category: String,
|
||||
scale_tier: String, // local | regional | reach_wide
|
||||
naming_pattern: String,
|
||||
commodity_inputs: Vec<CommodityInputTemplate>,
|
||||
premium_range: [f64; 2],
|
||||
scarcity_class: String,
|
||||
value_trajectory: String,
|
||||
#[serde(default)]
|
||||
terroir_locked: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Corp {
|
||||
corp_id: String,
|
||||
scope: String,
|
||||
headquarters_system: Option<String>,
|
||||
geographic_sector: Option<String>,
|
||||
currency_zone: Option<String>,
|
||||
shadow_economy_access: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct BrandProduct {
|
||||
brand_product_id: String,
|
||||
corp_id: String,
|
||||
product_name: String,
|
||||
brand_category: String,
|
||||
value_trajectory: String,
|
||||
scarcity_class: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
product_subcategory: Option<String>,
|
||||
base_premium_multiplier: f64,
|
||||
premium_floor: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
origin_system: Option<String>,
|
||||
terroir_locked: bool,
|
||||
currency_denomination: String,
|
||||
shadow_viable: bool,
|
||||
brand_tier: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
halo_brand_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct BrandInput {
|
||||
brand_product_id: String,
|
||||
commodity_id: String,
|
||||
quantity: f64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn resolve_repo_root() -> PathBuf {
|
||||
let mut dir = std::env::current_dir().expect("Cannot determine CWD");
|
||||
loop {
|
||||
if dir.join("server").join("data").join("systems.db").exists() {
|
||||
return dir;
|
||||
}
|
||||
if !dir.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
eprintln!("error: cannot find repo root (looking for server/data/systems.db)");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn load_templates(path: &PathBuf) -> Vec<(String, BrandTemplate)> {
|
||||
let content = std::fs::read_to_string(path).unwrap_or_else(|e| {
|
||||
eprintln!("error: cannot read {}: {}", path.display(), e);
|
||||
process::exit(1);
|
||||
});
|
||||
let raw: toml::Value = toml::from_str(&content).unwrap_or_else(|e| {
|
||||
eprintln!("error: cannot parse {}: {}", path.display(), e);
|
||||
process::exit(1);
|
||||
});
|
||||
let table = raw.as_table().unwrap_or_else(|| {
|
||||
eprintln!("error: brand_templates.toml is not a TOML table");
|
||||
process::exit(1);
|
||||
});
|
||||
|
||||
let mut templates = Vec::new();
|
||||
for (key, value) in table {
|
||||
if value.is_table() {
|
||||
// Skip comment-only keys (headers are usually bare strings, not tables)
|
||||
match toml::Value::try_into::<BrandTemplate>(value.clone()) {
|
||||
Ok(t) => templates.push((key.clone(), t)),
|
||||
Err(e) => {
|
||||
eprintln!("warning: skipping template {:?}: {}", key, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by key for deterministic ordering
|
||||
templates.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
templates
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn load_corps(conn: &Connection) -> Vec<Corp> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT co.corp_id, co.scope, co.headquarters_system,
|
||||
ss.geographic_sector,
|
||||
ss.currency_zone,
|
||||
co.shadow_economy_access
|
||||
FROM corporations co
|
||||
LEFT JOIN star_systems ss ON co.headquarters_system = ss.system_id
|
||||
ORDER BY co.corp_id",
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("error: failed to prepare corps query: {}", e);
|
||||
process::exit(1);
|
||||
});
|
||||
|
||||
stmt.query_map([], |row| {
|
||||
Ok(Corp {
|
||||
corp_id: row.get(0)?,
|
||||
scope: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
|
||||
headquarters_system: row.get(2)?,
|
||||
geographic_sector: row.get(3)?,
|
||||
currency_zone: row.get(4)?,
|
||||
shadow_economy_access: row.get::<_, i64>(5).unwrap_or(0) != 0,
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("error: failed to query corporations: {}", e);
|
||||
process::exit(1);
|
||||
})
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn load_valid_commodity_ids(conn: &Connection) -> BTreeSet<String> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT commodity_id FROM commodities")
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("error: failed to prepare commodities query: {}", e);
|
||||
process::exit(1);
|
||||
});
|
||||
stmt.query_map([], |row| row.get::<_, String>(0))
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("error: failed to query commodities: {}", e);
|
||||
process::exit(1);
|
||||
})
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template-to-corridor parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse the corridor prefix from a template's naming_pattern field.
|
||||
/// The pattern starts with "{corridor} — ..." or "{corridor}/{sub} — ...".
|
||||
fn parse_template_corridor(naming_pattern: &str) -> &str {
|
||||
if let Some(idx) = naming_pattern.find(" — ") {
|
||||
&naming_pattern[..idx]
|
||||
} else if let Some(idx) = naming_pattern.find(" - ") {
|
||||
&naming_pattern[..idx]
|
||||
} else {
|
||||
"reach_wide"
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine whether a corp is eligible for a given template.
|
||||
///
|
||||
/// Matching rules:
|
||||
/// - `reach_wide` scale templates: all corps eligible.
|
||||
/// - `regional` or `local` templates with corridor `reach_wide`, `inner_corridor*`:
|
||||
/// all corps eligible (the inner corridor is the Reach's trade hub).
|
||||
/// - `regional` or `local` templates with specific corridor: reach-wide corps
|
||||
/// (scope = "reach-wide") plus corps whose HQ geographic_sector matches.
|
||||
fn corp_eligible(corp: &Corp, template_scale: &str, template_corridor: &str) -> bool {
|
||||
// Reach-wide scale — no restriction
|
||||
if template_scale == "reach_wide" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Neutral / inner-corridor templates — open to all
|
||||
if matches!(
|
||||
template_corridor,
|
||||
"reach_wide" | "inner_corridor" | "inner_corridor/neutral"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reach-wide corps carry everything
|
||||
if corp.scope == "reach-wide" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Match sector to corridor
|
||||
let sector = corp.geographic_sector.as_deref().unwrap_or("core");
|
||||
match template_corridor {
|
||||
"north_reach" | "north_reach/compact" => sector == "north_reach",
|
||||
"south_reach" => sector == "south_reach",
|
||||
"west_reach" | "west_reach/compact" => sector == "west_reach",
|
||||
"east_reach" | "inner_corridor/east_reach" => sector == "east_reach" || sector == "core",
|
||||
"frontier" => sector == "deep_frontier",
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brand ID / naming helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn scale_abbrev(scale_tier: &str) -> &'static str {
|
||||
match scale_tier {
|
||||
"local" => "l",
|
||||
"regional" => "r",
|
||||
"reach_wide" => "rw",
|
||||
_ => "x",
|
||||
}
|
||||
}
|
||||
|
||||
fn to_slug(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
fn archetype_slug(archetype_group: &str) -> String {
|
||||
to_slug(archetype_group)
|
||||
}
|
||||
|
||||
fn halo_id(corp_id: &str, archetype_group: &str, scale_tier: &str) -> String {
|
||||
format!(
|
||||
"{}-{}-{}-halo",
|
||||
corp_id,
|
||||
archetype_slug(archetype_group),
|
||||
scale_abbrev(scale_tier)
|
||||
)
|
||||
}
|
||||
|
||||
fn volume_id(corp_id: &str, archetype_group: &str, scale_tier: &str) -> String {
|
||||
format!(
|
||||
"{}-{}-{}-vol",
|
||||
corp_id,
|
||||
archetype_slug(archetype_group),
|
||||
scale_abbrev(scale_tier)
|
||||
)
|
||||
}
|
||||
|
||||
fn currency_for_corp(corp: &Corp) -> &'static str {
|
||||
match corp.currency_zone.as_deref() {
|
||||
Some(z) if z.contains("MARK") => "mark",
|
||||
Some(z) if z.contains("SOL") => "sol_adjacent",
|
||||
_ => "tractus",
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brand generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct GeneratedPair {
|
||||
halo: BrandProduct,
|
||||
volume: BrandProduct,
|
||||
inputs_halo: Vec<BrandInput>,
|
||||
inputs_volume: Vec<BrandInput>,
|
||||
}
|
||||
|
||||
fn generate_pair(
|
||||
rng: &mut ChaCha8Rng,
|
||||
corp: &Corp,
|
||||
template_key: &str,
|
||||
template: &BrandTemplate,
|
||||
valid_commodities: &BTreeSet<String>,
|
||||
) -> Option<GeneratedPair> {
|
||||
let corridor = parse_template_corridor(&template.naming_pattern);
|
||||
let halo_bid = halo_id(
|
||||
&corp.corp_id,
|
||||
&template.archetype_group,
|
||||
&template.scale_tier,
|
||||
);
|
||||
let vol_bid = volume_id(
|
||||
&corp.corp_id,
|
||||
&template.archetype_group,
|
||||
&template.scale_tier,
|
||||
);
|
||||
|
||||
let halo_name = names::generate_halo_name(rng, corridor, &template.brand_category);
|
||||
let vol_name = names::generate_volume_name(rng, corridor, &template.brand_category);
|
||||
|
||||
let base_premium = {
|
||||
let lo = template.premium_range[0];
|
||||
let hi = template.premium_range[1];
|
||||
lo + rng.random::<f64>() * (hi - lo)
|
||||
};
|
||||
let premium_floor = base_premium * 0.45;
|
||||
|
||||
let origin_system = if template.terroir_locked {
|
||||
corp.headquarters_system.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let currency = currency_for_corp(corp).to_string();
|
||||
let subcategory = Some(archetype_slug(&template.archetype_group));
|
||||
|
||||
let halo = BrandProduct {
|
||||
brand_product_id: halo_bid.clone(),
|
||||
corp_id: corp.corp_id.clone(),
|
||||
product_name: halo_name,
|
||||
brand_category: template.brand_category.clone(),
|
||||
value_trajectory: template.value_trajectory.clone(),
|
||||
scarcity_class: template.scarcity_class.clone(),
|
||||
product_subcategory: subcategory.clone(),
|
||||
base_premium_multiplier: round2(base_premium),
|
||||
premium_floor: round2(premium_floor),
|
||||
origin_system: origin_system.clone(),
|
||||
terroir_locked: template.terroir_locked,
|
||||
currency_denomination: currency.clone(),
|
||||
shadow_viable: corp.shadow_economy_access,
|
||||
brand_tier: "halo".to_string(),
|
||||
halo_brand_id: None,
|
||||
};
|
||||
|
||||
let vol_premium = base_premium * 0.35 + rng.random::<f64>() * (base_premium * 0.25);
|
||||
let vol_floor = vol_premium * 0.30;
|
||||
|
||||
let volume = BrandProduct {
|
||||
brand_product_id: vol_bid.clone(),
|
||||
corp_id: corp.corp_id.clone(),
|
||||
product_name: vol_name,
|
||||
brand_category: template.brand_category.clone(),
|
||||
value_trajectory: template.value_trajectory.clone(),
|
||||
scarcity_class: downgrade_scarcity(&template.scarcity_class),
|
||||
product_subcategory: subcategory,
|
||||
base_premium_multiplier: round2(vol_premium),
|
||||
premium_floor: round2(vol_floor),
|
||||
origin_system: None,
|
||||
terroir_locked: false,
|
||||
currency_denomination: currency,
|
||||
shadow_viable: corp.shadow_economy_access,
|
||||
brand_tier: "volume".to_string(),
|
||||
halo_brand_id: Some(halo_bid.clone()),
|
||||
};
|
||||
|
||||
// Build inputs — filter to valid commodity IDs only
|
||||
let inputs_halo: Vec<BrandInput> = template
|
||||
.commodity_inputs
|
||||
.iter()
|
||||
.filter(|ci| valid_commodities.contains(&ci.commodity_id))
|
||||
.map(|ci| {
|
||||
let qty_range = ci.quantity_max - ci.quantity_min;
|
||||
let qty = ci.quantity_min + rng.random::<f64>() * qty_range;
|
||||
BrandInput {
|
||||
brand_product_id: halo_bid.clone(),
|
||||
commodity_id: ci.commodity_id.clone(),
|
||||
quantity: round2(qty),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Halo brands without any valid commodity inputs fail V-B03 validation
|
||||
if inputs_halo.is_empty() {
|
||||
eprintln!(
|
||||
"warning: template {:?} has no valid commodity inputs — skipping",
|
||||
template_key
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Volume inputs: same commodities, 3–5× higher quantities
|
||||
let vol_scale = 3.0 + rng.random::<f64>() * 2.0;
|
||||
let inputs_volume: Vec<BrandInput> = inputs_halo
|
||||
.iter()
|
||||
.map(|i| BrandInput {
|
||||
brand_product_id: vol_bid.clone(),
|
||||
commodity_id: i.commodity_id.clone(),
|
||||
quantity: round2(i.quantity * vol_scale),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(GeneratedPair {
|
||||
halo,
|
||||
volume,
|
||||
inputs_halo,
|
||||
inputs_volume,
|
||||
})
|
||||
}
|
||||
|
||||
/// Volume tiers get a slightly less restrictive scarcity class.
|
||||
fn downgrade_scarcity(scarcity: &str) -> String {
|
||||
match scarcity {
|
||||
"capped" => "constrained".to_string(),
|
||||
"constrained" => "scalable".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn round2(v: f64) -> f64 {
|
||||
(v * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output serialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn write_output(path: &PathBuf, products: &[BrandProduct], inputs: &[BrandInput]) {
|
||||
let mut out = String::new();
|
||||
out.push_str("# Generated Minor Brand Products — The Settled Reach\n");
|
||||
out.push_str("# Auto-generated by generate_brands binary. Do not hand-edit.\n");
|
||||
out.push_str("# Re-run: tooling/generate-brands\n");
|
||||
out.push_str(&format!(
|
||||
"# Total: {} brand_products, {} brand_inputs\n\n",
|
||||
products.len(),
|
||||
inputs.len()
|
||||
));
|
||||
|
||||
for p in products {
|
||||
out.push_str("[[brand_products]]\n");
|
||||
out.push_str(&format!("brand_product_id = {:?}\n", p.brand_product_id));
|
||||
out.push_str(&format!("corp_id = {:?}\n", p.corp_id));
|
||||
out.push_str(&format!("product_name = {:?}\n", p.product_name));
|
||||
out.push_str(&format!("brand_category = {:?}\n", p.brand_category));
|
||||
out.push_str(&format!("value_trajectory = {:?}\n", p.value_trajectory));
|
||||
out.push_str(&format!("scarcity_class = {:?}\n", p.scarcity_class));
|
||||
if let Some(ref sub) = p.product_subcategory {
|
||||
out.push_str(&format!("product_subcategory = {:?}\n", sub));
|
||||
}
|
||||
out.push_str(&format!(
|
||||
"base_premium_multiplier = {:.2}\n",
|
||||
p.base_premium_multiplier
|
||||
));
|
||||
out.push_str(&format!("premium_floor = {:.2}\n", p.premium_floor));
|
||||
if let Some(ref sys) = p.origin_system {
|
||||
out.push_str(&format!("origin_system = {:?}\n", sys));
|
||||
}
|
||||
out.push_str(&format!("terroir_locked = {}\n", p.terroir_locked));
|
||||
out.push_str(&format!(
|
||||
"currency_denomination = {:?}\n",
|
||||
p.currency_denomination
|
||||
));
|
||||
out.push_str(&format!("shadow_viable = {}\n", p.shadow_viable));
|
||||
out.push_str(&format!("brand_tier = {:?}\n", p.brand_tier));
|
||||
if let Some(ref hid) = p.halo_brand_id {
|
||||
out.push_str(&format!("halo_brand_id = {:?}\n", hid));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
for i in inputs {
|
||||
out.push_str("[[brand_inputs]]\n");
|
||||
out.push_str(&format!("brand_product_id = {:?}\n", i.brand_product_id));
|
||||
out.push_str(&format!("commodity_id = {:?}\n", i.commodity_id));
|
||||
out.push_str(&format!("quantity = {:.2}\n", i.quantity));
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
std::fs::write(path, &out).unwrap_or_else(|e| {
|
||||
eprintln!("error: cannot write {}: {}", path.display(), e);
|
||||
process::exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coverage report
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn print_coverage(products: &[BrandProduct]) {
|
||||
let mut by_category: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut by_tier: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut by_corp: BTreeMap<String, usize> = BTreeMap::new();
|
||||
|
||||
for p in products {
|
||||
*by_category.entry(p.brand_category.clone()).or_insert(0) += 1;
|
||||
*by_tier.entry(p.brand_tier.clone()).or_insert(0) += 1;
|
||||
*by_corp.entry(p.corp_id.clone()).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
println!("\n Coverage Report:");
|
||||
println!(" Total brand_products: {}", products.len());
|
||||
|
||||
println!(" By category:");
|
||||
for (cat, n) in &by_category {
|
||||
println!(" {}: {}", cat, n);
|
||||
}
|
||||
|
||||
println!(" By brand_tier:");
|
||||
for (tier, n) in &by_tier {
|
||||
println!(" {}: {}", tier, n);
|
||||
}
|
||||
|
||||
let min_per_corp = by_corp.values().min().copied().unwrap_or(0);
|
||||
let max_per_corp = by_corp.values().max().copied().unwrap_or(0);
|
||||
println!(" Corps covered: {}", by_corp.len());
|
||||
println!(
|
||||
" Brands per corp: min={} max={}",
|
||||
min_per_corp, max_per_corp
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let repo_root = resolve_repo_root();
|
||||
let db_path = cli
|
||||
.db
|
||||
.unwrap_or_else(|| repo_root.join("server").join("data").join("systems.db"));
|
||||
let templates_path = cli
|
||||
.templates
|
||||
.unwrap_or_else(|| repo_root.join("wiki/economics/archetypes/brand_templates.toml"));
|
||||
|
||||
// Resolve output relative to repo root if it's a relative path
|
||||
let output_path = if cli.output.is_relative() {
|
||||
repo_root.join(&cli.output)
|
||||
} else {
|
||||
cli.output.clone()
|
||||
};
|
||||
|
||||
println!("\n Minor Brand Generator");
|
||||
println!(" DB: {}", db_path.display());
|
||||
println!(" Templates: {}", templates_path.display());
|
||||
println!(" Output: {}", output_path.display());
|
||||
println!(" Seed: {}", cli.seed);
|
||||
println!(" Target: {} brand_product rows", cli.min_brands);
|
||||
println!();
|
||||
|
||||
// Load templates
|
||||
println!(" [1/5] Loading brand archetype templates...");
|
||||
let templates = load_templates(&templates_path);
|
||||
println!(" {} templates loaded", templates.len());
|
||||
|
||||
// Open DB
|
||||
let conn = Connection::open(&db_path).unwrap_or_else(|e| {
|
||||
eprintln!("error: cannot open {}: {}", db_path.display(), e);
|
||||
process::exit(1);
|
||||
});
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("error: failed to configure DB pragmas: {}", e);
|
||||
process::exit(1);
|
||||
});
|
||||
|
||||
// Load corps and commodity IDs
|
||||
println!(" [2/5] Loading corporations and commodities...");
|
||||
let corps = load_corps(&conn);
|
||||
let valid_commodities = load_valid_commodity_ids(&conn);
|
||||
println!(
|
||||
" {} corps, {} valid commodities",
|
||||
corps.len(),
|
||||
valid_commodities.len()
|
||||
);
|
||||
|
||||
// Generate brand pairs
|
||||
println!(" [3/5] Generating brand pairs...");
|
||||
let mut rng = ChaCha8Rng::seed_from_u64(cli.seed);
|
||||
let mut products: Vec<BrandProduct> = Vec::new();
|
||||
let mut inputs: Vec<BrandInput> = Vec::new();
|
||||
let mut seen_ids: BTreeSet<String> = BTreeSet::new();
|
||||
let mut skipped = 0usize;
|
||||
|
||||
for (template_key, template) in &templates {
|
||||
for corp in &corps {
|
||||
if !corp_eligible(
|
||||
corp,
|
||||
&template.scale_tier,
|
||||
parse_template_corridor(&template.naming_pattern),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let halo_bid = halo_id(
|
||||
&corp.corp_id,
|
||||
&template.archetype_group,
|
||||
&template.scale_tier,
|
||||
);
|
||||
let vol_bid = volume_id(
|
||||
&corp.corp_id,
|
||||
&template.archetype_group,
|
||||
&template.scale_tier,
|
||||
);
|
||||
|
||||
// Skip if IDs already generated (name-collision guard)
|
||||
if seen_ids.contains(&halo_bid) || seen_ids.contains(&vol_bid) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(pair) =
|
||||
generate_pair(&mut rng, corp, template_key, template, &valid_commodities)
|
||||
{
|
||||
seen_ids.insert(halo_bid);
|
||||
seen_ids.insert(vol_bid);
|
||||
products.push(pair.halo);
|
||||
products.push(pair.volume);
|
||||
inputs.extend(pair.inputs_halo);
|
||||
inputs.extend(pair.inputs_volume);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
" {} brand_products generated ({} skipped)",
|
||||
products.len(),
|
||||
skipped
|
||||
);
|
||||
|
||||
// Gap-fill: if under target, add more by re-applying reach_wide templates
|
||||
// with a counter suffix to avoid ID collisions.
|
||||
if products.len() < cli.min_brands {
|
||||
println!(" [4/5] Gap-fill to reach {} rows...", cli.min_brands);
|
||||
let reach_wide_templates: Vec<&(String, BrandTemplate)> = templates
|
||||
.iter()
|
||||
.filter(|(_, t)| t.scale_tier == "reach_wide")
|
||||
.collect();
|
||||
|
||||
let mut counter = 0usize;
|
||||
let mut template_idx = 0usize;
|
||||
|
||||
while products.len() < cli.min_brands && !reach_wide_templates.is_empty() {
|
||||
let (template_key, template) =
|
||||
reach_wide_templates[template_idx % reach_wide_templates.len()];
|
||||
let corp = &corps[counter % corps.len()];
|
||||
|
||||
counter += 1;
|
||||
template_idx += 1;
|
||||
|
||||
// Use a suffixed ID to avoid duplicates
|
||||
let suffix = counter;
|
||||
let halo_bid = format!(
|
||||
"{}-{}-rw-halo-{}",
|
||||
corp.corp_id,
|
||||
archetype_slug(&template.archetype_group),
|
||||
suffix
|
||||
);
|
||||
let vol_bid = format!(
|
||||
"{}-{}-rw-vol-{}",
|
||||
corp.corp_id,
|
||||
archetype_slug(&template.archetype_group),
|
||||
suffix
|
||||
);
|
||||
|
||||
if seen_ids.contains(&halo_bid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(pair) =
|
||||
generate_pair(&mut rng, corp, template_key, template, &valid_commodities)
|
||||
{
|
||||
let mut halo = pair.halo;
|
||||
let mut volume = pair.volume;
|
||||
halo.brand_product_id = halo_bid.clone();
|
||||
volume.brand_product_id = vol_bid.clone();
|
||||
volume.halo_brand_id = Some(halo_bid.clone());
|
||||
|
||||
let mut inputs_halo = pair.inputs_halo;
|
||||
let mut inputs_volume = pair.inputs_volume;
|
||||
for i in &mut inputs_halo {
|
||||
i.brand_product_id = halo_bid.clone();
|
||||
}
|
||||
for i in &mut inputs_volume {
|
||||
i.brand_product_id = vol_bid.clone();
|
||||
}
|
||||
|
||||
seen_ids.insert(halo_bid);
|
||||
seen_ids.insert(vol_bid);
|
||||
products.push(halo);
|
||||
products.push(volume);
|
||||
inputs.extend(inputs_halo);
|
||||
inputs.extend(inputs_volume);
|
||||
}
|
||||
|
||||
// Safety: avoid infinite loop if gap-fill produces no progress
|
||||
if counter > cli.min_brands * 2 {
|
||||
eprintln!("warning: gap-fill exhausted after {} iterations", counter);
|
||||
break;
|
||||
}
|
||||
}
|
||||
println!(" {} brand_products after gap-fill", products.len());
|
||||
} else {
|
||||
println!(" [4/5] Target reached — no gap-fill needed");
|
||||
}
|
||||
|
||||
// Coverage report
|
||||
print_coverage(&products);
|
||||
|
||||
// Write output
|
||||
println!("\n [5/5] Writing output...");
|
||||
write_output(&output_path, &products, &inputs);
|
||||
println!(
|
||||
" {} brand_products, {} brand_inputs",
|
||||
products.len(),
|
||||
inputs.len()
|
||||
);
|
||||
println!(" Output: {}", output_path.display());
|
||||
println!(" Done.\n");
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
//! Deterministic product name generation for minor brand instances.
|
||||
//!
|
||||
//! Reuses the corridor surname pools from generate_corporations/names.rs
|
||||
//! but combines them with category-specific product descriptors rather
|
||||
//! than business suffixes. Halo and volume tiers get distinct descriptor
|
||||
//! pools so the output sounds differentiated.
|
||||
|
||||
use rand::prelude::*;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Surname pools (same corpus as generate_corporations/names.rs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CORE_NAMES: &[&str] = &[
|
||||
"Alvarez",
|
||||
"Benoit",
|
||||
"Carvalho",
|
||||
"Durand",
|
||||
"Eriksen",
|
||||
"Fournier",
|
||||
"Gao",
|
||||
"Hartmann",
|
||||
"Ishida",
|
||||
"Johansson",
|
||||
"Kirchner",
|
||||
"Lemaire",
|
||||
"Moreau",
|
||||
"Nakamura",
|
||||
"Olsson",
|
||||
"Pelletier",
|
||||
"Richter",
|
||||
"Saito",
|
||||
"Torres",
|
||||
"Ueda",
|
||||
"Vasquez",
|
||||
"Werner",
|
||||
"Xu",
|
||||
"Yamada",
|
||||
"Zhou",
|
||||
"Andersen",
|
||||
"Beaumont",
|
||||
"Costa",
|
||||
"Delacroix",
|
||||
"Engel",
|
||||
"Fujita",
|
||||
"Gutierrez",
|
||||
"Hayashi",
|
||||
"Ibarra",
|
||||
"Jensen",
|
||||
"Klein",
|
||||
"Laurent",
|
||||
"Mercier",
|
||||
"Novak",
|
||||
"Ortiz",
|
||||
"Park",
|
||||
"Reuter",
|
||||
"Suzuki",
|
||||
"Takahashi",
|
||||
"Ulrich",
|
||||
"Valentin",
|
||||
"Wagner",
|
||||
"Xie",
|
||||
"Yilmaz",
|
||||
"Zhang",
|
||||
];
|
||||
|
||||
const NORTH_REACH_NAMES: &[&str] = &[
|
||||
"Andersson",
|
||||
"Bjornsson",
|
||||
"Calloway",
|
||||
"Dalsgaard",
|
||||
"Eklund",
|
||||
"Falk",
|
||||
"Grimstad",
|
||||
"Hedlund",
|
||||
"Ivarsson",
|
||||
"Jonasson",
|
||||
"Kirkpatrick",
|
||||
"Lindqvist",
|
||||
"MacLeod",
|
||||
"Nordstrom",
|
||||
"Olafsson",
|
||||
"Pettersson",
|
||||
"Rehn",
|
||||
"Strandberg",
|
||||
"Thorsen",
|
||||
"Ulvskog",
|
||||
"Vikstrom",
|
||||
"Wahlberg",
|
||||
"Aberg",
|
||||
"Berglund",
|
||||
"Carlsen",
|
||||
"Dalgaard",
|
||||
"Engstrom",
|
||||
"Forsell",
|
||||
"Gustafsson",
|
||||
"Halvorsen",
|
||||
"Ingvarsson",
|
||||
"Jansson",
|
||||
"Knudsen",
|
||||
"Lundin",
|
||||
"MacPherson",
|
||||
"Nylund",
|
||||
"Ostergaard",
|
||||
"Palsson",
|
||||
"Rasmussen",
|
||||
"Sjoberg",
|
||||
"Toft",
|
||||
"Ulfsson",
|
||||
"Vestergaard",
|
||||
"Wiklund",
|
||||
"Aasen",
|
||||
"Brannstrom",
|
||||
"Dahl",
|
||||
"Eide",
|
||||
"Friberg",
|
||||
"Gren",
|
||||
];
|
||||
|
||||
const SOUTH_REACH_NAMES: &[&str] = &[
|
||||
"Adamski",
|
||||
"Baranov",
|
||||
"Chernov",
|
||||
"Dubois",
|
||||
"Egorov",
|
||||
"Filipov",
|
||||
"Gromov",
|
||||
"Horvat",
|
||||
"Ivanova",
|
||||
"Jankovic",
|
||||
"Kowalski",
|
||||
"Lazarev",
|
||||
"Morozov",
|
||||
"Novikov",
|
||||
"Ostrowski",
|
||||
"Petrov",
|
||||
"Reznik",
|
||||
"Sokolov",
|
||||
"Tkachenko",
|
||||
"Uvarov",
|
||||
"Volkov",
|
||||
"Wojcik",
|
||||
"Yakimov",
|
||||
"Zheng",
|
||||
"Babic",
|
||||
"Chernyshev",
|
||||
"Dragunov",
|
||||
"Fedorov",
|
||||
"Grushevsky",
|
||||
"Havel",
|
||||
"Ito",
|
||||
"Jovanovic",
|
||||
"Katsaros",
|
||||
"Lebedev",
|
||||
"Mazur",
|
||||
"Nemec",
|
||||
"Ochoa",
|
||||
"Popov",
|
||||
"Radic",
|
||||
"Smirnov",
|
||||
"Tanaka",
|
||||
"Urasawa",
|
||||
"Vasiliev",
|
||||
"Watanabe",
|
||||
"Xiang",
|
||||
"Yegorov",
|
||||
"Zaytsev",
|
||||
"Borysko",
|
||||
"Chen",
|
||||
"Dimitrov",
|
||||
];
|
||||
|
||||
const WEST_REACH_NAMES: &[&str] = &[
|
||||
"Albrecht",
|
||||
"Baumann",
|
||||
"Christensen",
|
||||
"Dietrich",
|
||||
"Eisenberg",
|
||||
"Fischer",
|
||||
"Gruber",
|
||||
"Hoffmann",
|
||||
"Ingolstadt",
|
||||
"Jaeger",
|
||||
"Kessler",
|
||||
"Lehmann",
|
||||
"Mueller",
|
||||
"Neumann",
|
||||
"Obermann",
|
||||
"Pfeiffer",
|
||||
"Quandt",
|
||||
"Roth",
|
||||
"Schaefer",
|
||||
"Thiel",
|
||||
"Urban",
|
||||
"Vogt",
|
||||
"Weidenfeld",
|
||||
"Ziegler",
|
||||
"Becker",
|
||||
"Claussen",
|
||||
"Dorfmann",
|
||||
"Eberhardt",
|
||||
"Fleischer",
|
||||
"Gerstner",
|
||||
"Haber",
|
||||
"Imhof",
|
||||
"Jung",
|
||||
"Kraemer",
|
||||
"Linden",
|
||||
"Metzger",
|
||||
"Niedermann",
|
||||
"Opitz",
|
||||
"Preuss",
|
||||
"Raabe",
|
||||
"Steinbach",
|
||||
"Trautmann",
|
||||
"Unger",
|
||||
"Vollmer",
|
||||
"Winterberg",
|
||||
"Zahn",
|
||||
"Auerbach",
|
||||
"Bruckner",
|
||||
"Dahlem",
|
||||
"Eckhardt",
|
||||
];
|
||||
|
||||
const EAST_REACH_NAMES: &[&str] = &[
|
||||
"Aquino",
|
||||
"Bautista",
|
||||
"Cruz",
|
||||
"Dalisay",
|
||||
"Espiritu",
|
||||
"Flores",
|
||||
"Garcia",
|
||||
"Hernandez",
|
||||
"Ilagan",
|
||||
"Jeon",
|
||||
"Kim",
|
||||
"Lim",
|
||||
"Magalang",
|
||||
"Navarro",
|
||||
"Ocampo",
|
||||
"Park",
|
||||
"Quijano",
|
||||
"Reyes",
|
||||
"Santos",
|
||||
"Tan",
|
||||
"Uy",
|
||||
"Villanueva",
|
||||
"Wong",
|
||||
"Yoo",
|
||||
"Aguilar",
|
||||
"Buenaventura",
|
||||
"Castillo",
|
||||
"Dizon",
|
||||
"Enriquez",
|
||||
"Fernandez",
|
||||
"Gonzales",
|
||||
"Hwang",
|
||||
"Ignacio",
|
||||
"Jeong",
|
||||
"Kwon",
|
||||
"Lee",
|
||||
"Marasigan",
|
||||
"Nakamura",
|
||||
"Oh",
|
||||
"Perez",
|
||||
"Ramos",
|
||||
"Son",
|
||||
"Tolentino",
|
||||
"Umali",
|
||||
"Valdez",
|
||||
"Yun",
|
||||
"Zamora",
|
||||
"Baek",
|
||||
"Choi",
|
||||
"Dela Cruz",
|
||||
];
|
||||
|
||||
const FRONTIER_NAMES: &[&str] = &[
|
||||
"Adeyemi",
|
||||
"Bergstrom",
|
||||
"Chandra",
|
||||
"Duval",
|
||||
"Emeka",
|
||||
"Fonseca",
|
||||
"Gupta",
|
||||
"Hassan",
|
||||
"Ibrahim",
|
||||
"Jansson",
|
||||
"Kovac",
|
||||
"Liu",
|
||||
"Martinez",
|
||||
"Nkosi",
|
||||
"Okafor",
|
||||
"Patel",
|
||||
"Quinn",
|
||||
"Rodriguez",
|
||||
"Sousa",
|
||||
"Thorne",
|
||||
"Uddin",
|
||||
"Varga",
|
||||
"Wu",
|
||||
"Xiong",
|
||||
"Yoshida",
|
||||
"Zhao",
|
||||
"Abara",
|
||||
"Beaumont",
|
||||
"Cardenas",
|
||||
"Doyle",
|
||||
"Ekwueme",
|
||||
"Ferreira",
|
||||
"Gomes",
|
||||
"Henriksen",
|
||||
"Idris",
|
||||
"Juma",
|
||||
"Kato",
|
||||
"Larsen",
|
||||
"Morales",
|
||||
"Ndlovu",
|
||||
"Osei",
|
||||
"Petrov",
|
||||
"Ruiz",
|
||||
"Singh",
|
||||
"Tavares",
|
||||
"Uchida",
|
||||
"Volkov",
|
||||
"Wang",
|
||||
"Yang",
|
||||
"Zaman",
|
||||
];
|
||||
|
||||
fn names_for_corridor(corridor: &str) -> &'static [&'static str] {
|
||||
match corridor {
|
||||
"north_reach" | "north_reach/compact" => NORTH_REACH_NAMES,
|
||||
"south_reach" => SOUTH_REACH_NAMES,
|
||||
"west_reach" | "west_reach/compact" => WEST_REACH_NAMES,
|
||||
"east_reach" | "inner_corridor/east_reach" => EAST_REACH_NAMES,
|
||||
"frontier" => FRONTIER_NAMES,
|
||||
_ => CORE_NAMES, // core / inner_corridor / reach_wide
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Product descriptor pools by brand_category × tier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TERROIR_HALO: &[&str] = &[
|
||||
"Reserve",
|
||||
"Single",
|
||||
"Estate",
|
||||
"Vintage",
|
||||
"Heritage",
|
||||
"Grand",
|
||||
"Limited",
|
||||
"Prestige",
|
||||
"Signature",
|
||||
"Cellar",
|
||||
"Select",
|
||||
"Cru",
|
||||
"Premier",
|
||||
"Old",
|
||||
"Aged",
|
||||
];
|
||||
|
||||
const TERROIR_VOLUME: &[&str] = &[
|
||||
"Standard", "Export", "Blend", "Classic", "Field", "Ordinary", "Running", "Table", "Regular",
|
||||
"Common", "House", "Station", "Corridor", "Transit",
|
||||
];
|
||||
|
||||
const HERITAGE_CRAFT_HALO: &[&str] = &[
|
||||
"Heritage",
|
||||
"Limited",
|
||||
"Artisan",
|
||||
"Master",
|
||||
"Guild",
|
||||
"Prestige",
|
||||
"Premium",
|
||||
"Classic",
|
||||
"Signature",
|
||||
"Original",
|
||||
"Bespoke",
|
||||
"Traditional",
|
||||
"First",
|
||||
];
|
||||
|
||||
const HERITAGE_CRAFT_VOLUME: &[&str] = &[
|
||||
"Standard", "Classic", "Working", "Everyday", "Regular", "Plain", "Field", "Grade", "Basic",
|
||||
"Workshop", "Studio", "Common",
|
||||
];
|
||||
|
||||
const TECH_PREMIUM_HALO: &[&str] = &[
|
||||
"Elite",
|
||||
"Pro",
|
||||
"Advanced",
|
||||
"Precision",
|
||||
"Superior",
|
||||
"Grand",
|
||||
"Signature",
|
||||
"First",
|
||||
"Prime",
|
||||
"Expert",
|
||||
"Master",
|
||||
"Apex",
|
||||
"Summit",
|
||||
];
|
||||
|
||||
const TECH_PREMIUM_VOLUME: &[&str] = &[
|
||||
"Standard",
|
||||
"Series",
|
||||
"Base",
|
||||
"Classic",
|
||||
"Regular",
|
||||
"Field",
|
||||
"Grade",
|
||||
"Value",
|
||||
"Essential",
|
||||
"Core",
|
||||
"Basic",
|
||||
];
|
||||
|
||||
const CULTURAL_HALO: &[&str] = &[
|
||||
"Archive",
|
||||
"Heritage",
|
||||
"Classic",
|
||||
"Definitive",
|
||||
"Prestige",
|
||||
"Limited",
|
||||
"Master",
|
||||
"Collected",
|
||||
"Curated",
|
||||
"Canonical",
|
||||
"Flagship",
|
||||
"Grand",
|
||||
];
|
||||
|
||||
const CULTURAL_VOLUME: &[&str] = &[
|
||||
"Standard",
|
||||
"Classic",
|
||||
"Essential",
|
||||
"Value",
|
||||
"Regular",
|
||||
"Base",
|
||||
"Field",
|
||||
"Running",
|
||||
"Everyday",
|
||||
"Popular",
|
||||
];
|
||||
|
||||
const SERVICE_PREMIUM_HALO: &[&str] = &[
|
||||
"Premier",
|
||||
"Elite",
|
||||
"Priority",
|
||||
"Signature",
|
||||
"Prestige",
|
||||
"Grand",
|
||||
"First",
|
||||
"Select",
|
||||
"Platinum",
|
||||
"Gold",
|
||||
"Senior",
|
||||
"Executive",
|
||||
];
|
||||
|
||||
const SERVICE_PREMIUM_VOLUME: &[&str] = &[
|
||||
"Standard",
|
||||
"Basic",
|
||||
"Classic",
|
||||
"Regular",
|
||||
"Field",
|
||||
"Value",
|
||||
"General",
|
||||
"Common",
|
||||
"Ordinary",
|
||||
"Essential",
|
||||
];
|
||||
|
||||
const COMMODITY_BRANDED_HALO: &[&str] = &[
|
||||
"Original",
|
||||
"Select",
|
||||
"Premium",
|
||||
"Reserve",
|
||||
"Classic",
|
||||
"Heritage",
|
||||
"Signature",
|
||||
"Superior",
|
||||
"First",
|
||||
"Grade",
|
||||
"Certified",
|
||||
];
|
||||
|
||||
const COMMODITY_BRANDED_VOLUME: &[&str] = &[
|
||||
"Standard", "Basic", "Regular", "Field", "Value", "Economy", "Bulk", "Run", "Common", "Grade",
|
||||
"Plain",
|
||||
];
|
||||
|
||||
const DESIGN_HERITAGE_HALO: &[&str] = &[
|
||||
"Heritage",
|
||||
"Limited",
|
||||
"Prestige",
|
||||
"Grand",
|
||||
"Signature",
|
||||
"Edition",
|
||||
"Series",
|
||||
"Classic",
|
||||
"Archive",
|
||||
"Collector",
|
||||
"Retrospective",
|
||||
];
|
||||
|
||||
const DESIGN_HERITAGE_VOLUME: &[&str] = &[
|
||||
"Standard",
|
||||
"Classic",
|
||||
"Regular",
|
||||
"Field",
|
||||
"Value",
|
||||
"Base",
|
||||
"Essential",
|
||||
"Running",
|
||||
"Contemporary",
|
||||
"Current",
|
||||
];
|
||||
|
||||
const PLATFORM_CATALOGUE_HALO: &[&str] = &[
|
||||
"Premium",
|
||||
"Pro",
|
||||
"Plus",
|
||||
"Elite",
|
||||
"Advanced",
|
||||
"Signature",
|
||||
"Select",
|
||||
"Grand",
|
||||
"Unlimited",
|
||||
"Complete",
|
||||
"Full",
|
||||
];
|
||||
|
||||
const PLATFORM_CATALOGUE_VOLUME: &[&str] = &[
|
||||
"Standard",
|
||||
"Basic",
|
||||
"Classic",
|
||||
"Regular",
|
||||
"Field",
|
||||
"Value",
|
||||
"Entry",
|
||||
"Lite",
|
||||
"Essential",
|
||||
"Free",
|
||||
];
|
||||
|
||||
fn halo_descriptors(brand_category: &str) -> &'static [&'static str] {
|
||||
match brand_category {
|
||||
"terroir" => TERROIR_HALO,
|
||||
"heritage_craft" => HERITAGE_CRAFT_HALO,
|
||||
"tech_premium" => TECH_PREMIUM_HALO,
|
||||
"cultural" => CULTURAL_HALO,
|
||||
"service_premium" => SERVICE_PREMIUM_HALO,
|
||||
"commodity_branded" => COMMODITY_BRANDED_HALO,
|
||||
"design_heritage" => DESIGN_HERITAGE_HALO,
|
||||
"platform_catalogue" => PLATFORM_CATALOGUE_HALO,
|
||||
_ => COMMODITY_BRANDED_HALO,
|
||||
}
|
||||
}
|
||||
|
||||
fn volume_descriptors(brand_category: &str) -> &'static [&'static str] {
|
||||
match brand_category {
|
||||
"terroir" => TERROIR_VOLUME,
|
||||
"heritage_craft" => HERITAGE_CRAFT_VOLUME,
|
||||
"tech_premium" => TECH_PREMIUM_VOLUME,
|
||||
"cultural" => CULTURAL_VOLUME,
|
||||
"service_premium" => SERVICE_PREMIUM_VOLUME,
|
||||
"commodity_branded" => COMMODITY_BRANDED_VOLUME,
|
||||
"design_heritage" => DESIGN_HERITAGE_VOLUME,
|
||||
"platform_catalogue" => PLATFORM_CATALOGUE_VOLUME,
|
||||
_ => COMMODITY_BRANDED_VOLUME,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a plausible halo-tier product name.
|
||||
/// Deterministic for a given RNG state.
|
||||
pub fn generate_halo_name(rng: &mut ChaCha8Rng, corridor: &str, brand_category: &str) -> String {
|
||||
let names = names_for_corridor(corridor);
|
||||
let descs = halo_descriptors(brand_category);
|
||||
|
||||
let surname = names[rng.random_range(0..names.len())];
|
||||
let desc = descs[rng.random_range(0..descs.len())];
|
||||
|
||||
// 25% chance: "Surname & Surname Descriptor" double-barrel
|
||||
if rng.random::<f64>() < 0.25 {
|
||||
let surname2 = names[rng.random_range(0..names.len())];
|
||||
if surname != surname2 {
|
||||
return format!("{} & {} {}", surname, surname2, desc);
|
||||
}
|
||||
}
|
||||
|
||||
format!("{} {}", surname, desc)
|
||||
}
|
||||
|
||||
/// Generate a plausible volume-tier product name.
|
||||
/// Volume names are shorter and more utilitarian than halo names.
|
||||
pub fn generate_volume_name(rng: &mut ChaCha8Rng, corridor: &str, brand_category: &str) -> String {
|
||||
let names = names_for_corridor(corridor);
|
||||
let descs = volume_descriptors(brand_category);
|
||||
|
||||
let surname = names[rng.random_range(0..names.len())];
|
||||
let desc = descs[rng.random_range(0..descs.len())];
|
||||
|
||||
format!("{} {}", surname, desc)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rand::SeedableRng;
|
||||
|
||||
#[test]
|
||||
fn halo_names_not_empty() {
|
||||
let mut rng = ChaCha8Rng::seed_from_u64(1);
|
||||
for corridor in &[
|
||||
"north_reach",
|
||||
"south_reach",
|
||||
"west_reach",
|
||||
"east_reach",
|
||||
"frontier",
|
||||
"core",
|
||||
"reach_wide",
|
||||
] {
|
||||
for cat in &[
|
||||
"terroir",
|
||||
"heritage_craft",
|
||||
"tech_premium",
|
||||
"cultural",
|
||||
"service_premium",
|
||||
"commodity_branded",
|
||||
"design_heritage",
|
||||
"platform_catalogue",
|
||||
] {
|
||||
let name = generate_halo_name(&mut rng, corridor, cat);
|
||||
assert!(!name.is_empty());
|
||||
assert!(name.contains(' '));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_names_not_empty() {
|
||||
let mut rng = ChaCha8Rng::seed_from_u64(2);
|
||||
for corridor in &["north_reach", "east_reach", "reach_wide"] {
|
||||
for cat in &["terroir", "tech_premium", "cultural"] {
|
||||
let name = generate_volume_name(&mut rng, corridor, cat);
|
||||
assert!(!name.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_names() {
|
||||
let mut rng1 = ChaCha8Rng::seed_from_u64(42);
|
||||
let mut rng2 = ChaCha8Rng::seed_from_u64(42);
|
||||
for _ in 0..50 {
|
||||
let a = generate_halo_name(&mut rng1, "north_reach", "terroir");
|
||||
let b = generate_halo_name(&mut rng2, "north_reach", "terroir");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Bookmark definition system (#614, D-115/D-117).
|
||||
//!
|
||||
//! A bookmark is a named starting scenario: skills weighting + starting state +
|
||||
//! flavor text. The player chooses a bookmark on the character-creation screen.
|
||||
//! v0.2 ships exactly one bookmark: `"tycoon"`.
|
||||
|
||||
pub mod types;
|
||||
|
||||
pub use types::{BookmarkDefinition, BookmarkId, CareerKind};
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::bridge::types::{BookmarkCatalog, BookmarkWire, CareerKindWire, SnapshotBuffer};
|
||||
use crate::knowledge::{resolve_culture, CultureResolver};
|
||||
|
||||
/// Immutable registry of bookmark definitions.
|
||||
///
|
||||
/// Built once during `BookmarkPlugin::build`, read-only at runtime.
|
||||
/// `BTreeMap` for deterministic iteration (D-010 principle 4, D-041).
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct BookmarkRegistry {
|
||||
entries: BTreeMap<String, BookmarkDefinition>,
|
||||
}
|
||||
|
||||
impl BookmarkRegistry {
|
||||
pub fn insert(&mut self, defn: BookmarkDefinition) {
|
||||
self.entries.insert(defn.id.0.clone(), defn);
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&BookmarkDefinition> {
|
||||
self.entries.get(id)
|
||||
}
|
||||
|
||||
pub fn available(&self) -> impl Iterator<Item = &BookmarkDefinition> {
|
||||
self.entries.values().filter(|d| d.available)
|
||||
}
|
||||
|
||||
pub fn contains(&self, id: &str) -> bool {
|
||||
self.entries.contains_key(id)
|
||||
}
|
||||
|
||||
/// Build the wire catalog for the client.
|
||||
///
|
||||
/// `allowed_locations_cultures` is populated via the culture resolver (D-128, #679)
|
||||
/// when one is provided. Entries for locations that fail resolution are empty strings
|
||||
/// — callers should log a warning; see the error handling spec in #679.
|
||||
pub fn build_catalog(&self, culture: Option<&CultureResolver>) -> BookmarkCatalog {
|
||||
let bookmarks: Vec<BookmarkWire> = self
|
||||
.available()
|
||||
.map(|defn| {
|
||||
let allowed_locations_cultures = defn
|
||||
.allowed_locations
|
||||
.iter()
|
||||
.map(|loc| {
|
||||
culture
|
||||
.and_then(|r| match resolve_culture(r, loc) {
|
||||
Ok(tag) => Some(tag.0),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
location = %loc,
|
||||
error = %e,
|
||||
"culture resolution failed for bookmark location"
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect();
|
||||
BookmarkWire {
|
||||
id: defn.id.0.clone(),
|
||||
title: defn.title.clone(),
|
||||
subtitle: defn.subtitle.clone(),
|
||||
flavor: defn.flavor.clone(),
|
||||
default_location: defn.default_location.clone(),
|
||||
allowed_locations: defn.allowed_locations.clone(),
|
||||
allowed_locations_cultures,
|
||||
career: CareerKindWire::from(defn.career),
|
||||
starting_capital_tractus: defn.starting_capital_tractus,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
BookmarkCatalog { bookmarks }
|
||||
}
|
||||
}
|
||||
|
||||
/// The confirmed bookmark selection for the current session.
|
||||
/// Populated when `ConfirmBookmark` is processed. `None` during the
|
||||
/// character-creation phase (before confirm) and always `None` in a
|
||||
/// fresh session.
|
||||
///
|
||||
/// **v0.2 scope: transient only.** Not serialized — save/load of
|
||||
/// `SelectedBookmark` is deferred to Sprint 37 (follow-up ticket
|
||||
/// filed alongside #614). Add `Serialize`/`Deserialize` derives and
|
||||
/// wire into `SaveState` when that ticket is claimed.
|
||||
///
|
||||
/// Downstream systems (apartment generator, skill seeder) read from this resource.
|
||||
// TODO(sprint-37): serialize — see #863
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct SelectedBookmark {
|
||||
pub bookmark_id: Option<String>,
|
||||
pub starting_location_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Bookmark system plugin.
|
||||
///
|
||||
/// Registers `BookmarkRegistry`, `SelectedBookmark`, and the startup system that
|
||||
/// stages the initial catalog in `SnapshotBuffer` for tick-0 delivery.
|
||||
pub struct BookmarkPlugin;
|
||||
|
||||
impl Plugin for BookmarkPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let mut registry = BookmarkRegistry::default();
|
||||
register_default_bookmarks(&mut registry);
|
||||
|
||||
app.insert_resource(registry)
|
||||
.init_resource::<SelectedBookmark>()
|
||||
.add_systems(Startup, prime_initial_catalog);
|
||||
|
||||
tracing::debug!("BookmarkPlugin initialized");
|
||||
}
|
||||
}
|
||||
|
||||
/// Startup system: stage the initial bookmark catalog in `SnapshotBuffer` so it
|
||||
/// is delivered in the first `ObserverSnapshot` (tick 0).
|
||||
///
|
||||
/// `SnapshotBuffer` is registered by `BridgePlugin` (added before `BookmarkPlugin`).
|
||||
/// `CultureResolverResource` is optional — missing in test worlds and when
|
||||
/// `systems.db` is unavailable (server logs a warning in that case).
|
||||
fn prime_initial_catalog(
|
||||
registry: Res<BookmarkRegistry>,
|
||||
mut buffer: ResMut<SnapshotBuffer>,
|
||||
culture: Option<Res<crate::knowledge::CultureResolverResource>>,
|
||||
) {
|
||||
let resolver = culture.as_deref().map(|c| &c.0);
|
||||
buffer.pending_bookmark_catalog = Some(registry.build_catalog(resolver));
|
||||
tracing::debug!("BookmarkPlugin: initial catalog staged for tick-0 snapshot");
|
||||
}
|
||||
|
||||
/// Register all v0.2 bookmarks.
|
||||
///
|
||||
/// Hard-coded for now (single entry). Promote to TOML loading in Sprint 38+
|
||||
/// when a second bookmark is planned; the pattern is established by `content/brands/`.
|
||||
fn register_default_bookmarks(registry: &mut BookmarkRegistry) {
|
||||
registry.insert(BookmarkDefinition {
|
||||
id: BookmarkId("tycoon".to_string()),
|
||||
title: "Tycoon".into(),
|
||||
subtitle: "Small business owner on the make.".into(),
|
||||
// TODO(mellanie): author flavor text (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()));
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// Career seed — determines initial skill weighting, inventory, and starting business.
|
||||
pub career: CareerKind,
|
||||
/// Starting capital in Tractus (D-118 small-business scale).
|
||||
pub starting_capital_tractus: i64,
|
||||
/// Whether this bookmark is visible in the character-creation screen.
|
||||
pub available: bool,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -304,8 +304,7 @@ mod tests {
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
|
||||
follow_state: None,
|
||||
character_pressure: None,
|
||||
sound_events: vec![],
|
||||
@@ -321,6 +320,7 @@ mod tests {
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
economy_snapshot: None,
|
||||
bookmark_catalog: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,8 +446,7 @@ mod tests {
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
|
||||
follow_state: None,
|
||||
character_pressure: None,
|
||||
sound_events: vec![],
|
||||
@@ -463,6 +462,7 @@ mod tests {
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
economy_snapshot: None,
|
||||
bookmark_catalog: None,
|
||||
};
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Tick 0"));
|
||||
|
||||
+69
-10
@@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
|
||||
/// negotiation is unnecessary. Client should reject snapshots with version !=
|
||||
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
|
||||
/// period, then the default is removed once both sides are updated.
|
||||
pub const PROTOCOL_VERSION: u8 = 21;
|
||||
pub const PROTOCOL_VERSION: u8 = 23;
|
||||
|
||||
/// Handshake message sent as the very first framed message after connection (#555).
|
||||
/// Client reads this before entering the normal tick loop and validates
|
||||
@@ -83,6 +83,9 @@ pub struct StartupMessage {
|
||||
/// v20 adds: settings_response (#627, SQLite settings IPC).
|
||||
/// v21 adds: economy_snapshot (#822, D-181 7-signal snapshot per queried system),
|
||||
/// EconStateQuery PlayerAction variant (#822).
|
||||
/// v22 adds: bookmark_catalog (#614, D-115/D-117 CK3-style bookmark system),
|
||||
/// RequestBookmarkCatalog + ConfirmBookmark PlayerAction variants (#614).
|
||||
/// v23 removes: conversation_events, conversation_ended (D-078 scrapped per R-012).
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
@@ -140,15 +143,6 @@ pub struct ObserverSnapshot {
|
||||
/// Empty when no sounds are in range.
|
||||
#[serde(default)]
|
||||
pub sound_events: Vec<crate::simulation::sound::SoundEvent>,
|
||||
/// Overheard NPC-to-NPC conversation lines this tick (#247, D-078).
|
||||
/// Each event carries pre-occluded text — client renders verbatim.
|
||||
/// Empty when no conversations are overheard.
|
||||
#[serde(default)]
|
||||
pub conversation_events: Vec<crate::simulation::conversation::ConversationEvent>,
|
||||
/// Conversations that ended this tick (#247, D-078).
|
||||
/// Client dismisses the passive dialogue panel for these pairs.
|
||||
#[serde(default)]
|
||||
pub conversation_ended: Vec<crate::simulation::conversation::ConversationEndEvent>,
|
||||
/// Follow-mode state for client HUD display (#241).
|
||||
/// Present when the player is actively following an NPC.
|
||||
/// Client shows follow indicator with distance, LOS, and tension.
|
||||
@@ -224,6 +218,56 @@ pub struct ObserverSnapshot {
|
||||
/// None during normal gameplay; client queries explicitly via `EconStateQuery`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub economy_snapshot: Option<EconomySnapshot>,
|
||||
/// Bookmark catalog (#614, D-115/D-117).
|
||||
/// Present for exactly one tick after handshake (tick 0) and after
|
||||
/// `PlayerAction::RequestBookmarkCatalog`. None otherwise.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bookmark_catalog: Option<BookmarkCatalog>,
|
||||
}
|
||||
|
||||
/// Career kind wire-safe mirror for `CareerKind` (#614, D-117).
|
||||
///
|
||||
/// Kept separate from the server-internal `CareerKind` so future server-only
|
||||
/// variants (e.g. debug/test archetypes) don't leak through serde.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CareerKindWire {
|
||||
Tycoon,
|
||||
}
|
||||
|
||||
impl From<crate::bookmark::types::CareerKind> for CareerKindWire {
|
||||
fn from(k: crate::bookmark::types::CareerKind) -> Self {
|
||||
match k {
|
||||
crate::bookmark::types::CareerKind::Tycoon => CareerKindWire::Tycoon,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bookmark projection for the client (#614, D-115/D-117).
|
||||
///
|
||||
/// Sent inside `BookmarkCatalog` in `ObserverSnapshot.bookmark_catalog`.
|
||||
/// Drop server-only fields; keep the struct stable for the bridge.
|
||||
/// `allowed_locations_cultures` is pre-resolved server-side (§9 of culture spec, #679).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BookmarkWire {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub flavor: String,
|
||||
pub default_location: String,
|
||||
pub allowed_locations: Vec<String>,
|
||||
/// Parallel to `allowed_locations`: resolved culture tag per location (#679).
|
||||
/// Populated by `BookmarkRegistry::build_catalog` using `CultureResolverResource`.
|
||||
/// Empty string when the resolver has no mapping for a location.
|
||||
pub allowed_locations_cultures: Vec<String>,
|
||||
pub career: CareerKindWire,
|
||||
pub starting_capital_tractus: i64,
|
||||
}
|
||||
|
||||
/// Catalog of all available bookmarks sent to the client (#614).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BookmarkCatalog {
|
||||
pub bookmarks: Vec<BookmarkWire>,
|
||||
}
|
||||
|
||||
/// A single news ticker headline crossing the wire boundary (#591).
|
||||
@@ -564,6 +608,19 @@ pub enum PlayerAction {
|
||||
EconStateQuery {
|
||||
system_id: String,
|
||||
},
|
||||
/// Client requests the full bookmark catalog (#614).
|
||||
/// Server responds with `ObserverSnapshot.bookmark_catalog` in the next tick.
|
||||
RequestBookmarkCatalog,
|
||||
/// Player confirms character creation with a chosen bookmark (#614, #618).
|
||||
///
|
||||
/// `bookmark_id` must match a known `BookmarkId`. `starting_location_id` must
|
||||
/// be in `BookmarkDefinition.allowed_locations` for that bookmark.
|
||||
/// On invalid inputs: server pushes a `SimError { kind: ProtocolError }` and
|
||||
/// ignores the action — client should block the confirm button and re-prompt.
|
||||
ConfirmBookmark {
|
||||
bookmark_id: String,
|
||||
starting_location_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl PlayerAction {
|
||||
@@ -1038,6 +1095,8 @@ pub struct SnapshotBuffer {
|
||||
pub pending_settings_response: Option<crate::settings::types::SettingsResponseWire>,
|
||||
/// Pending economy snapshot, consumed once by `compute_observer_snapshot` (#822).
|
||||
pub pending_economy_response: Option<EconomySnapshot>,
|
||||
/// Pending bookmark catalog, consumed once by `compute_observer_snapshot` (#614).
|
||||
pub pending_bookmark_catalog: Option<BookmarkCatalog>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Location → culture resolution (D-128).
|
||||
//!
|
||||
//! Single canonical lookup: `location_id → CultureTag`.
|
||||
//! Culture is implicit in the starting location — Van Maanen's Star start =
|
||||
//! Van Maanen's Star culture. All downstream pipelines (voice, NPC blueprint,
|
||||
//! apartment generator, visual grammar) call through here, not ad-hoc queries.
|
||||
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Canonical culture identifier (D-128).
|
||||
///
|
||||
/// String-backed — cultures expand with content, not code.
|
||||
/// Value space matches `star_systems.cultural_corridor` in systems.db:
|
||||
/// `"core"`, `"sol-gateway-axis"`, `"north_reach"`, `"south_reach"`,
|
||||
/// `"east_reach"`, `"west_reach"`, `"deep_frontier"`.
|
||||
///
|
||||
/// Wire format: plain `String` over IPC.
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
|
||||
pub struct CultureTag(pub String);
|
||||
|
||||
impl CultureTag {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CultureTag {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
/// Error from culture resolution.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CultureError {
|
||||
/// `location_id` does not resolve to any known row in systems.db.
|
||||
#[error("unknown location: `{0}`")]
|
||||
UnknownLocation(String),
|
||||
|
||||
/// Location row found but `cultural_corridor` is NULL — this is a data bug.
|
||||
#[error("no culture assigned to location `{0}` in systems.db")]
|
||||
NoCulture(String),
|
||||
|
||||
/// Underlying SQLite error.
|
||||
#[error("database error: {0}")]
|
||||
Db(String),
|
||||
}
|
||||
|
||||
/// Handle that owns the DB connection.
|
||||
///
|
||||
/// Constructed once at startup against `server/data/systems.db`.
|
||||
/// `Mutex<Connection>` mirrors `SettingsStoreResource` — rusqlite `Connection`
|
||||
/// is `!Sync`. All queries are indexed primary-key lookups: sub-microsecond.
|
||||
pub struct CultureResolver {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl CultureResolver {
|
||||
/// Open a resolver. `SQLITE_OPEN_READ_ONLY` — purely query-side.
|
||||
pub fn open(path: &Path) -> Result<Self, CultureError> {
|
||||
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| CultureError::Db(e.to_string()))?;
|
||||
Ok(CultureResolver {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Bevy `Resource` wrapper — `Res<CultureResolverResource>` in systems.
|
||||
#[derive(Resource)]
|
||||
pub struct CultureResolverResource(pub CultureResolver);
|
||||
|
||||
/// Resolve a `location_id` to the `CultureTag` it implies (D-128).
|
||||
///
|
||||
/// Accepted forms:
|
||||
/// - `system_id` — e.g. `"GJ 35"` (matched against `star_systems.system_id`)
|
||||
/// - `body_id` — e.g. `"GJ 35-2"` (body override wins; falls to parent system)
|
||||
/// - `station_id` — e.g. `"sova-transit"` (always falls through to parent system)
|
||||
///
|
||||
/// Tries each table in order, returns the first match.
|
||||
/// For v0.2 bookmark selection, callers pass `system_id`, but the function is
|
||||
/// body/station-aware so downstream systems don't need a second lookup path.
|
||||
///
|
||||
/// # Errors
|
||||
/// - `CultureError::UnknownLocation` — not found in any table.
|
||||
/// - `CultureError::NoCulture` — found but culture column is NULL (data bug).
|
||||
/// - `CultureError::Db` — SQLite I/O failure.
|
||||
pub fn resolve_culture(
|
||||
resolver: &CultureResolver,
|
||||
location_id: &str,
|
||||
) -> Result<CultureTag, CultureError> {
|
||||
let conn = resolver
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| CultureError::Db(format!("mutex poisoned: {}", e)))?;
|
||||
|
||||
if let Some(culture) = query_system_culture(&conn, location_id)? {
|
||||
return Ok(CultureTag(culture));
|
||||
}
|
||||
if let Some(culture) = query_body_culture(&conn, location_id)? {
|
||||
return Ok(CultureTag(culture));
|
||||
}
|
||||
if let Some(culture) = query_station_culture(&conn, location_id)? {
|
||||
return Ok(CultureTag(culture));
|
||||
}
|
||||
|
||||
Err(CultureError::UnknownLocation(location_id.to_string()))
|
||||
}
|
||||
|
||||
/// Try `star_systems` by `system_id`.
|
||||
/// Returns `Ok(None)` if no row. `Ok(Some(culture))` or `Err(NoCulture)` if found.
|
||||
fn query_system_culture(conn: &Connection, loc: &str) -> Result<Option<String>, CultureError> {
|
||||
let result: rusqlite::Result<Option<Option<String>>> = conn
|
||||
.query_row(
|
||||
"SELECT cultural_corridor FROM star_systems WHERE system_id = ?1",
|
||||
[loc],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map(Some)
|
||||
.or_else(|e| match e {
|
||||
rusqlite::Error::QueryReturnedNoRows => Ok(None),
|
||||
other => Err(other),
|
||||
});
|
||||
|
||||
match result.map_err(|e| CultureError::Db(e.to_string()))? {
|
||||
None => Ok(None),
|
||||
Some(Some(c)) => Ok(Some(c)),
|
||||
Some(None) => Err(CultureError::NoCulture(loc.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try `bodies` by `body_id`. Body's own `cultural_corridor` wins; NULL falls
|
||||
/// through to parent system's corridor via COALESCE.
|
||||
fn query_body_culture(conn: &Connection, loc: &str) -> Result<Option<String>, CultureError> {
|
||||
let result: rusqlite::Result<Option<Option<String>>> = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(b.cultural_corridor, s.cultural_corridor) \
|
||||
FROM bodies b \
|
||||
JOIN star_systems s ON s.system_id = b.system_id \
|
||||
WHERE b.body_id = ?1",
|
||||
[loc],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map(Some)
|
||||
.or_else(|e| match e {
|
||||
rusqlite::Error::QueryReturnedNoRows => Ok(None),
|
||||
other => Err(other),
|
||||
});
|
||||
|
||||
match result.map_err(|e| CultureError::Db(e.to_string()))? {
|
||||
None => Ok(None),
|
||||
Some(Some(c)) => Ok(Some(c)),
|
||||
Some(None) => Err(CultureError::NoCulture(loc.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try `stations` by `station_id`. Always falls through to parent system's corridor.
|
||||
fn query_station_culture(conn: &Connection, loc: &str) -> Result<Option<String>, CultureError> {
|
||||
let result: rusqlite::Result<Option<Option<String>>> = conn
|
||||
.query_row(
|
||||
"SELECT s.cultural_corridor \
|
||||
FROM stations st \
|
||||
JOIN star_systems s ON s.system_id = st.system_id \
|
||||
WHERE st.station_id = ?1",
|
||||
[loc],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map(Some)
|
||||
.or_else(|e| match e {
|
||||
rusqlite::Error::QueryReturnedNoRows => Ok(None),
|
||||
other => Err(other),
|
||||
});
|
||||
|
||||
match result.map_err(|e| CultureError::Db(e.to_string()))? {
|
||||
None => Ok(None),
|
||||
Some(Some(c)) => Ok(Some(c)),
|
||||
Some(None) => Err(CultureError::NoCulture(loc.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture_db() -> CultureResolver {
|
||||
let path =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("src/knowledge/fixtures/culture_test.db");
|
||||
CultureResolver::open(&path).expect("open fixture DB")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_known_system() {
|
||||
let r = fixture_db();
|
||||
let tag = resolve_culture(&r, "GJ 35").expect("resolve");
|
||||
assert_eq!(tag.as_str(), "south_reach");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_known_system_gateway() {
|
||||
let r = fixture_db();
|
||||
let tag = resolve_culture(&r, "GJ 244A").expect("resolve");
|
||||
assert_eq!(tag.as_str(), "sol-gateway-axis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_known_body_override() {
|
||||
let r = fixture_db();
|
||||
// GJ 35-2 has body-level override "core" despite parent being "south_reach"
|
||||
let tag = resolve_culture(&r, "GJ 35-2").expect("resolve body");
|
||||
assert_eq!(tag.as_str(), "core");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_body_inherits_parent_system() {
|
||||
let r = fixture_db();
|
||||
// GJ 35-3 has NULL cultural_corridor — falls through to parent GJ 35 = "south_reach"
|
||||
let tag = resolve_culture(&r, "GJ 35-3").expect("resolve body inheritance");
|
||||
assert_eq!(tag.as_str(), "south_reach");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_station_to_parent_system() {
|
||||
let r = fixture_db();
|
||||
let tag = resolve_culture(&r, "sova-transit").expect("resolve station");
|
||||
assert_eq!(tag.as_str(), "south_reach");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_location_returns_err() {
|
||||
let r = fixture_db();
|
||||
let err = resolve_culture(&r, "BOGUS-SYSTEM-XYZ").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, CultureError::UnknownLocation(_)),
|
||||
"expected UnknownLocation, got {:?}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_culture_returns_err() {
|
||||
let r = fixture_db();
|
||||
// GJ 999-null exists in fixture but has NULL cultural_corridor
|
||||
let err = resolve_culture(&r, "GJ 999-null").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, CultureError::NoCulture(_)),
|
||||
"expected NoCulture, got {:?}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_reads_are_safe() {
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
let path =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("src/knowledge/fixtures/culture_test.db");
|
||||
let r = Arc::new(CultureResolver::open(&path).expect("open"));
|
||||
|
||||
let handles: Vec<_> = (0..4)
|
||||
.map(|_| {
|
||||
let r2 = Arc::clone(&r);
|
||||
thread::spawn(move || {
|
||||
for _ in 0..250 {
|
||||
let tag = resolve_culture(&r2, "GJ 35").expect("resolve in thread");
|
||||
assert_eq!(tag.as_str(), "south_reach");
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
for h in handles {
|
||||
h.join().expect("thread panic");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,7 +185,7 @@ pub fn process_knowledge_events(
|
||||
mut queue: ResMut<KnowledgeEventQueue>,
|
||||
mut contradiction_queue: ResMut<ContradictionDetectedQueue>,
|
||||
registry: Res<EntityRegistry>,
|
||||
npc_names: Query<&crate::simulation::conversation::NpcName>,
|
||||
npc_names: Query<&crate::simulation::npc_components::NpcName>,
|
||||
mut knowledge_query: Query<&mut KnowledgeGraph>,
|
||||
) {
|
||||
let events = queue.drain();
|
||||
|
||||
Binary file not shown.
@@ -8,12 +8,16 @@ use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
pub mod content_registry;
|
||||
pub mod culture;
|
||||
pub mod events;
|
||||
pub mod graph;
|
||||
pub mod registry;
|
||||
pub mod types;
|
||||
|
||||
pub use content_registry::ContentEntityRegistry;
|
||||
pub use culture::{
|
||||
resolve_culture, CultureError, CultureResolver, CultureResolverResource, CultureTag,
|
||||
};
|
||||
pub use events::{
|
||||
ContradictionDetectedEvent, ContradictionDetectedQueue, InteractionType, KnowledgeEvent,
|
||||
KnowledgeEventQueue, KnowledgeEventType, ProcessedEntityGrant, ProcessedFactGrant,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// The Settled Reach - Simulation Server
|
||||
// Rust/bevy_ecs simulation server for D-010 client-server architecture
|
||||
|
||||
pub mod bookmark;
|
||||
pub mod bridge;
|
||||
pub mod cause_chain;
|
||||
pub mod knowledge;
|
||||
|
||||
+21
-2
@@ -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
|
||||
|
||||
@@ -17,7 +17,6 @@ use crate::perception::cognitive_delay::CognitiveDelay;
|
||||
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
|
||||
use crate::perception::vision_cone::Facing;
|
||||
use crate::simulation::contraband::ScanEventBuffer;
|
||||
use crate::simulation::conversation::ConversationEventBuffer;
|
||||
use crate::simulation::dialogue::DialogueResponseBuffer;
|
||||
use crate::simulation::examine::ExamineResultBuffer;
|
||||
use crate::simulation::follow::FollowTarget;
|
||||
@@ -84,7 +83,6 @@ pub fn compute_observer_snapshot(
|
||||
Option<&CognitiveDelay>,
|
||||
Option<&mut DialogueResponseBuffer>,
|
||||
Option<&mut ScanEventBuffer>,
|
||||
Option<&mut ConversationEventBuffer>,
|
||||
Option<&FollowTarget>,
|
||||
Option<&mut ExamineResultBuffer>,
|
||||
),
|
||||
@@ -121,7 +119,6 @@ pub fn compute_observer_snapshot(
|
||||
cognitive_delay_opt,
|
||||
mut dialogue_response_opt,
|
||||
mut scan_event_buffer_opt,
|
||||
mut conversation_buffer_opt,
|
||||
follow_target_opt,
|
||||
mut examine_result_buffer_opt,
|
||||
)) = observer_query.single_mut()
|
||||
@@ -225,12 +222,6 @@ pub fn compute_observer_snapshot(
|
||||
.map(|buf| buf.take())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Drain NPC-to-NPC conversation events (#247, D-078)
|
||||
let (conversation_events, conversation_ended) = conversation_buffer_opt
|
||||
.as_mut()
|
||||
.map(|buf| (buf.take_events(), buf.take_ended()))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Collect sound events audible to the observer (D-038, #124).
|
||||
// Filter by D-018 range: only events the player can hear based on distance.
|
||||
let sound_events = if let Some(ref queue) = sound_queue {
|
||||
@@ -397,6 +388,9 @@ pub fn compute_observer_snapshot(
|
||||
// Consume pending economy snapshot for this tick (#822).
|
||||
let economy_snapshot = buffer.pending_economy_response.take();
|
||||
|
||||
// Consume pending bookmark catalog for this tick (#614).
|
||||
let bookmark_catalog = buffer.pending_bookmark_catalog.take();
|
||||
|
||||
// Consume pending save/load result for this tick (#553).
|
||||
let save_result = buffer.pending_save_result.take();
|
||||
|
||||
@@ -473,8 +467,6 @@ pub fn compute_observer_snapshot(
|
||||
dialogue_response,
|
||||
blocked_entities,
|
||||
scan_events,
|
||||
conversation_events,
|
||||
conversation_ended,
|
||||
follow_state,
|
||||
character_pressure: pressure_query
|
||||
.iter()
|
||||
@@ -493,6 +485,7 @@ pub fn compute_observer_snapshot(
|
||||
current_ticker,
|
||||
settings_response,
|
||||
economy_snapshot,
|
||||
bookmark_catalog,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
// Timestamped player input events for deterministic simulation (D-010 principle 4)
|
||||
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance)
|
||||
|
||||
use crate::bookmark::{BookmarkRegistry, SelectedBookmark};
|
||||
use crate::bridge::debug::DebugCommandBuffer;
|
||||
use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInput};
|
||||
use crate::bridge::types::{
|
||||
FacingDirection, ObjectType, PlayerAction, PlayerInput, SimError, SimErrorKind, SnapshotBuffer,
|
||||
};
|
||||
use crate::knowledge::CultureResolverResource;
|
||||
use crate::knowledge::{EntityRegistry, StableId};
|
||||
use crate::perception::vision_cone::{facing_from_delta, Facing};
|
||||
use crate::settings::{SettingsCommand, SettingsCommandBuffer};
|
||||
@@ -18,6 +22,7 @@ use crate::simulation::stance::{PlayerMoveCooldown, Stance};
|
||||
use crate::simulation::time::{SimulationTime, TickRate};
|
||||
use crate::test_world::reset::{RoomResetTrigger, RoomSnapshots};
|
||||
use bevy_ecs::prelude::*;
|
||||
use bevy_ecs::system::SystemParam;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Maximum number of inputs the queue will hold before dropping oldest.
|
||||
@@ -78,6 +83,19 @@ impl InputQueue {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundled SystemParam for bookmark-related input handling.
|
||||
///
|
||||
/// Bevy's blanket `IntoSystem` impl covers functions up to 16 parameters.
|
||||
/// Bundling the 4 bookmark params keeps `process_player_input` at exactly 16.
|
||||
#[derive(SystemParam)]
|
||||
pub struct BookmarkInputParams<'w> {
|
||||
pub registry: Option<Res<'w, BookmarkRegistry>>,
|
||||
pub selected: Option<ResMut<'w, SelectedBookmark>>,
|
||||
pub snapshot_buf: Option<ResMut<'w, SnapshotBuffer>>,
|
||||
pub sim_error_buf: Option<ResMut<'w, crate::bridge::types::SimErrorBuffer>>,
|
||||
pub culture: Option<Res<'w, CultureResolverResource>>,
|
||||
}
|
||||
|
||||
/// Drains InputQueue for the current tick, converts PlayerActions to ECS components.
|
||||
/// Handles stance toggling (D-053), movement cooldown, Take/Place verbs (#424),
|
||||
/// and save/load commands (#553).
|
||||
@@ -106,6 +124,7 @@ pub fn process_player_input(
|
||||
mut econ_query_buf: Option<ResMut<EconQueryBuffer>>,
|
||||
door_states: Query<&DoorState>,
|
||||
object_types: Query<&ObjectType>,
|
||||
mut bookmark: BookmarkInputParams<'_>,
|
||||
) {
|
||||
let current_tick = time.tick;
|
||||
let paused = time.paused();
|
||||
@@ -130,6 +149,8 @@ pub fn process_player_input(
|
||||
| PlayerAction::RequestAllSettings
|
||||
| PlayerAction::DeleteSetting { .. }
|
||||
| PlayerAction::EconStateQuery { .. }
|
||||
| PlayerAction::RequestBookmarkCatalog
|
||||
| PlayerAction::ConfirmBookmark { .. }
|
||||
)
|
||||
{
|
||||
continue;
|
||||
@@ -420,6 +441,32 @@ pub fn process_player_input(
|
||||
tracing::debug!("EconStateQuery received but EconQueryBuffer not registered — economy not loaded");
|
||||
}
|
||||
}
|
||||
PlayerAction::RequestBookmarkCatalog => {
|
||||
if let (Some(ref registry), Some(ref mut buf)) =
|
||||
(&bookmark.registry, &mut bookmark.snapshot_buf)
|
||||
{
|
||||
let resolver = bookmark.culture.as_deref().map(|c| &c.0);
|
||||
buf.pending_bookmark_catalog = Some(registry.build_catalog(resolver));
|
||||
tracing::debug!("RequestBookmarkCatalog: catalog staged");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"RequestBookmarkCatalog: BookmarkRegistry or SnapshotBuffer not available"
|
||||
);
|
||||
}
|
||||
}
|
||||
PlayerAction::ConfirmBookmark {
|
||||
bookmark_id,
|
||||
starting_location_id,
|
||||
} => {
|
||||
handle_confirm_bookmark(
|
||||
bookmark_id,
|
||||
starting_location_id,
|
||||
&bookmark.registry,
|
||||
&mut bookmark.selected,
|
||||
&mut bookmark.sim_error_buf,
|
||||
current_tick,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1121,6 +1168,71 @@ fn handle_teleport_to_hub(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_confirm_bookmark(
|
||||
bookmark_id: String,
|
||||
starting_location_id: String,
|
||||
registry: &Option<Res<BookmarkRegistry>>,
|
||||
selected_bookmark: &mut Option<ResMut<SelectedBookmark>>,
|
||||
sim_error_buf: &mut Option<ResMut<crate::bridge::types::SimErrorBuffer>>,
|
||||
tick: u64,
|
||||
) {
|
||||
let Some(ref registry) = registry else {
|
||||
tracing::warn!("ConfirmBookmark: BookmarkRegistry not available");
|
||||
return;
|
||||
};
|
||||
let Some(defn) = registry.get(&bookmark_id) else {
|
||||
let msg = format!("ConfirmBookmark: unknown bookmark_id {:?}", bookmark_id);
|
||||
tracing::warn!("{}", msg);
|
||||
if let Some(ref mut buf) = sim_error_buf {
|
||||
buf.push(SimError {
|
||||
kind: SimErrorKind::ProtocolError,
|
||||
message: msg,
|
||||
tick,
|
||||
});
|
||||
}
|
||||
return;
|
||||
};
|
||||
if !defn.allowed_locations.contains(&starting_location_id) {
|
||||
let msg = format!(
|
||||
"ConfirmBookmark: starting_location_id {:?} not in allowed_locations for {:?}",
|
||||
starting_location_id, bookmark_id
|
||||
);
|
||||
tracing::warn!("{}", msg);
|
||||
if let Some(ref mut buf) = sim_error_buf {
|
||||
buf.push(SimError {
|
||||
kind: SimErrorKind::ProtocolError,
|
||||
message: msg,
|
||||
tick,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(ref mut sel) = selected_bookmark {
|
||||
if sel.bookmark_id.is_some() {
|
||||
let msg = format!(
|
||||
"ConfirmBookmark: bookmark already confirmed ({}), ignoring retry",
|
||||
sel.bookmark_id.as_deref().unwrap_or("?")
|
||||
);
|
||||
tracing::warn!("{}", msg);
|
||||
if let Some(ref mut buf) = sim_error_buf {
|
||||
buf.push(SimError {
|
||||
kind: SimErrorKind::ProtocolError,
|
||||
message: msg,
|
||||
tick,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
sel.bookmark_id = Some(bookmark_id.clone());
|
||||
sel.starting_location_id = Some(starting_location_id.clone());
|
||||
tracing::info!(
|
||||
bookmark_id,
|
||||
starting_location_id,
|
||||
"ConfirmBookmark: selection recorded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2462,4 +2574,168 @@ mod tests {
|
||||
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
||||
assert_eq!(pos.x, hub_spawn.x, "teleport works while paused");
|
||||
}
|
||||
|
||||
fn make_bookmark_world() -> bevy_ecs::world::World {
|
||||
use crate::bookmark::types::{BookmarkDefinition, BookmarkId, CareerKind};
|
||||
use crate::bookmark::{BookmarkRegistry, SelectedBookmark};
|
||||
use crate::bridge::types::SimErrorBuffer;
|
||||
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
world.init_resource::<SelectedBookmark>();
|
||||
world.init_resource::<SimErrorBuffer>();
|
||||
|
||||
let mut registry = BookmarkRegistry::default();
|
||||
registry.insert(BookmarkDefinition {
|
||||
id: BookmarkId("test_bookmark".to_string()),
|
||||
title: "Test Bookmark".into(),
|
||||
subtitle: String::new(),
|
||||
flavor: String::new(),
|
||||
default_location: "Loc A".into(),
|
||||
allowed_locations: vec!["Loc A".into(), "Loc B".into()],
|
||||
career: CareerKind::Tycoon,
|
||||
starting_capital_tractus: 1_000,
|
||||
available: true,
|
||||
});
|
||||
world.insert_resource(registry);
|
||||
|
||||
world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_bookmark_unknown_id_emits_sim_error() {
|
||||
use crate::bridge::types::SimErrorBuffer;
|
||||
|
||||
let mut world = make_bookmark_world();
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ConfirmBookmark {
|
||||
bookmark_id: "no_such_bookmark".into(),
|
||||
starting_location_id: "Loc A".into(),
|
||||
},
|
||||
});
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let errors = world.resource_mut::<SimErrorBuffer>().drain();
|
||||
assert_eq!(
|
||||
errors.len(),
|
||||
1,
|
||||
"expected one SimError for unknown bookmark"
|
||||
);
|
||||
assert_eq!(
|
||||
errors[0].kind,
|
||||
crate::bridge::types::SimErrorKind::ProtocolError
|
||||
);
|
||||
assert!(errors[0].message.contains("unknown bookmark_id"));
|
||||
assert!(
|
||||
world
|
||||
.resource::<crate::bookmark::SelectedBookmark>()
|
||||
.bookmark_id
|
||||
.is_none(),
|
||||
"SelectedBookmark must not be set on error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_bookmark_invalid_location_emits_sim_error() {
|
||||
use crate::bridge::types::SimErrorBuffer;
|
||||
|
||||
let mut world = make_bookmark_world();
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ConfirmBookmark {
|
||||
bookmark_id: "test_bookmark".into(),
|
||||
starting_location_id: "Not A Location".into(),
|
||||
},
|
||||
});
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let errors = world.resource_mut::<SimErrorBuffer>().drain();
|
||||
assert_eq!(
|
||||
errors.len(),
|
||||
1,
|
||||
"expected one SimError for invalid location"
|
||||
);
|
||||
assert_eq!(
|
||||
errors[0].kind,
|
||||
crate::bridge::types::SimErrorKind::ProtocolError
|
||||
);
|
||||
assert!(errors[0].message.contains("not in allowed_locations"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_bookmark_valid_inputs_populate_selected_bookmark() {
|
||||
use crate::bridge::types::SimErrorBuffer;
|
||||
|
||||
let mut world = make_bookmark_world();
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ConfirmBookmark {
|
||||
bookmark_id: "test_bookmark".into(),
|
||||
starting_location_id: "Loc B".into(),
|
||||
},
|
||||
});
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let errors = world.resource_mut::<SimErrorBuffer>().drain();
|
||||
assert!(
|
||||
errors.is_empty(),
|
||||
"no errors expected for valid ConfirmBookmark"
|
||||
);
|
||||
|
||||
let sel = world.resource::<crate::bookmark::SelectedBookmark>();
|
||||
assert_eq!(sel.bookmark_id.as_deref(), Some("test_bookmark"));
|
||||
assert_eq!(sel.starting_location_id.as_deref(), Some("Loc B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_bookmark_double_confirm_emits_sim_error() {
|
||||
use crate::bridge::types::SimErrorBuffer;
|
||||
|
||||
let mut world = make_bookmark_world();
|
||||
|
||||
// Both confirms at tick=0: processed in order within the same run.
|
||||
// First succeeds; second hits the idempotency guard.
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ConfirmBookmark {
|
||||
bookmark_id: "test_bookmark".into(),
|
||||
starting_location_id: "Loc A".into(),
|
||||
},
|
||||
});
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ConfirmBookmark {
|
||||
bookmark_id: "test_bookmark".into(),
|
||||
starting_location_id: "Loc B".into(),
|
||||
},
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let errors = world.resource_mut::<SimErrorBuffer>().drain();
|
||||
assert_eq!(errors.len(), 1, "double-confirm must emit ProtocolError");
|
||||
assert_eq!(
|
||||
errors[0].kind,
|
||||
crate::bridge::types::SimErrorKind::ProtocolError
|
||||
);
|
||||
assert!(errors[0].message.contains("already confirmed"));
|
||||
|
||||
// Selection must remain the original, not overwritten
|
||||
let sel = world.resource::<crate::bookmark::SelectedBookmark>();
|
||||
assert_eq!(sel.starting_location_id.as_deref(), Some("Loc A"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,8 +16,8 @@ use rand::Rng;
|
||||
use crate::bridge::types::MonologueEvent;
|
||||
use crate::knowledge::{ContradictionDetectedQueue, EntityRegistry};
|
||||
use crate::perception::interpretation::ObservationTrigger;
|
||||
use crate::simulation::conversation::NpcName;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::npc_components::NpcName;
|
||||
use crate::simulation::rng::EntityRng;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::storyteller::EngagementRecord;
|
||||
@@ -84,15 +84,6 @@ const HEAR_SOUND_LINES: &[(&str, &str)] = &[
|
||||
("hear_sound_03", "Something just happened nearby."),
|
||||
];
|
||||
|
||||
/// Hardcoded v0.1 witness_interaction monologue lines.
|
||||
/// Fire when the player overhears an NPC-to-NPC conversation (D-078).
|
||||
/// Future: move to content pools with trigger="witness_interaction".
|
||||
const WITNESS_INTERACTION_LINES: &[(&str, &str)] = &[
|
||||
("witness_01", "Interesting. Wonder what that was about."),
|
||||
("witness_02", "I should remember what they just said."),
|
||||
("witness_03", "They didn't know I was listening."),
|
||||
];
|
||||
|
||||
/// Hardcoded v0.1 post_conversation monologue lines.
|
||||
/// Fire after a player-NPC dialogue concludes (walk-away or natural end).
|
||||
/// Future: move to content pools with trigger="post_conversation".
|
||||
@@ -375,7 +366,6 @@ fn select_hardcoded_fallback(trigger: &str, rng: &mut impl Rng) -> (String, Stri
|
||||
let lines = match trigger {
|
||||
"observe_npc" => OBSERVE_NPC_LINES,
|
||||
"hear_sound" => HEAR_SOUND_LINES,
|
||||
"witness_interaction" => WITNESS_INTERACTION_LINES,
|
||||
"post_conversation" => POST_CONVERSATION_LINES,
|
||||
unknown => {
|
||||
tracing::warn!(
|
||||
@@ -405,16 +395,15 @@ fn sound_range_tiles(range: &crate::knowledge::types::SoundRange) -> u32 {
|
||||
|
||||
/// Event-driven monologue trigger system (#119, D-035).
|
||||
///
|
||||
/// Checks observation events, sound events, overheard conversations, and
|
||||
/// completed dialogues for monologue-worthy triggers. Fires at most one
|
||||
/// Checks observation events, sound events, and completed dialogues for
|
||||
/// monologue-worthy triggers. Fires at most one
|
||||
/// monologue per tick. Event-driven — no cooldown gate. Updates
|
||||
/// `last_fired_tick` so v0.2 periodic triggers can respect the recency window.
|
||||
///
|
||||
/// Priority order (first match wins):
|
||||
/// 1. observe_npc (new entity spotted — uses previous-tick observation events)
|
||||
/// 2. hear_sound (non-routine sound: Machinery, Alert)
|
||||
/// 3. witness_interaction (overheard NPC-to-NPC conversation, D-078)
|
||||
/// 4. post_conversation (player-NPC dialogue concluded)
|
||||
/// 3. post_conversation (player-NPC dialogue concluded)
|
||||
///
|
||||
/// System ordering: after all event producers + recognition/anomaly monologue
|
||||
/// systems, before compute_observer_snapshot.
|
||||
@@ -429,7 +418,6 @@ pub fn trigger_event_monologue(
|
||||
&TilePosition,
|
||||
&mut MonologueState,
|
||||
&mut MonologueBuffer,
|
||||
Option<&crate::simulation::conversation::ConversationEventBuffer>,
|
||||
&mut EntityRng,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
@@ -441,9 +429,7 @@ pub fn trigger_event_monologue(
|
||||
// Saved for NPC attribution (engagement tracking #570) and trigger detection.
|
||||
let post_conv_npcs: Vec<Entity> = post_conv_queue.drain();
|
||||
|
||||
let Ok((player_pos, mut state, mut buffer, conv_buffer_opt, mut entity_rng)) =
|
||||
query.single_mut()
|
||||
else {
|
||||
let Ok((player_pos, mut state, mut buffer, mut entity_rng)) = query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -468,11 +454,6 @@ pub fn trigger_event_monologue(
|
||||
.unwrap_or(false)
|
||||
{
|
||||
Some("hear_sound")
|
||||
} else if conv_buffer_opt
|
||||
.map(|b| !b.events.is_empty())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
Some("witness_interaction")
|
||||
} else if !post_conv_npcs.is_empty() {
|
||||
Some("post_conversation")
|
||||
} else {
|
||||
@@ -511,7 +492,7 @@ pub fn trigger_event_monologue(
|
||||
);
|
||||
|
||||
// Engagement tracking (#570): attribute monologue_trigger_count to specific NPCs.
|
||||
// Only NPC-context triggers are attributed — hear_sound/witness_interaction are not NPC-specific.
|
||||
// Only NPC-context triggers are attributed — hear_sound is not NPC-specific.
|
||||
match trigger {
|
||||
"observe_npc" => {
|
||||
// Attribute to all NPCs whose NewEntity event triggered this monologue
|
||||
@@ -537,7 +518,7 @@ pub fn trigger_event_monologue(
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {} // hear_sound, witness_interaction: no NPC-specific attribution
|
||||
_ => {} // hear_sound: no NPC-specific attribution
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1345,7 +1326,6 @@ mod tests {
|
||||
use crate::perception::interpretation::{
|
||||
ObservationEvent, ObservationEventQueue, ObservationTrigger,
|
||||
};
|
||||
use crate::simulation::conversation::ConversationEventBuffer;
|
||||
use crate::simulation::sound::{SoundEvent, SoundEventKind, SoundEventQueue};
|
||||
|
||||
fn setup_event_world() -> World {
|
||||
@@ -1366,7 +1346,6 @@ mod tests {
|
||||
TilePosition::new(10, 10, 0),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
ConversationEventBuffer::default(),
|
||||
))
|
||||
.id()
|
||||
}
|
||||
@@ -1521,36 +1500,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn witness_interaction_fires_on_conversation_event() {
|
||||
let mut world = setup_event_world();
|
||||
let player = spawn_event_player(&mut world);
|
||||
|
||||
// Pre-fill ConversationEventBuffer with an overheard conversation
|
||||
world
|
||||
.get_mut::<ConversationEventBuffer>(player)
|
||||
.unwrap()
|
||||
.events
|
||||
.push(crate::simulation::conversation::ConversationEvent {
|
||||
occluded_line: "Keep your head down today.".to_string(),
|
||||
speaker_id: 100,
|
||||
target_id: 101,
|
||||
speaker_name: "Worker".to_string(),
|
||||
target_name: "Courier".to_string(),
|
||||
speaker_color_index: 0,
|
||||
target_color_index: 1,
|
||||
});
|
||||
|
||||
run_event_system(&mut world);
|
||||
|
||||
let buf = world.get::<MonologueBuffer>(player).unwrap();
|
||||
assert!(
|
||||
buf.event.is_some(),
|
||||
"witness_interaction should fire when conversation overheard"
|
||||
);
|
||||
assert!(buf.event.as_ref().unwrap().id.starts_with("witness_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_conversation_fires_on_queue_entry() {
|
||||
let mut world = setup_event_world();
|
||||
@@ -1668,47 +1617,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_hear_sound_over_witness_interaction() {
|
||||
let mut world = setup_event_world();
|
||||
let player = spawn_event_player(&mut world);
|
||||
|
||||
// Sound event
|
||||
world
|
||||
.resource_mut::<SoundEventQueue>()
|
||||
.events
|
||||
.push(SoundEvent::at(
|
||||
&TilePosition::new(11, 10, 0),
|
||||
SoundEventKind::Alert,
|
||||
1.0,
|
||||
crate::knowledge::types::SoundRange::Medium,
|
||||
None,
|
||||
));
|
||||
|
||||
// Conversation event
|
||||
world
|
||||
.get_mut::<ConversationEventBuffer>(player)
|
||||
.unwrap()
|
||||
.events
|
||||
.push(crate::simulation::conversation::ConversationEvent {
|
||||
occluded_line: "Test".to_string(),
|
||||
speaker_id: 100,
|
||||
target_id: 101,
|
||||
speaker_name: "A".to_string(),
|
||||
target_name: "B".to_string(),
|
||||
speaker_color_index: 0,
|
||||
target_color_index: 1,
|
||||
});
|
||||
|
||||
run_event_system(&mut world);
|
||||
|
||||
let buf = world.get::<MonologueBuffer>(player).unwrap();
|
||||
assert!(
|
||||
buf.event.as_ref().unwrap().id.starts_with("hear_sound_"),
|
||||
"hear_sound should have priority over witness_interaction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_trigger_updates_last_fired_tick() {
|
||||
let mut world = setup_event_world();
|
||||
@@ -1813,12 +1721,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hardcoded_lines_all_valid() {
|
||||
for lines in &[
|
||||
OBSERVE_NPC_LINES,
|
||||
HEAR_SOUND_LINES,
|
||||
WITNESS_INTERACTION_LINES,
|
||||
POST_CONVERSATION_LINES,
|
||||
] {
|
||||
for lines in &[OBSERVE_NPC_LINES, HEAR_SOUND_LINES, POST_CONVERSATION_LINES] {
|
||||
assert!(!lines.is_empty());
|
||||
for (id, text) in *lines {
|
||||
assert!(!id.is_empty(), "line id should not be empty");
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// NPC component types shared across simulation systems.
|
||||
//
|
||||
// Extracted from conversation.rs (D-078 scrapped per R-012). These types
|
||||
// survive because they are used by dialogue, debug, knowledge transfer,
|
||||
// monologue, and the D-080 knowledge propagation system.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Display name for an NPC.
|
||||
/// Attached during content spawn.
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NpcName(pub String);
|
||||
|
||||
/// Map a dialogue role string to a display label for use when the player
|
||||
/// does not yet know the NPC's real name.
|
||||
pub fn display_label_for_role(role: &str) -> String {
|
||||
match role {
|
||||
"dock-worker" => "Dock Worker",
|
||||
"courier" => "Courier",
|
||||
"maintenance-tech" => "Technician",
|
||||
"new-hire" | "day-worker" | "transit-worker" => "Worker",
|
||||
"scheduler" => "Scheduler",
|
||||
"shift-supervisor" => "Supervisor",
|
||||
"bartender" => "Bartender",
|
||||
"bar-regular" => "Patron",
|
||||
_ => "Bystander",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Color index (0-7) for rendering this NPC with a distinct color.
|
||||
/// Assigned at spawn time as `(stable_id % 8)`.
|
||||
#[derive(Component, Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct NpcColorIndex(pub u8);
|
||||
|
||||
/// Active NPC-to-NPC conversation session (D-080 knowledge propagation).
|
||||
/// Attached to the "speaker" NPC (the one who initiated).
|
||||
/// The "listener" is tracked by entity reference.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct NpcConversation {
|
||||
/// The other NPC in the conversation.
|
||||
pub partner: Entity,
|
||||
/// Tick when the conversation started.
|
||||
pub started_tick: u64,
|
||||
/// Tick when the conversation will end.
|
||||
pub end_tick: u64,
|
||||
/// Ticks since last line was spoken (for pacing).
|
||||
pub ticks_since_last_line: u64,
|
||||
}
|
||||
@@ -28,8 +28,8 @@ use crate::knowledge::{
|
||||
};
|
||||
use crate::npc::relationships::RelationshipGraph;
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::conversation::NpcConversation;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::npc_components::NpcConversation;
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
@@ -115,7 +115,7 @@ impl TransferCandidate {
|
||||
/// If the player is within VOICE_RANGE_TILES, they gain entity-level knowledge
|
||||
/// about both NPCs at `Suspects` confidence (`Heard` source).
|
||||
///
|
||||
/// System ordering: after(run_npc_conversations).
|
||||
/// Runs in [`TickPhase::Simulation`] with no ordering constraint.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn transfer_npc_knowledge(
|
||||
time: Res<SimulationTime>,
|
||||
@@ -203,10 +203,9 @@ pub fn transfer_npc_knowledge(
|
||||
}
|
||||
|
||||
// --- Dual-mutable KG access ---
|
||||
// Transfer is one-directional per conversation tick: speaker → partner.
|
||||
// If both participants are Active NPCs, each fires as "speaker" in
|
||||
// separate conversation pairs (run_npc_conversations creates symmetric
|
||||
// pairs), so both directions are covered across two iterations.
|
||||
// Transfer is one-directional per NpcConversation component: speaker → partner.
|
||||
// When both participants have NpcConversation, each fires as "speaker"
|
||||
// in a separate query row, so both directions are covered.
|
||||
|
||||
let Ok([speaker_kg, mut partner_kg]) =
|
||||
kg_query.get_many_mut([speaker_entity, partner_entity])
|
||||
@@ -438,8 +437,8 @@ mod tests {
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::npc::relationships::{RelationshipEdge, RelationshipGraph};
|
||||
use crate::npc::RelationshipKind;
|
||||
use crate::simulation::conversation::NpcConversation;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::npc_components::NpcConversation;
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
//! Observer integration for the voice pipeline (D-138, Phase 3).
|
||||
//!
|
||||
//! Two enrichment systems run before `compute_observer_snapshot` and rewrite
|
||||
//! NPC text in the dialogue and conversation buffers with voiced variants
|
||||
//! looked up from the cache. Cache miss → base text (never blocks).
|
||||
//! One enrichment system runs before `compute_observer_snapshot` and rewrites
|
||||
//! NPC dialogue text with voiced variants looked up from the cache.
|
||||
//! Cache miss → base text (never blocks).
|
||||
//!
|
||||
//! ## System ordering
|
||||
//!
|
||||
//! ```text
|
||||
//! process_talk_interaction ─┐
|
||||
//! run_npc_conversations ─┤─► voice_enrich_dialogue_response ─┐
|
||||
//! └─► voice_enrich_conversation_events ─► compute_observer_snapshot
|
||||
//! process_talk_interaction ─► voice_enrich_dialogue_response ─► compute_observer_snapshot
|
||||
//! ```
|
||||
//!
|
||||
//! ## Content index derivation
|
||||
@@ -24,7 +22,6 @@ use bevy_ecs::prelude::*;
|
||||
use crate::knowledge::{EntityRegistry, StableId};
|
||||
use crate::npc::tell_state::DerivedTellState;
|
||||
use crate::npc::{Npc, NpcVoiceProfile};
|
||||
use crate::simulation::conversation::ConversationEventBuffer;
|
||||
use crate::simulation::dialogue::DialogueResponseBuffer;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::zone::ZoneMap;
|
||||
@@ -127,85 +124,6 @@ pub fn voice_enrich_dialogue_response(
|
||||
response.text = voiced;
|
||||
}
|
||||
|
||||
/// Enrich the conversation event buffer with voiced lines from the cache.
|
||||
///
|
||||
/// Must run after `run_npc_conversations` and before
|
||||
/// `compute_observer_snapshot`. No-op when the voice cache resource is absent.
|
||||
///
|
||||
/// Each event in the buffer is processed: the pre-occlusion base text is
|
||||
/// not available at this point (occlusion has already been applied), so the
|
||||
/// `occluded_line` is treated as the base text for the voice lookup.
|
||||
/// This means the voice register wraps the already-occluded line.
|
||||
///
|
||||
/// ## Accepted risk: re-voicing of heavily occluded text
|
||||
///
|
||||
/// When many words are dropped by D-078 occlusion, the remaining text may
|
||||
/// be fragmentary ("... the ... came in ..."). Re-voicing such fragments can
|
||||
/// produce incoherent output. This is acceptable for two reasons:
|
||||
/// 1. Cache misses are common for conversation text (no pre-baking pipeline),
|
||||
/// so the base text fallback in `voiced_behavior()` fires most of the time.
|
||||
/// 2. Even incoherent voiced output is no worse than the already-degraded
|
||||
/// overheard line — the occlusion itself has already broken coherence.
|
||||
/// The player's inability to fully parse overheard speech is the mechanic.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn voice_enrich_conversation_events(
|
||||
voice_cache: Option<Res<VoiceCacheResource>>,
|
||||
registry: Res<EntityRegistry>,
|
||||
zone_map: Option<Res<ZoneMap>>,
|
||||
player_query: Query<&TilePosition, With<PlayerCharacter>>,
|
||||
npc_voice_query: Query<(&NpcVoiceProfile, Option<&DerivedTellState>), With<Npc>>,
|
||||
mut conversation_buffer: Query<&mut ConversationEventBuffer, With<PlayerCharacter>>,
|
||||
) {
|
||||
let Some(voice_cache) = voice_cache else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(mut buffer) = conversation_buffer.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if buffer.events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let zone_id = player_zone_id(&player_query, zone_map.as_deref());
|
||||
|
||||
for event in &mut buffer.events {
|
||||
let speaker_entity = registry.to_entity(&StableId(event.speaker_id));
|
||||
let Some(entity) = speaker_entity else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok((voice_profile, tell_state_opt)) = npc_voice_query.get(entity) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let tell_state = tell_state_opt.and_then(|t| t.category);
|
||||
// Conversation lines don't have a stable line_id; derive content_index
|
||||
// from the occluded line text. The full cache key is
|
||||
// (culture_id, npc_stable_id, content_type, content_index, tell_state),
|
||||
// so a u16 hash collision requires two different lines from the same
|
||||
// speaker with the same tell state to hash identically — at ~65K
|
||||
// possible values this is extremely rare, and the worst outcome is
|
||||
// a stale voiced line being served instead of base text. Acceptable.
|
||||
let content_index = content_index_from_line_id(&event.occluded_line);
|
||||
|
||||
let voiced = voiced_behavior(
|
||||
&voice_cache.cache,
|
||||
zone_id,
|
||||
&voice_profile.culture_id,
|
||||
event.speaker_id,
|
||||
ContentType::Dialogue,
|
||||
content_index,
|
||||
tell_state,
|
||||
&event.occluded_line,
|
||||
false,
|
||||
);
|
||||
|
||||
event.occluded_line = voiced;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -39,8 +39,6 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
character_pressure: None,
|
||||
rng_seed: None,
|
||||
@@ -54,6 +52,8 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
|
||||
sim_errors: vec![],
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
economy_snapshot: None,
|
||||
bookmark_catalog: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,8 +236,6 @@ fn generate_msgpack_fixtures() {
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
character_pressure: None,
|
||||
rng_seed: None,
|
||||
@@ -251,6 +249,8 @@ fn generate_msgpack_fixtures() {
|
||||
sim_errors: vec![],
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
economy_snapshot: None,
|
||||
bookmark_catalog: None,
|
||||
};
|
||||
write_fixture(
|
||||
"snapshot_v2_full",
|
||||
@@ -375,8 +375,6 @@ fn generate_msgpack_fixtures() {
|
||||
blocked_entities: vec![5, 6],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
character_pressure: None,
|
||||
rng_seed: Some(0xDEADBEEF),
|
||||
@@ -418,6 +416,8 @@ fn generate_msgpack_fixtures() {
|
||||
sim_errors: vec![],
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
economy_snapshot: None,
|
||||
bookmark_catalog: None,
|
||||
};
|
||||
write_fixture(
|
||||
"snapshot_full",
|
||||
@@ -502,3 +502,33 @@ fn generate_msgpack_fixtures() {
|
||||
write_fixture(name, &rmp_serde::to_vec_named(&snapshot).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a snapshot fixture with a populated `BookmarkCatalog`.
|
||||
///
|
||||
/// Client (#618) uses this to validate GDScript MessagePack decode against
|
||||
/// real `rmp_serde` output — field order and string encoding may diverge
|
||||
/// from GDScript-constructed data.
|
||||
#[test]
|
||||
#[ignore] // Run manually: cargo test --test gen_fixtures -- --ignored
|
||||
fn generate_snapshot_with_bookmark_catalog() {
|
||||
let catalog = BookmarkCatalog {
|
||||
bookmarks: vec![BookmarkWire {
|
||||
id: "tycoon".into(),
|
||||
title: "Tycoon".into(),
|
||||
subtitle: "Small business owner on the make.".into(),
|
||||
flavor: String::new(),
|
||||
default_location: "GJ 35".into(),
|
||||
allowed_locations: vec!["GJ 35".into()],
|
||||
allowed_locations_cultures: vec!["frontier_industrial".into()],
|
||||
career: CareerKindWire::Tycoon,
|
||||
starting_capital_tractus: 5_000,
|
||||
}],
|
||||
};
|
||||
|
||||
let mut snapshot = fixture_snapshot(0, vec![]);
|
||||
snapshot.bookmark_catalog = Some(catalog);
|
||||
|
||||
let bytes =
|
||||
rmp_serde::to_vec_named(&snapshot).expect("serialize snapshot_with_bookmark_catalog");
|
||||
write_fixture("snapshot_with_bookmark_catalog", &bytes);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
3
|
||||
],
|
||||
"character_pressure": null,
|
||||
"conversation_ended": [],
|
||||
"conversation_events": [],
|
||||
"current_monologue": null,
|
||||
"dialogue_response": null,
|
||||
"entities": [
|
||||
|
||||
@@ -27,8 +27,6 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
character_pressure: None,
|
||||
rng_seed: None,
|
||||
@@ -42,6 +40,8 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
sim_errors: vec![],
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
economy_snapshot: None,
|
||||
bookmark_catalog: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,8 +291,6 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
character_pressure: None,
|
||||
rng_seed: None,
|
||||
@@ -306,6 +304,8 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
sim_errors: vec![],
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
economy_snapshot: None,
|
||||
bookmark_catalog: None,
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
@@ -401,8 +401,7 @@ fn all_facing_direction_variants_roundtrip() {
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
|
||||
follow_state: None,
|
||||
character_pressure: None,
|
||||
rng_seed: None,
|
||||
@@ -416,6 +415,8 @@ fn all_facing_direction_variants_roundtrip() {
|
||||
sim_errors: vec![],
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
economy_snapshot: None,
|
||||
bookmark_catalog: None,
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
@@ -1450,8 +1451,6 @@ fn serde_default_fields_fill_in_when_missing_from_wire() {
|
||||
"blocked_entities": [],
|
||||
"scan_events": [],
|
||||
"sound_events": [],
|
||||
"conversation_events": [],
|
||||
"conversation_ended": [],
|
||||
"follow_state": null,
|
||||
"rng_seed": null
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
Executable
+23
@@ -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" "$@"
|
||||
Reference in New Issue
Block a user