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

This commit is contained in:
2026-04-22 10:37:39 +02:00
63 changed files with 1333 additions and 2591 deletions
+15
View File
@@ -12,9 +12,24 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- **`make check-systems-db`** — verifies the meta stamp matches current generator sources
- **`make install-hooks`** — installs pre-push and pre-commit hooks in one step
- **`tooling/db/decision show <D-NNN>`** (#723) — drill-down view of a decision with implementing tickets and cross-refs
- **Atlas determinism smoke test** (#847) — `make test-atlas-determinism` runs `generate_atlas.process_body()` twice with a fixed seed and diffs the output to catch determinism regressions in terrain analysis, city placement, A* routing, and naming
- **SelectedBookmark save/load** (#863) — bookmark and starting-location choice now persist across save/load; replaces the v0.2-deferred TODO on `SelectedBookmark`
- **`BookmarkPlugin::new(registry)` injection** (#862) — test-friendly plugin construction for future TOML bookmark loading; default constructor still wires the canonical tycoon registry
- **Six new corporation wiki pages** (#860) — Arbour Aggregates, Earth Standard Group, Rush Mining, Scapa Flow Industries, Sede Chemical Works, Threshold Fuel Syndicate
- **Atlas naming corridor-scoped dedup, compass-direction filter, river vocab filter, infra pair-naming** (#853) — city and mountain names deduplicate across bodies within a corridor; compass-direction defaults blocked in the few-shot prompt; navigational vocabulary (`Flow`, `Current`) rejected for rivers; unnamed roads and railroads receive deterministic `{CityA}{CityB} {corridor_suffix}` names
### Changed
- `decisions-coverage` Makefile target now lists implementing ticket IDs per decision instead of aggregate counts
- **Economy coverage gate** (#860) now passes end-to-end — closes the 21 raw-commodity / system gaps that blocked Phase 2 demand simulation
- Tag updates on 15 existing corporation wiki pages to match commodity coverage needs
### Fixed
- Storyteller `activation_pass` "no Simmering triangles — holding" no longer fires as `warn` during normal early-game state — downgraded to `debug` (#789)
### Removed
- **`PROTOCOL_VERSION` lockstep handshake** (#874, D-192) — `HandshakeMessage` is now empty; schema drift surfaces as MessagePack missing-field errors downstream, which is the intended signal
- **`HeritageRoot` type alias and `ZonePaletteModifier::Heritage` variant** (#877, D-167) — last stubs of the abstract heritage-root system retired in favour of the corridor cultural framework
- **`CharacterArchetype` (Smuggler/Detective) trace from server** (#878) — enum, IPC field, verb-differentiation branch in the observer Phase 2 filter (D-057 superseded), monologue pool partitioning, Gauntlet plumbing, drama-module schema, archetype-dependent integration tests. Per the development cascade, character/NPC differentiation is Phase 6 work and the running trace was pre-cascade filler, not production. Client-side cleanup tracked in #882.
## [v0.1.36] — 2026-04-21
+4
View File
@@ -61,6 +61,7 @@ help:
@echo " make regen-db Regenerate systems.db from all sources + stamp meta table (#855)"
@echo " make check-systems-db Verify systems.db meta stamp matches current generator sources"
@echo " make install-hooks Install pre-push + pre-commit git hooks (once per clone)"
@echo " make test-atlas-determinism Determinism smoke test for generate_atlas.py (#847)"
@echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)"
@echo " make golden-diff Show diff if golden file output has changed"
@echo " make golden-update Regenerate golden file and stage for commit"
@@ -230,6 +231,9 @@ test-ipc-integration:
test-ipc-benchmark:
tests/run-ipc-benchmark
test-atlas-determinism: ## Determinism smoke test for generate_atlas.py (#847)
tests/run-atlas-determinism
# --- Clean ---
clean-imports:
+5 -3
View File
@@ -44,9 +44,10 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
- **Dissent:** None
- **Amendment (2026-03-05, Where's the Fun? Workshop):** The 30/50/20 population split rationale survives but implementation context changes. [D-122](#d-122-all-npcs-generated--no-named-hand-authored-characters) (all NPCs generated) means no NPC is hand-authored. The "entangled 20%" are generated NPCs whose triangles happen to be flagged for intrigue content. For the tycoon v0.2 bookmark ([D-117](scope.md#d-117-tycoon-is-the-v02-bookmark--zero-investigation-content)), the split applies to economic, social, and mundane triangles rather than investigation-intrigue triangles. The specific ratios will be revisited after the generator spike ([D-119](scope.md#d-119-generator-spike-confirmed-for-sprint-25--critical-path)) proves what population density the generator can sustain.
### D-032: Separate monologue pools per character [SUPERSEDED]
### D-032: Separate monologue pools per character [SUPERSEDED — deferred to Phase 6]
- **Date:** 2026-02-11
- **Superseded by:** [D-117](scope.md#d-117-tycoon-is-the-v02-bookmark--zero-investigation-content) (single tycoon character in v0.2 eliminates the smuggler/detective hard partition). The principle of character-specific monologue pools survives — the tycoon has their own monologue pool. The hard partition between smuggler and detective does not apply when there is only one playable character. Per [D-127](#d-127-player-choices-are-the-content--rimworld-model-job-as-rails), the player's monologue reflects their character background. The partition design is preserved as a pattern for when multiple playable characters are reintroduced.
- **Superseded by:** Development cascade (CLAUDE.md) — character/NPC monologue content is Phase 6 detail-coloring, below the current Phase 1 (wiki content). The smuggler/detective archetype enum and its hard-partitioned monologue pools were pre-cascade scaffolding and have been fully stripped from the server codebase (Sprint 37, #878, PR #137). The original D-117 supersession framing (single tycoon character in v0.2) is itself obsolete now that v0.2 is dropped (CLAUDE.md: "v0.2 target is dropped"). The partition *design pattern* is preserved in this record for when culture-driven / generator-produced monologue is reintroduced in Phase 6, but no corresponding code or content exists today.
- **Amendment (2026-04-22, Sprint 37, PR #137):** Monologue pool selection is now unkeyed by archetype until a Phase 6 character system exists. `MonologueState.character` field and `CharacterArchetype` enum deleted from `server/src/bridge/types.rs`, `server/src/simulation/monologue.rs`, observer relabeling logic, and `server/content/modules/tier1/smuggling_ring_v0_1.yaml`. Uniform single-pool behavior is the intended end state pre-Phase-6 — not scaffolding deferred in place with a stub, but retired pending the real character system. Lead override recorded in `docs/architecture/sprint-37-878-audit.md` (2026-04-21 override section). Reintroduction gate: a confirmed Phase 6 character-model design is a prerequisite before this decision is revived.
- **Decision:** Internal monologue content is hard-partitioned by playable character. The smuggler and detective have completely separate monologue pools — no shared lines. The `character` tag on monologue lines is a hard partition, not a filter. File structure uses separate files per character per location (e.g., `monologue-smuggler.yaml`, `monologue-detective.yaml`).
- **Rationale:** Shared monologue would dilute character voice and undermine the dual-lens experience. Each character's internal voice must be independently coherent. Same trigger, different pool — this is how mirror moments work without either pool knowing about the other.
- **Cross-reference:** Dialogue lines remain character-agnostic — the access tier system (D-028 Layer 1) handles per-character filtering without separate pools.
@@ -80,7 +81,7 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
- `mood` (list\<enum\>): 8 moods for v0.1 — D-028 Layer 4 weighted selection
- `tags` (list\<string\>): freeform escape hatch for author intent
- **Monologue-specific additions:**
- `character` (enum): `smuggler`, `detective`hard partition per D-032 **[Obsolete post-D-117: smuggler/detective eliminated. v0.2 uses culture-driven voice per D-121; this enum is unused.]**
- `character` (enum): historically `smuggler`, `detective`removed. Per [D-032 SUPERSEDED] and the development cascade (CLAUDE.md), character-partitioned monologue is Phase 6 and has been stripped from the codebase (Sprint 37, #878). Field is unused; do not reintroduce without a confirmed Phase 6 design.
- `trigger` (enum): 9 trigger types (enter_location, observe_npc, hear_sound, observe_anomaly, post_conversation, discover_evidence, witness_interaction, time_idle, return_visit)
- `prerequisite` (map or null): knowledge state gate
- **Authoring-only tags (not consumed by engine):** `dual_lens` (map, per-character notes), `notes` (string)
@@ -92,6 +93,7 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
- **Amendment (Sprint 14):** Mood vocabulary renamed to match voice guide (monologue-voice-guide.md). Old → new: `fond``warm`, `comfortable``content`, `worried``anxious`, `concerned``frustrated`. Dropped: `analytical` (merged into `focused`), `conflicted` (modeled as `suspicious`+`warm` collision). Added: `hostile`. Final 8 moods: `anxious`, `frustrated`, `content`, `suspicious`, `warm`, `hostile`, `relieved`, `focused`. Neutral = untagged.
- **Amendment (Sprint 24):** `triangle_activated` added as 15th situation (fires post-TriangleActivated when player observes anchor NPCs). Two freeform tags registered as conventions: `triangle-signal` (line is part of the triangle activation sequence) and `tell-observation` (line observes a behavioral tell without naming its cause). `npc_in_los` prerequisite added for LOS-gated monologue lines. Schema updated to match.
- **Amendment (Sprint 15):** Line ID namespace changed from location-scoped to NPC-scoped. Old scheme: `{location_slug}_{d|m}_{###}` (e.g., `the-terminal_d_039`) — all NPCs at a location share one ID sequence, requiring cross-file coordination and causing collisions at scale. New scheme: `{npc-slug}_{d|m}_{###}` for dialogue, `{npc-slug}_m_{s|d}_{###}` for monologue (e.g., `kael-davan_d_001`, `dock-worker_d_001`). Each NPC's IDs are independent — no cross-file coordination needed. Auto-generated NPCs use their generated slug. Schema regex patterns unchanged (prefix is still `^[a-z][a-z0-9-]*`), only the `description` field and convention documentation update. Migration: mechanical rename of all existing line IDs across ~20 dialogue files and monologue pools.
- **Amendment (2026-04-22, Sprint 37, PR #137):** The `character` monologue-specific tag (historically `smuggler | detective`) is retired pending a Phase 6 character-model design — not deferred in place with a stub enum. Per the development cascade (CLAUDE.md), character-partitioned monologue is Phase 6 detail-coloring; the codebase has been stripped of the `CharacterArchetype` enum and the `MonologueState.character` field that keyed pool selection (#878). Pool selection is now archetype-independent. Authoring files that carry historical `character:` tags are content artifacts and will be re-evaluated when the Phase 6 character system is designed; engine consumption of the field is gone. Reintroduction gate: a confirmed Phase 6 character-model design is a prerequisite. See `docs/architecture/sprint-37-878-audit.md` (lead override section) for the cascade rationale.
### D-036: Sova Transit District / Van Maanen's Star as v0.1 setting
- **Date:** 2026-02-11
+3 -2
View File
@@ -225,10 +225,11 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
- `GameState.insert_active` is the source of truth (defaults true in v0.1; wired from snapshot field `insert_active`).
- Rationale: diegetically consistent — the body reacts to proximity; the insert reacts to commands.
### D-057: Entity interaction — vertical list, insert-styled
### D-057: Entity interaction — vertical list, insert-styled [PARTIALLY SUPERSEDED — archetype portion deferred to Phase 6]
- **Date:** 2026-02-13
- **Supersession note (2026-04-21 / amended 2026-04-22, Sprint 37, #878, PR #137):** The character-archetype verb variation portion of this decision is retired pending a Phase 6 character-model design — not deferred in place with a stub. Per the development cascade (CLAUDE.md), archetype-driven verb relabeling is Phase 6 detail-coloring and has been stripped from the server. Vertical-list structure, Phase 1/Phase 2 split, POI priority flips, and contradiction markers remain live. Relabeling (Open→"Move"/"Stash" vs "Scan"/"Flag") is deleted; container verb labels are now identical across all player states, and uniform labeling is the intended pre-Phase-6 end state, not a regression. Reintroduction gate: a confirmed Phase 6 character-model design is a prerequisite. See `docs/architecture/sprint-37-878-audit.md` (lead override section) for the cascade rationale.
- **Decision:** Entity interactions use a compact vertical list (not radial). 2-4 options max, anchored to entity position. Insert-styled with Araminta's geometric aesthetic. New options unlocked by knowledge changes are highlighted with a gradient glow background. Radial menu reserved for world menu only ([D-058](#d-058-world-menu--radial-4-spokes)). Max 3 visible response options in dialogue context.
- **Server architecture:** Two-phase verb computation. Phase 1 (simulation, no KG): compute maximum possible verb set from ObjectType component (Readable, Container, Terminal, Door, Pickup, Furniture — each with specific verb sets). Phase 2 (observer, reads KG): filter by character's knowledge (Confront requires KnowsDetails+ per [D-041](architecture.md#d-041-knowledge-graph-data-model)), apply POI priority flips, add contradiction markers. Character-archetype verb variation implemented as Phase 2 observer filter rules (same crate: smuggler sees "Move/Stash", detective sees "Scan/Flag").
- **Server architecture:** Two-phase verb computation. Phase 1 (simulation, no KG): compute maximum possible verb set from ObjectType component (Readable, Container, Terminal, Door, Pickup, Furniture — each with specific verb sets). Phase 2 (observer, reads KG): filter by character's knowledge (Confront requires KnowsDetails+ per [D-041](architecture.md#d-041-knowledge-graph-data-model)), apply POI priority flips, add contradiction markers. ~~Character-archetype verb variation implemented as Phase 2 observer filter rules (same crate: smuggler sees "Move/Stash", detective sees "Scan/Flag").~~ *[Removed Sprint 37 — archetype verb relabeling deleted; see supersession note above.]*
- **Diegetic test:** Labels render on z-layer 6. If insert is off, labels disappear.
- **Rationale:** Variable-length text options (e.g., confrontation lines in character voice) break radial spatial memory. List handles 1-4 options cleanly. New-item glow signals "something changed" without UX hazard of geometry transforming under cursor. Two-phase computation enables character differentiation without separate verb systems.
- **References:** Disco Elysium (world-embedded indicators), Darkwood (minimal cursor), Rimworld (right-click context list).
+10 -17
View File
@@ -213,15 +213,12 @@ Note: `ConfirmBookmark` is the **trigger** for transitioning from the character-
```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.
/// Populated when `ConfirmBookmark` is processed. `None` fields during the
/// character-creation phase (before confirm) and 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)]
/// Serialized into `SaveStateV1.selected_bookmark` (#863) so that a loaded
/// game remembers which bookmark and starting location were chosen.
#[derive(Resource, Debug, Clone, Default, Serialize, Deserialize)]
pub struct SelectedBookmark {
pub bookmark_id: Option<String>,
pub starting_location_id: Option<String>,
@@ -231,15 +228,11 @@ pub struct SelectedBookmark {
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.
**Save/load scope (Sprint 37, #863):** `SelectedBookmark` is persisted into
`SaveStateV1.selected_bookmark`. After `load_from_file` completes, the resource
reflects the bookmark confirmed at session-start. Saves created before Sprint 37
will deserialize the field as `SelectedBookmark::default()` (both fields `None`)
via `#[serde(default)]` on the `SaveStateV1` field.
## 5. Content source — how bookmarks get into the registry
+211
View File
@@ -0,0 +1,211 @@
# Sprint 37 #878 — CharacterArchetype audit (D-032 obsolete claim)
**Author:** Tyre (architecture)
**Date:** 2026-04-21 (audit); 2026-04-22 (lead override amendment)
**Ticket:** #878 — Audit and purge dead smuggler/detective character enum (D-032 obsolete)
**Status:** CLOSED — lead override 2026-04-21: strip stays. See override section below.
---
## Lead override (2026-04-21)
**Decision:** STRIP the full `CharacterArchetype` trace from the server. The
original audit (below) recommended Option A (no-op, docs-only) on the basis
that grep identified five load-bearing consumers. The lead reframed the
live-vs-filler determination:
> "It is live because we have not scrapped the system loading it in the
> client. This is not live gameplay. Only the character creation elements
> and the insert screens are actual production code. The rest is uncleaned
> filler — per the cascade, we don't deal with character and NPC
> instructions." — Jeroen, 2026-04-21
**Rationale — the cascade framing.** Per `CLAUDE.md`, development follows a
strict six-phase cascade. Current focus is Phase 1 (wiki content). Character
model, NPC differentiation, verb-label relabeling, and character-keyed
monologue pools all belong to Phase 6 (detail coloring). Code that executes
at runtime is **not** automatically production — if the topic belongs to a
later cascade phase, it is pre-cascade filler regardless of how deeply it is
wired in.
The audit's identification of `archetype_verb_label` as "live feature code"
was mechanically correct: the code runs, produces output, and is observed on
the wire. But the **feature itself is premature**. Container verb
differentiation (Smuggler Move/Stash vs Detective Scan/Flag) and
archetype-keyed monologue pools are Phase 6 detail, not Phase 13
scaffolding. Uniform verb labels and a single monologue pool are the
intended end state until a Phase 6 character system is designed — not a
regression.
**Audit correction.** The audit's TL;DR conclusion ("v0.2 dropped reverses
D-117 which reverses D-032-obsolete, therefore keep the enum") was the
wrong frame. The supersession chain collapsed — yes — but the correct
reading is that **all three decisions sit below the current cascade
floor**, so the enum is cruft on cascade grounds independent of the D-117
revocation. The audit should have consulted the cascade phase before
grep-counting consumers; that is the non-obvious precedent captured in
memory (`feedback_running_code_not_production.md`).
**Scope of the strip (Sprint 37, #878, merged via PR #137). Commit `cae3d3ab`
— "refactor(simulation): strip archetype trace + HeritageRoot per cascade
(#877, #878)" — is the authoritative file list; the summary below is the
high-signal view:**
- `server/src/bridge/types.rs``CharacterArchetype` enum +
`StartupMessage.character_archetype` field removed. Protocol break rides
the `PROTOCOL_VERSION` drop in #874 (D-192), co-shipped in the same PR.
- `server/src/perception/observer/mod.rs``apply_phase2_verb_filter`
loses its archetype parameter; `archetype_verb_label` helper + the
container-relabeling block deleted.
- `server/src/simulation/monologue.rs``MonologueState.character`
field + `Default` value removed. Pool selection is now uniform.
- `server/src/simulation/examine.rs``generate_examine_text` collapses
two archetype-specific branches into a single detective-style frame.
- `server/src/test_world/mod.rs` + `server/src/main.rs``setup_gauntlet`
and `setup_proof_room` drop the archetype parameter; all internal
callsites + the production main loop updated.
- `server/tests/archetype_monologue.rs` — deleted (regression guard for
#587's archetype→monologue wiring; wiring itself deleted).
- `server/tests/v01_integration_playthrough.rs` — deleted (5
archetype-using integration tests; superseded by Gauntlet coverage).
- `server/content/schemas/drama_module.schema.yaml` — deleted.
- `server/content/modules/tier1/smuggling_ring_v0_1.yaml` — deleted.
**Client follow-up: ticket #882** — *"Strip archetype-driven client code
(follow-up to #878 server)"*. The server strip leaves client-side code
referencing the removed `character_archetype` wire field and archetype-
keyed palette/monologue branches. Client cleanup preserves the character-
creation UI and insert screens (production per the lead call) and strips
`lattice_profile` branching, `character.txt` session I/O, and protocol
decoding of the removed field. Blocked by this PR; cross-referenced in
the server task description.
**Decision record amendments (2026-04-22):**
- `decisions/content.md` D-032 `[SUPERSEDED]` header rewritten to cite the
cascade instead of the dropped D-117.
- `decisions/content.md` D-035 monologue `character` enum note updated
(was `[Obsolete post-D-117]`, now cites the cascade strip).
- `decisions/perception.md` D-057 marked `[PARTIALLY SUPERSEDED]` with
the archetype verb relabeling portion crossed out; vertical-list +
Phase 1/2 split preserved.
**Regression guards for the new uniform behavior** are being added under
separate tasks (Hoshe, Sprint 37) — positive assertions that container
verb labels and monologue pool selection are archetype-independent, to
prevent silent reintroduction.
**DECISION: STRIP.**
The original audit body below is retained as a historical record of the
pre-override analysis. Do not take its recommendation as current.
---
## TL;DR (original audit, SUPERSEDED by lead override above)
The ticket's premise — that `CharacterArchetype` (Smuggler/Detective) is dead
code per D-032's "[Obsolete post-D-117]" footnote — is **stale**. The
footnote relied on D-117 (tycoon is the v0.2 bookmark), but `CLAUDE.md`
now declares **"v0.2 target is dropped. No scoping negotiations."** That
revocation is the most recent architectural directive, and it rolls back
the premise that justified marking the enum obsolete.
Recommendation: **do not delete `CharacterArchetype`**. Instead, update
the decision record to clear the stale obsolete footnote, close the
ticket as "no-op — premise superseded," and (optionally) claim a small
D-record documenting the reversal chain.
## Audit
Grep was run against `server/`, `tooling/`, and `tests/`. `CharacterArchetype`
has **five load-bearing consumers** plus content-schema users:
### 1. IPC protocol surface
- `server/src/bridge/types.rs:51-54``StartupMessage.character_archetype: CharacterArchetype`.
Field is serialized into the session handshake. Removing it is a protocol break.
- `server/src/bridge/types.rs:500-518` — enum + `as_monologue_key()` helper + `Default = Detective`.
- Roundtrip tests at lines 1133, 1146, 1157, 1166, 1173, 1184 exercise the field.
### 2. Observer pipeline (D-057, #422) — real runtime behavior
- `server/src/perception/observer/mod.rs:81,117,138,200` — archetype flows through
`apply_phase2_verb_filter`.
- `server/src/perception/observer/mod.rs:657-760``archetype_verb_label()` swaps
container verb labels based on archetype (Smuggler sees `Move`/`Stash`, Detective
sees `Scan`/`Flag`). This is live feature code, not scaffolding.
### 3. Monologue pool selection (D-032, #587, #595)
- `server/src/simulation/monologue.rs:134-152``MonologueState.character: String` is
initialized from `CharacterArchetype.as_monologue_key()` at session start.
Pool partition is by string key, but the string is *derived* from the enum.
### 4. Gauntlet test-world plumbing
- `server/src/test_world/mod.rs:98``pub fn setup_gauntlet(app: &mut App, archetype: CharacterArchetype)`.
- 5 internal callsites (lines 702, 725, 742, 761, 778) plus the external
`archetype_monologue.rs` integration suite.
### 5. Regression test suite
- `server/tests/archetype_monologue.rs` — entire file is a regression guard
against #587 (archetype→monologue character wiring). Seven tests, four
explicitly assert Smuggler vs Detective behavior. Deleting the enum requires
deleting this guard, which is the thing that catches the bug it was built for.
### 6. Content schemas (authoring)
- `server/content/schemas/drama_module.schema.yaml:231,233,234,280,558`
schema enumerates `smuggler | detective | any` for dialogue/monologue
partitioning in drama modules.
## Decision chain (why the ticket premise is stale)
```
D-027 (v0.1 vertical slice = smuggler + detective)
└─ superseded by D-117 (2026-03-05: tycoon is the v0.2 bookmark)
└─ superseded by "v0.2 target is dropped" (CLAUDE.md, current)
```
The obsolete footnote in D-035 line 83 and the `[SUPERSEDED]` header on
D-032 both point at D-117 as the supersession. With v0.2 dropped, we are
back to the v0.1 smuggler/detective frame as the implemented base until
the 6-phase cascade reaches Phase 4 (Player control) — and even then,
the cascade describes a 2-floor test map + character rendering, not a
wholesale character-model replacement.
## Proposed alternative scope for #878
Three options, cheapest first:
### A. Close as no-op + documentation cleanup (recommended)
- Strip the `[Obsolete post-D-117]` footnote from `decisions/content.md:83`
(D-035 tag taxonomy).
- Remove the `[SUPERSEDED]` marker from `decisions/content.md:47` (D-032
header) or add a "supersession reversed" note.
- Optionally claim a new D-record in `decisions/scope.md` documenting
that the v0.2-dropped directive implicitly reverses D-117's character
frame revocation.
- Zero code changes. Build stays green. 30 minutes.
### B. Narrow the ticket to the authoring-side leftovers
- If there are *authoring* artifacts (half-written tycoon monologue
partitioning, stale schema fields) that were added in anticipation of
D-117 and never used, those can be purged. But a quick scan of
`drama_module.schema.yaml` shows the schema is consistent with v0.1 usage.
- Requires a content team review — server-team scope alone cannot
confirm what is live in authoring.
### C. Rename without removing (if lead wants distance from v0.1 framing)
- Rename `CharacterArchetype``PlayerCharacterRole` (or similar) and
its variants to preserve behavior while shedding the "smuggler/detective
investigation framing" language. Higher risk, touches ~45 files, and
doesn't actually change runtime. **Not recommended** unless the lead
specifically wants the naming to match post-cascade vocabulary.
## Recommendation (SUPERSEDED — see "Lead override" at top)
~~Go with **Option A**. The enum is architecturally sound, the ticket is
a casualty of the v0.2→cascade pivot, and the cleanup is documentation-
only.~~
**Actual decision:** strip (Option D, not enumerated above — full trace
purge driven by cascade framing, not by the D-117/v0.2 supersession
chain). The audit's grep-count-first methodology was the wrong starting
heuristic; cascade phase comes first. See top of document.
@@ -1,692 +0,0 @@
# yaml-language-server: $schema=../../schemas/drama_module.schema.yaml
#
# Tier 1 Drama Module: The Smuggling Ring (v0.1)
# The vertical slice Tier 1 module for D-027.
#
# NARRATIVE CORE:
# A logistics worker (the smuggler PC, if played) is embedded in a small ring
# smuggling unlicensed lattice components through Sova Transit District (D-037).
# The ring is led by Voss from The Terminal. Kael Davan — a ring member and the
# smuggler's FRIEND — is quietly trying to exit to protect his partner Naia Tamm.
# Sera Venn (the detective's FRIEND) has noticed Kael's manifest discrepancies
# but hasn't reported them, protecting Naia by proxy.
#
# DUAL-LENS EXPERIENCE:
# Smuggler plays INSIDE the ring: manage drops, cover tracks, notice Kael going cold.
# Detective plays OUTSIDE: cargo anomalies → follow Kael → witness secret meeting →
# confront or protect.
#
# SUCCESS CRITERIA (D-027):
# #1: 30 minutes of daily-life play before the ring activates (min_play_ticks: 2100)
# #3: Player names Kael as an NPC they felt conflicted about
# #4: observe→notice→follow→discover emerges from systems, not scripts
module_id: smuggling_ring_v0_1
display_name: "The Smuggling Ring"
version: "0.1"
tier: 1
description: >
A small ring of logistics workers smuggling unlicensed lattice components through
Sova Transit District. The ring's weakest link — Kael Davan — is trying to exit
to protect his partner. The detective investigates cargo anomalies. The smuggler
manages ring operations and navigates Kael's loyalty crisis. Neither character
knows the other's full picture until confrontation forces it.
notes: >
This module IS the vertical slice (D-027). It exercises every system at full depth:
dual-lens NPC observation, tell progression, trust-gated dialogue, knowledge graph
confidence accumulation, confrontation weight (D-063), walk-away consequences (D-064),
and THE FRIEND contradiction arc (D-034). All outcome paths must feel earned.
No outcome is "the right answer" — Kael's situation has no clean resolution.
dual_lens:
smuggler: >
You're inside the ring. Voss manages operations; you handle logistics cover.
Kael used to be reliable. Lately he's absent, distracted, making excuses.
The drop schedule is at risk. Do you pressure him, cover for him, or cut him?
You don't know he's trying to get out. He doesn't know you've noticed.
detective: >
Cargo manifest discrepancies in The Terminal. Small, systematic, deniable.
Your analytical lattice flags them before your conscious mind does.
Follow the thread: discrepancy → dock worker with odd schedule → Kael Davan →
maintenance corridors → someone he shouldn't be meeting. And then what?
Arrest a man trying to leave a ring he never wanted to join?
pool:
weight: 8
compatible_districts:
- sova-transit
max_concurrent: 1
# ── ENTRY CONDITIONS ─────────────────────────────────────────────────────────
# Ring activity begins after player has had time to establish routine (D-027 #1).
# The ring is already running at game start — the module activates when the
# storyteller decides the tension has built enough to surface.
entry_conditions:
world_state:
- type: npc_present
role: ring-leader
- type: npc_present
role: ring-member-exiting
- type: location_accessible
location: the-terminal
- type: location_accessible
location: maintenance-corridors
activation:
trigger: storyteller_push
min_play_ticks: 2100 # ~35 minutes at 1 tick/second — D-027 criterion #1
# The storyteller pushes activation when player has established presence
# in The Terminal or The Last Shift through routine interaction.
# Proximity trigger (maintenance-corridors) is a secondary activation path
# if the player wanders there early.
# ── NPC REQUIREMENTS ─────────────────────────────────────────────────────────
# All core roles are named (hand-authored NPCs from the vertical slice).
# No generated NPC slots in v0.1 — the smuggling ring uses the 15 authored NPCs.
npc_requirements:
- role: ring-leader
display_hint: >
Runs the ring from The Terminal. Logistics authority = cover.
Never handles contraband directly. Pressure source for Kael.
binding: named
named_npc: "npc:voss"
must_have_motivation: HANDLER
- role: ring-member-exiting
display_hint: >
Kael Davan. Dock worker, ring member, smuggler's FRIEND.
Trying to exit quietly to protect Naia. This is THE FRIEND contradiction.
Every event sequence runs through this role.
binding: named
named_npc: "npc:kael-davan"
must_have_pattern: FRIEND
must_have_motivation: TURNCOAT
- role: partner-uninvolved
display_hint: >
Naia Tamm. Kael's partner. Does not know about the ring.
Her safety is Kael's motivation for exiting. Her ignorance is the moral weight.
Discovery of her connection to Kael is a late-investigation revelation.
binding: named
named_npc: "npc:naia-tamm"
must_have_motivation: CIVILIAN
- role: evidence-holder
display_hint: >
Sera Venn. Detective's FRIEND. Commission field tech.
She has noticed Kael's manifest discrepancies but hasn't reported them —
she knows Naia, and filing means Kael's arrest and Naia's exposure.
Her silence IS the detective's investigation blocker in phase 1.
binding: named
named_npc: "npc:sera-venn"
must_have_pattern: FRIEND
must_have_motivation: WITNESS
- role: ring-operative
display_hint: >
The ring's operational member in maintenance corridors.
Handles physical drops. Not a speaking character — observable behavior only.
Can be the anonymous contact Kael meets.
binding: named
named_npc: "npc:nils-davan"
is_optional: false
- role: institutional-watcher
display_hint: >
Maret Korr. A Commission observer embedded at The Terminal.
Her growing attention is the external pressure that accelerates the timeline.
She doesn't know about the ring specifically — she's tracking cargo patterns.
binding: named
named_npc: "npc:maret-korr"
must_have_motivation: OPERATOR
is_optional: true # Module runs without Maret, but with degraded tension arc
# ── EVENTS ───────────────────────────────────────────────────────────────────
# Two sequences + one pool.
# Sequence A: Kael's exit arc (the FRIEND contradiction backbone)
# Sequence B: Investigation pressure arc (escalating discovery opportunities)
# Pool: ambient ring activity (fires opportunistically throughout the module)
events:
sequences:
# SEQUENCE A: Kael's Exit Arc
# The narrative spine. Each step makes Kael's contradiction more visible.
# Observable to both characters, interpreted differently.
- sequence_id: kael_exit_arc
label: "Kael's Exit Arc"
description: >
Kael Davan's progressive attempt to leave the ring.
Tells intensify. Routine deviations appear. The secret meeting is the
pivot point — after it fires, both characters' understanding shifts.
steps:
- event_id: kael_goes_cold
label: "Kael Goes Cold"
description: >
Kael starts missing social patterns he'd normally keep — fewer bar visits,
shorter responses at The Terminal, leaving early. His tell system activates:
the shoulder-check behavior appears. Nothing dramatic. Just absence where
there was presence. The smuggler notices because they work together.
The detective might notice if they've been tracking Kael's baseline.
triggers:
- type: ticks_since_activation
ticks: 300 # ~5 minutes after module activates
effects:
- type: npc_routine_deviation
npc_role: ring-member-exiting
description: >
Kael skips his usual post-shift drink at The Last Shift.
Leaves the terminal 15 minutes early. No explanation.
- type: tell_intensify
npc_role: ring-member-exiting
description: >
Kael's shoulder-check behavior activates at The Terminal.
Visible to any character with forward vision cone in his direction.
sets_flag: kael_behavior_changed
- event_id: drop_happens_without_kael
label: "Scheduled Drop — Kael Absent"
description: >
A ring drop occurs in maintenance corridor C-7. Kael was supposed
to verify the cargo. He wasn't there. Nils covered it.
The smuggler notices the irregularity in the paperwork.
The detective — if watching cargo patterns — sees a manifest entry
with no verifying signature where one is normally present.
triggers:
- type: ticks_since_event
after_event: kael_goes_cold
ticks: 450 # ~7.5 minutes after goes-cold
effects:
- type: fact_becomes_discoverable
fact_id: "ring.kael_missed_verification"
discoverable_by: any
discovery_method: >
Smuggler: check the cargo manifest in The Terminal office.
Detective: analytical lattice flags unsigned verification entry.
- type: location_state
location: maintenance-corridors
description: "An unsigned cargo verification entry exists in corridor C-7's log."
sets_flag: kael_missed_drop
- event_id: kael_secret_meeting
label: "Kael's Secret Meeting"
description: >
Kael meets an off-district contact in maintenance corridor B-7.
This is the observable contradiction (D-034): Kael, in a restricted
area he has no logged reason to be in, talking to someone who's
not in any district NPC roster. His body language is tense.
If the player is in visual range: this is the pivot moment.
If not: the meeting happens anyway — the world doesn't wait.
triggers:
- type: ticks_since_event
after_event: drop_happens_without_kael
ticks: 600 # ~10 minutes after the dropped verification
- type: player_proximity
target_type: location
target: maintenance-corridors
radius_tiles: 12 # Player wandering near triggers the meeting early
effects:
- type: npc_routine_deviation
npc_role: ring-member-exiting
description: >
Kael enters maintenance corridor B-7. Locked door to restricted
supply closet. Emerges with the ring-operative 8 minutes later.
Neither acknowledges the encounter publicly.
- type: fact_becomes_discoverable
fact_id: "ring.kael_unauthorized_corridor_access"
discoverable_by: any
discovery_method: >
Player must be in visual range of corridor B-7.
Or examine the corridor door access log (investigative action).
- type: tell_intensify
npc_role: ring-member-exiting
description: >
After the meeting, Kael's shoulder-check frequency doubles.
Also: he avoids eye contact with the smuggler at The Terminal.
sets_flag: secret_meeting_occurred
- event_id: kael_sends_message
label: "Kael Sends the Message"
description: >
Kael sends an encrypted Meridian message to an off-district contact.
The detective's analytical lattice can detect an anomalous outgoing
packet from the district node — not the content, just the pattern
(frequent, encrypted, sent from cargo bay terminals, not personal devices).
The smuggler won't see this unless they're specifically watching Kael.
triggers:
- type: ticks_since_event
after_event: kael_secret_meeting
ticks: 200
effects:
- type: fact_becomes_discoverable
fact_id: "ring.kael_unusual_meridian_activity"
discoverable_by: detective
discovery_method: >
Detective's analytical lattice flags the outgoing packet pattern.
Requires player to be in or adjacent to The Terminal cargo bay.
sets_flag: kael_message_sent
- event_id: ring_leader_confronts_kael
label: "Voss Confronts Kael"
description: >
Voss calls Kael into The Terminal supervisor's office.
Closed door. Raised voices (audible only from adjacent room/position).
Kael emerges pale. Voss emerges neutral. The smuggler can witness
the approach/departure without hearing content. The detective can
observe Kael's state immediately after if in The Terminal.
This is Voss applying pressure. Kael is now visibly under strain.
triggers:
- type: flag_set
flag: kael_message_sent
- type: ticks_since_event
after_event: kael_message_sent
ticks: 400
effects:
- type: npc_routine_deviation
npc_role: ring-leader
description: "Voss calls Kael into the supervisor's office. Door closed."
- type: npc_routine_deviation
npc_role: ring-member-exiting
description: >
Kael emerges from the meeting looking strained. His shoulder-check
is now constant. He takes an unscheduled break outside, alone.
- type: tell_intensify
npc_role: ring-member-exiting
description: >
Kael's contentment hits lowest observed level. He now actively avoids
the ring-operative (Nils) in public. The disconnection is visible.
- type: fact_becomes_discoverable
fact_id: "ring.voss_kael_private_meeting"
discoverable_by: any
discovery_method: >
Observe the meeting room door (spatial). Or ask Kael directly
after (trust-gated dialogue unlocks "Are you alright?" option).
sets_flag: voss_pressure_applied
# SEQUENCE B: Investigation Pressure Arc
# External pressure that escalates the timeline.
# Fires in parallel with Sequence A.
- sequence_id: investigation_pressure
label: "Investigation Pressure Arc"
description: >
Maret Korr's institutional attention creates a closing window.
Her growing interest is the reason the module can't stay in equilibrium forever.
She doesn't know about the ring — she's a pattern-watcher. But patterns
are what the detective investigates too. Their paths converge.
steps:
- event_id: maret_flags_anomaly
label: "Maret Flags the Cargo Anomaly"
description: >
Maret Korr files an internal Commission note flagging The Terminal's
cargo variance rate as statistically unusual. Not an investigation —
just a flag. The detective's institutional access can pull this note.
The smuggler has no way to know it exists (unless the detective tells them).
triggers:
- type: ticks_since_activation
ticks: 900 # ~15 minutes after activation
effects:
- type: fact_becomes_discoverable
fact_id: "ring.commission_cargo_flag"
discoverable_by: detective
discovery_method: >
Detective queries Commission data via institutional access
(authority access tier, Terminal records).
sets_flag: commission_flag_exists
- event_id: maret_increases_presence
label: "Maret Increases Her Presence"
description: >
Maret starts spending more time in The Terminal. More frequent
walkthroughs during shift changes. Her attention to the cargo bay
area is noticeable to anyone watching. Ring members are unnerved.
Voss starts accelerating the timeline to close operations before
institutional attention becomes formal investigation.
triggers:
- type: ticks_since_event
after_event: maret_flags_anomaly
ticks: 600
- type: player_action
action: examine
target_role: institutional-watcher
effects:
- type: npc_routine_deviation
npc_role: institutional-watcher
description: >
Maret adds two extra Terminal walkthroughs per shift cycle.
Spends 15 minutes studying the cargo bay manifest terminals.
- type: tell_intensify
npc_role: ring-leader
description: >
Voss becomes quieter, more deliberate. Less casual conversation.
His tell — the stillness before speaking — becomes more frequent.
- type: fact_becomes_discoverable
fact_id: "ring.voss_accelerating_timeline"
discoverable_by: any
discovery_method: >
Smuggler: Voss mentions "the schedule moving up" in a guarded
conversation (trust-gated, ring-insider access required).
Detective: observe Voss and Nils in two exchanges within same shift.
sets_flag: timeline_accelerating
- event_id: final_shipment_scheduled
label: "The Final Shipment Is Scheduled"
description: >
The ring schedules the last major drop — after this, they go dark.
This is the closing window. If the detective hasn't uncovered enough
by the time this fires, the ring disperses and the operation closes
without exposure (escaped outcome). If they have, confrontation
becomes unavoidable. The smuggler knows about this drop. Kael doesn't
want to participate. Voss insists.
triggers:
- type: flag_set
flag: timeline_accelerating
- type: ticks_since_event
after_event: maret_increases_presence
ticks: 800
effects:
- type: fact_becomes_discoverable
fact_id: "ring.final_shipment_scheduled"
discoverable_by: any
discovery_method: >
Smuggler: direct notification from Voss.
Detective: cargo manifest shows an unusual large entry for 3 days out.
- type: npc_routine_deviation
npc_role: ring-member-exiting
description: >
Kael's schedule changes: he's assigned to the cargo bay
during the drop window. He doesn't want to be there.
sets_flag: final_shipment_known
pools:
# POOL: Ambient ring activity — opportunistic events that add texture
- pool_id: ambient_ring_activity
label: "Ambient Ring Activity"
description: >
Low-level ring business that happens throughout the module regardless of
player engagement. Creates the sense that the ring exists independently.
Players who look closely will find more; players who don't still feel the world moving.
events:
- event_id: cargo_discrepancy_appears
label: "Small Cargo Discrepancy Appears"
description: >
A minor manifest irregularity appears in The Terminal records.
Small enough to be deniable. Systematic enough to be a pattern.
The detective's analytical lattice may flag it. The smuggler can
correct it if they notice it — covering tracks is part of their role.
triggers:
- type: ticks_since_activation
ticks: 150 # Fires early and repeats
effects:
- type: fact_becomes_discoverable
fact_id: "ring.cargo_discrepancy_pattern"
discoverable_by: any
discovery_method: >
Detective: analytical lattice flags during Terminal walkthrough.
Smuggler: check manifest terminals (or get flagged by the discrepancy
in their own work).
once: false # Repeats — pattern builds over time
- event_id: sera_avoids_torek
label: "Sera Avoids Torek at The Bar"
description: >
Sera Venn reroutes her usual path through The Last Shift to avoid
standing near Torek Lintar (the Commission enforcement officer).
Anyone watching Sera's normal pattern would notice.
This is the detective's first clue that Sera's behavior is odd.
triggers:
- type: ticks_since_activation
ticks: 500
effects:
- type: npc_routine_deviation
npc_role: evidence-holder
description: >
Sera takes a longer route to her usual seat, passing through
the back of the bar to avoid Torek's sightline.
- type: fact_becomes_discoverable
fact_id: "ring.sera_avoidance_behavior"
discoverable_by: detective
discovery_method: >
Observe Sera's path through the bar on two separate occasions.
Requires forward vision cone and awareness of her baseline route.
once: false
- event_id: nils_makes_supply_run
label: "Nils Makes an Unscheduled Supply Run"
description: >
The ring-operative (Nils) enters the maintenance corridors with a
small container logged as "calibration tools". The container isn't
logged for return. Someone paying attention to cargo flow would notice.
triggers:
- type: ticks_since_activation
ticks: 700
effects:
- type: npc_routine_deviation
npc_role: ring-operative
description: "Nils takes a container to maintenance corridor C-7."
- type: fact_becomes_discoverable
fact_id: "ring.nils_unlogged_cargo"
discoverable_by: any
discovery_method: >
Watch Nils's cargo handling pattern over two shifts.
Or examine maintenance corridor C-7 access log.
once: false
# ── OUTCOMES ─────────────────────────────────────────────────────────────────
# Five resolution states. Checked each tick after the first sequence step fires.
# Order matters — the storyteller applies the first matching outcome.
# is_terminal: true ends the module.
outcomes:
# 1. RING EXPOSED
# Detective successfully uncovers the operation.
# Commission becomes involved. Arrests/flight follow.
- outcome_id: ring_exposed
label: "Ring Exposed"
is_terminal: true
description: >
The detective accumulates enough evidence to trigger a formal Commission
inquiry. The ring collapses: arrests, flight, or both. Voss is detained.
Kael's situation is now public. The smuggler (if played) faces consequences.
Naia learns what Kael was doing — and why he was trying to leave.
No clean endings. The right outcome for the detective who goes all the way.
conditions:
facts_known:
- "ring.cargo_discrepancy_pattern"
- "ring.kael_unauthorized_corridor_access"
- "ring.voss_kael_private_meeting"
flags_set:
- secret_meeting_occurred # set by kael_secret_meeting event
- commission_flag_exists # Commission was watching before exposure
effects:
- type: npc_disposition
npc_role: ring-leader
shift: hostile
description: "Voss is detained or flees. Commission inquiry opens."
- type: npc_disposition
npc_role: ring-member-exiting
shift: hostile
description: >
Kael is arrested or disappears. His exit attempt is now moot.
His relationship with Naia is exposed.
- type: faction_reaction
faction: lattice-commission
reaction: grateful
description: "Commission credits the detective's investigation."
- type: npc_exit
npc_role: ring-leader
description: "Voss leaves the district — detained, fled, or both."
# 2. KAEL ESCAPES THE RING
# Unique path. Requires the player to engage with Kael directly
# and choose to help him rather than expose the ring wholesale.
- outcome_id: kael_escapes
label: "Kael Escapes the Ring"
is_terminal: true
description: >
Through the player's choices — helping Kael cover his exit, or warning him,
or simply choosing not to act on what they know — Kael successfully leaves
the ring before the final shipment. He and Naia leave the district quietly.
The ring continues without him, smaller and more cautious.
This outcome requires discovering Kael's secret AND choosing restraint.
The smuggler can engineer this by covering for Kael with Voss.
The detective can achieve this by confronting Kael privately rather than
filing a report. The most morally complicated path.
conditions:
facts_known:
- "ring.kael_unauthorized_corridor_access"
flags_set:
- kael_behavior_changed # set by kael_goes_cold — his exit arc begins here
- secret_meeting_occurred # set by kael_secret_meeting — the pivot moment
- voss_pressure_applied # set by ring_leader_confronts_kael — pressure applied
# ring_exposed is checked first in the outcomes list and is terminal,
# so kael_escapes only evaluates if ring_exposed hasn't fired.
# No flags_not_set needed here — outcome ordering handles priority.
effects:
- type: npc_disposition
npc_role: ring-member-exiting
shift: friendly
description: "Kael remembers whoever helped him. He's gone, but grateful."
- type: npc_exit
npc_role: ring-member-exiting
description: "Kael and Naia leave Sova Transit District."
- type: faction_reaction
faction: the-ring
reaction: suspicious
description: "The ring is destabilized by Kael's exit. Voss is alert to further leaks."
# 3. RING COMPLETES OPERATION
# The ring finishes the final shipment and goes dark before discovery.
# Default path if the detective doesn't move fast enough.
- outcome_id: ring_completes
label: "Ring Completes the Operation"
is_terminal: true
description: >
The final shipment clears. The ring disperses. Voss transfers. Nils goes quiet.
Kael stays — he's now out by default, the ring having dissolved around him.
The evidence trail goes cold. The detective closes the case as inconclusive.
The smuggler completes their last run and waits to see if there's another.
Unsatisfying only if you expected a tidy resolution. The world moved on.
conditions:
flags_set:
- final_shipment_known # set by final_shipment_scheduled event
- timeline_accelerating # set by maret_increases_presence — Maret forced their hand
facts_not_known:
- "ring.cargo_discrepancy_pattern" # detective never found the basic pattern — no investigation
ticks_since_activation: 3600 # Module ran for ~60 minutes without full exposure
# kael_message_sent was previously gated here but auto-fires at tick ~1550,
# making this outcome permanently unreachable. Replaced with player-action fact gate.
effects:
- type: faction_reaction
faction: the-ring
reaction: neutral
description: "The ring successfully completed this operation. They'll be back."
- type: npc_exit
npc_role: ring-leader
description: "Voss transfers to another station for 'career development'."
- type: location_access_change
location: maintenance-corridors
change: open
description: "The restricted supply closet is now empty. Access log shows it cleared."
# 4. RING SPLINTERS
# Partial discovery. The ring fractures but doesn't fully collapse.
# An incomplete ending that leaves threads for future investigation.
- outcome_id: ring_splinters
label: "Ring Splinters"
is_terminal: false # Not terminal — splinter state can evolve
description: >
Enough evidence surfaces that the ring knows it's been partially seen.
Voss shuts down active operations. Nils disappears. Kael stays — now the
one person in the district who knows what happened and has no one to tell.
The formal investigation stalls for lack of a clear chain of evidence.
The detective has facts but not the complete picture. The smuggler
faces an awkward return to normalcy. Both know the ring isn't gone — just quiet.
conditions:
facts_known:
- "ring.cargo_discrepancy_pattern" # detective found some evidence — ring responds
events_fired:
- kael_goes_cold # event ID — Kael's behavioral shift fired
flags_set:
- kael_missed_drop # set by drop_happens_without_kael — ring destabilized
ticks_since_activation: 2400
# Mutually exclusive with ring_completes via facts_known/facts_not_known on
# ring.cargo_discrepancy_pattern. No auto-flag gate needed.
effects:
- type: npc_disposition
npc_role: ring-leader
shift: suspicious
description: "Voss goes quiet. He's watching to see who knows what."
- type: npc_exit
npc_role: ring-operative
description: "Nils stops appearing at The Terminal. Transferred, officially."
- type: faction_reaction
faction: the-ring
reaction: suspicious
description: "The ring is alerted to exposure risk. Future operations will be more careful."
# 5. INVESTIGATION STALLS (post-splinter exit)
# The ring splinters but the detective never breaks through to the pivot evidence.
# Explicit terminal exit for the non-terminal ring_splinters state.
- outcome_id: ring_stalemate
label: "Investigation Stalls"
is_terminal: true
description: >
The ring went dark after the splinter. The detective has the cargo discrepancy
on record — enough to flag, not enough to pursue. The case stays open but cold.
No arrests. No answers. Kael stays in the district, the only person who knows
the full shape of what happened, with no one left to tell it to.
The ring will reconstitute elsewhere. It always does.
conditions:
facts_known:
- "ring.cargo_discrepancy_pattern" # ring_splinters already fired (same gate)
facts_not_known:
- "ring.kael_unauthorized_corridor_access" # detective never reached the pivot evidence
flags_set:
- kael_missed_drop
- final_shipment_known # ring finished while investigation stalled
ticks_since_activation: 4500 # 2100 ticks after ring_splinters window — investigation ran cold
effects:
- type: faction_reaction
faction: lattice-commission
reaction: neutral
description: "The discrepancy flag stays in Maret's file. No follow-up action."
- type: npc_exit
npc_role: ring-leader
description: "Voss quietly transfers. No announcement, no incident report."
# 6. MODULE EXPIRY (quiet exit)
# Player never engaged at all. Module times out without drama.
# NOTE (Gestalt, Sprint 18): Condition uses facts_not_known, not flags_not_set.
# kael_behavior_changed fires automatically at tick 300 (time-triggered), making
# flags_not_set: [kael_behavior_changed] permanently false after tick 300.
# Gate expiry on player-action-required facts instead.
- outcome_id: module_abandoned
label: "Module Abandoned"
is_terminal: true
is_expiry: true
description: >
The player never engaged with the ring's signals. The final shipment
completed without incident. The ring disperses on its own schedule.
Kael stays. The world is unchanged. This is not failure — it's the game
acknowledging that not every conspiracy needs a protagonist.
The 70% mundane majority (D-029) plays out: life continued.
conditions:
facts_not_known:
- "ring.cargo_discrepancy_pattern" # Only known via player examination of terminal
- "ring.kael_unauthorized_corridor_access" # Only known via player observing Kael in B-7
ticks_since_activation: 5400 # ~90 minutes with zero player investigation
effects:
- type: faction_reaction
faction: the-ring
reaction: neutral
description: "The ring closed operations without incident. No record of compromise."
@@ -1,718 +0,0 @@
# Drama Module Schema — Tier 1 Content (D-023)
# YAML expression of JSON Schema 2020-12
# Validated against this schema: server/content/modules/tier1/*.yaml
#
# Ownership:
# Dramatic structure (this file): Paula
# YAML validation tooling / serde structs: Gestalt / Tyre
# Authoring ergonomics review: Mellanie
#
# See: docs/design/tier1-module-authoring.md for field-by-field guide.
$schema: "https://json-schema.org/draft/2020-12/schema"
$id: "drama_module.schema.yaml"
title: "Tier 1 Drama Module"
description: >
A hand-authored drama module drawn from the pool at game start.
The storyteller activates one or more modules per playthrough based on
entry conditions, then fires events and detects outcomes. Tier 1 modules
are the conspiracy layer of D-023 — authored, optional, relocatable.
type: object
required:
- module_id
- display_name
- version
- tier
- pool
- entry_conditions
- npc_requirements
- events
- outcomes
additionalProperties: false
properties:
# ── IDENTITY ────────────────────────────────────────────────────────────────
module_id:
type: string
pattern: "^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$"
description: >
Stable unique slug. Format: {name}_v{major}_{minor}.
Never reuse IDs. Increment version on breaking structural changes.
Example: "smuggling_ring_v0_1"
display_name:
type: string
minLength: 1
description: "Human-readable title shown in dev/debug tooling."
version:
type: string
pattern: "^[0-9]+\\.[0-9]+$"
description: "Authoring version. Semantic: major.minor."
tier:
type: integer
const: 1
description: "Always 1 for Tier 1 drama modules."
description:
type: string
description: "One-paragraph authoring summary. Not shown in-game."
# ── POOL METADATA ─────────────────────────────────────────────────────────
# Controls how the storyteller includes this module in the per-playthrough pool.
pool:
type: object
required:
- weight
additionalProperties: false
description: "How the storyteller samples this module from the pool."
properties:
weight:
type: integer
minimum: 1
maximum: 10
description: >
Relative selection probability (110). Higher = more likely to be
included in a given playthrough's active module set. Default: 5.
compatible_districts:
type: array
items:
type: string
description: >
District slugs where this module can activate, or omit for "any".
Example: ["sova-transit"]
incompatible_with:
type: array
items:
type: string
pattern: "^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$"
description: >
Module IDs that cannot run concurrently with this one.
The storyteller will not activate both in the same playthrough.
max_concurrent:
type: integer
minimum: 1
default: 1
description: >
Maximum simultaneous active instances. Almost always 1.
Set to 2+ only for modules designed to stack (rare).
# ── ENTRY CONDITIONS ──────────────────────────────────────────────────────
# All listed conditions must be true for the module to become activatable.
# The storyteller checks these each tick after min_play_ticks.
entry_conditions:
type: object
required:
- activation
additionalProperties: false
description: >
World-state prerequisites. The storyteller activates the module when
ALL conditions are satisfied AND the activation trigger fires.
properties:
world_state:
type: array
items:
$ref: "#/$defs/world_state_condition"
description: "World-state conditions checked each tick."
player:
type: array
items:
$ref: "#/$defs/player_condition"
description: >
Optional player-state conditions. Module can activate without
player engagement — these gate on player-specific world state,
not on player noticing the module.
activation:
type: object
required:
- trigger
additionalProperties: false
description: "How and when activation is evaluated."
properties:
trigger:
type: string
enum:
- proximity # Player comes within range of a key NPC/location
- storyteller_push # Storyteller activates on its own schedule
- player_action # Player performs a specific action
description: "What pushes the module from 'eligible' to 'active'."
min_play_ticks:
type: integer
minimum: 0
description: >
Minimum ticks of game time before this module can activate.
Enforces D-027 success criterion #1: 30 minutes of daily-life
breathing room. At 1 tick/second, 30 minutes ≈ 1800 ticks.
proximity_location:
type: string
description: >
Required when trigger = proximity. Location slug the player
must enter or approach. Example: "maintenance-corridors"
proximity_radius_tiles:
type: integer
minimum: 1
description: >
Required when trigger = proximity. Tile radius around the
location's anchor point.
player_action_required:
type: string
description: >
Required when trigger = player_action. The action that fires
activation. Example: "examine:cargo-manifest"
# ── NPC REQUIREMENTS ──────────────────────────────────────────────────────
# NPC slots this module requires. Each slot is filled at module load time.
# Named bindings resolve to specific authored NPCs; generated bindings
# are filled from the district's generated NPC pool.
npc_requirements:
type: array
minItems: 1
items:
$ref: "#/$defs/npc_slot"
description: >
Module-internal NPC role slots. Roles are referenced by slug throughout
the rest of this document. Hand-authored NPCs use named bindings.
Generated NPCs use constraint-based bindings.
# ── EVENTS ────────────────────────────────────────────────────────────────
# Ordered sequences and unordered event pools the storyteller can fire.
# Sequences are narrative beats in a defined order.
# Pools are events the storyteller can fire in any order when conditions are met.
events:
type: object
additionalProperties: false
description: "Event sequences and pools the storyteller manages."
properties:
sequences:
type: array
items:
$ref: "#/$defs/event_sequence"
description: >
Ordered event sequences. Steps fire in order; the next step
becomes eligible only after the previous one fires.
pools:
type: array
items:
$ref: "#/$defs/event_pool"
description: >
Unordered event pools. The storyteller may fire any eligible
event in the pool when its trigger conditions are met.
# ── OUTCOMES ──────────────────────────────────────────────────────────────
# Resolution states the module can reach. The storyteller checks outcome
# conditions each tick. First matching outcome wins.
# Every module MUST include an expiry outcome.
outcomes:
type: array
minItems: 1
items:
$ref: "#/$defs/outcome"
description: >
Terminal and transitional resolution states. The storyteller checks
these each tick and applies the first matching outcome.
# ── AUTHORING NOTES ───────────────────────────────────────────────────────
notes:
type: string
description: "Authoring-only field. Design rationale, cross-references. Ignored at load time."
dual_lens:
type: object
additionalProperties: false
description: "Authoring-only. How smuggler vs detective experience this module."
properties:
smuggler: { type: string }
detective: { type: string }
# ── SHARED DEFINITIONS ────────────────────────────────────────────────────────
$defs:
# World-state condition types
world_state_condition:
type: object
required:
- type
description: "A single world-state prerequisite for module activation."
oneOf:
- # NPC with the given module role is present in the district
properties:
type: { type: string, const: "npc_present" }
role: { type: string, description: "Module-internal NPC role slug." }
required: [type, role]
additionalProperties: false
- # A specific location is accessible to the player
properties:
type: { type: string, const: "location_accessible" }
location: { type: string, description: "Location slug." }
required: [type, location]
additionalProperties: false
- # Player has NOT yet discovered a specific fact
properties:
type: { type: string, const: "fact_not_known" }
fact_id: { type: string, description: "Fact ID from global/knowledge/." }
required: [type, fact_id]
additionalProperties: false
- # No other Tier 1 module of the given ID is currently active
properties:
type: { type: string, const: "no_active_module" }
module_id: { type: string }
required: [type, module_id]
additionalProperties: false
- # A named fact IS known (module requires precondition awareness)
properties:
type: { type: string, const: "fact_known" }
fact_id: { type: string }
known_by: { type: string, enum: [smuggler, detective, any] }
required: [type, fact_id]
additionalProperties: false
# Player-state condition types
player_condition:
type: object
required:
- type
description: "A player-state prerequisite."
oneOf:
- # Player has reached minimum relationship threshold with an NPC
properties:
type: { type: string, const: "relationship_threshold" }
npc_role: { type: string, description: "Module-internal NPC role." }
min_state:
type: string
enum: [stranger, known, friendly]
description: "Minimum RelationshipState required."
required: [type, npc_role, min_state]
additionalProperties: false
- # Minimum game ticks elapsed
properties:
type: { type: string, const: "min_ticks" }
ticks: { type: integer, minimum: 0 }
required: [type, ticks]
additionalProperties: false
# NPC slot definition
npc_slot:
type: object
required:
- role
- binding
additionalProperties: false
description: >
One NPC slot in the module. Named binding = specific authored NPC.
Generated binding = constraint-matched NPC from district pool.
properties:
role:
type: string
pattern: "^[a-z][a-z0-9-]*$"
description: >
Module-internal role slug. Referenced in events, outcomes, and
triggers. Example: "ring-leader", "ring-member-exiting", "witness"
display_hint:
type: string
description: "Authoring note. What this role is narratively."
binding:
type: string
enum: [named, generated]
description: >
named = resolves to a specific authored NPC (use named_npc).
generated = any district NPC matching the axis constraints.
named_npc:
type: string
pattern: "^npc:[a-z][a-z0-9-]*$"
description: >
Required when binding = named. Short-form NPC canonical ID.
Example: "npc:kael-davan"
axes:
type: array
items:
$ref: "#/$defs/axis_constraint"
description: >
Required when binding = generated. The NPC must satisfy all
listed axis constraints to fill this slot.
must_have_pattern:
type: string
enum: [FRIEND, MIRROR, ANCHOR, GHOST, CATALYST, THRESHOLD, REMNANT, SYSTEM, NOBODY]
description: "Optional: NPC must have this pattern (D-024)."
must_have_motivation:
type: string
enum: [HANDLER, WITNESS, TURNCOAT, CIVILIAN, OPERATOR, SKEPTIC]
description: "Optional: NPC must have this motivation (D-024)."
is_optional:
type: boolean
default: false
description: >
If true, the module can activate without this slot filled.
Optional slots produce degraded but valid module runs.
# NPC axis constraint (used in generated bindings)
axis_constraint:
type: object
required:
- axis
- constraint
additionalProperties: false
properties:
axis:
type: string
enum: [want, secret, relationships, tolerance, routine, information, contentment, personality, tells, skills]
description: "Which NPC axis to constrain (D-024)."
constraint:
type: string
description: >
Constraint expression. Freeform string interpreted by the storyteller.
Convention: "has_{value}", "min_{N}", "not_{value}".
Examples: "has_major_secret", "min_contentment_-3", "not_combat_trained"
# Event sequence
event_sequence:
type: object
required:
- sequence_id
- steps
additionalProperties: false
description: "An ordered sequence of narrative events."
properties:
sequence_id:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
label:
type: string
description:
type: string
steps:
type: array
minItems: 1
items:
$ref: "#/$defs/event_step"
# Unordered event pool
event_pool:
type: object
required:
- pool_id
- events
additionalProperties: false
properties:
pool_id:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
label:
type: string
description:
type: string
events:
type: array
minItems: 1
items:
$ref: "#/$defs/event_step"
# Individual event step
event_step:
type: object
required:
- event_id
- triggers
additionalProperties: false
description: "A single storyteller-managed event with triggers and effects."
properties:
event_id:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
description: "Unique within this module. Used in outcome conditions."
label:
type: string
description:
type: string
description: "What happens narratively when this event fires."
triggers:
type: array
minItems: 1
items:
$ref: "#/$defs/event_trigger"
description: "ANY trigger being true fires this event."
effects:
type: array
items:
$ref: "#/$defs/event_effect"
description: "What changes in the world when this event fires."
once:
type: boolean
default: true
description: "If true, fires only once. If false, may repeat when conditions reset."
sets_flag:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
description: "Module-internal flag set when this event fires. Queryable in outcomes."
# Event trigger conditions
event_trigger:
type: object
required:
- type
description: "A condition that causes an event to fire."
oneOf:
- # Ticks elapsed since module activation
properties:
type: { type: string, const: "ticks_since_activation" }
ticks: { type: integer, minimum: 1 }
required: [type, ticks]
additionalProperties: false
- # Ticks elapsed since a previous event fired
properties:
type: { type: string, const: "ticks_since_event" }
after_event: { type: string }
ticks: { type: integer, minimum: 1 }
required: [type, after_event, ticks]
additionalProperties: false
- # Player enters a location or comes within range of NPC
properties:
type: { type: string, const: "player_proximity" }
target_type: { type: string, enum: [location, npc_role] }
target: { type: string }
radius_tiles: { type: integer, minimum: 1 }
required: [type, target_type, target]
additionalProperties: false
- # Player performs an interaction
properties:
type: { type: string, const: "player_action" }
action:
type: string
enum: [talk, examine, confront, follow, observe]
target_role: { type: string, description: "Module NPC role or location slug." }
required: [type, action, target_role]
additionalProperties: false
- # Player has discovered a specific fact
properties:
type: { type: string, const: "fact_known_by_player" }
fact_id: { type: string }
required: [type, fact_id]
additionalProperties: false
- # A module flag has been set
properties:
type: { type: string, const: "flag_set" }
flag: { type: string }
required: [type, flag]
additionalProperties: false
- # NPC enters a specific mood state
properties:
type: { type: string, const: "npc_mood" }
npc_role: { type: string }
mood:
type: string
enum: [anxious, frustrated, content, suspicious, warm, hostile, relieved, focused]
required: [type, npc_role, mood]
additionalProperties: false
# Event effects
event_effect:
type: object
required:
- type
description: "A world change triggered by an event."
oneOf:
- # NPC deviates from their normal routine
properties:
type: { type: string, const: "npc_routine_deviation" }
npc_role: { type: string }
description: { type: string, description: "What the deviation looks like." }
duration_ticks: { type: integer }
required: [type, npc_role, description]
additionalProperties: false
- # A fact becomes discoverable (moves to Rumoured confidence)
properties:
type: { type: string, const: "fact_becomes_discoverable" }
fact_id: { type: string }
discoverable_by:
type: string
enum: [smuggler, detective, any]
discovery_method:
type: string
description: "How the player can discover this. Authoring note."
required: [type, fact_id, discoverable_by]
additionalProperties: false
- # NPC tell behavior becomes more pronounced
properties:
type: { type: string, const: "tell_intensify" }
npc_role: { type: string }
description: { type: string }
required: [type, npc_role]
additionalProperties: false
- # A module-internal flag is set
properties:
type: { type: string, const: "flag_set" }
flag: { type: string, pattern: "^[a-z][a-z0-9_-]*$" }
required: [type, flag]
additionalProperties: false
- # Something changes about a location
properties:
type: { type: string, const: "location_state" }
location: { type: string }
description: { type: string }
required: [type, location, description]
additionalProperties: false
- # NPC's access to information changes
properties:
type: { type: string, const: "npc_knowledge_update" }
npc_role: { type: string }
fact_id: { type: string }
description: { type: string }
required: [type, npc_role, fact_id]
additionalProperties: false
# Module outcome definition
outcome:
type: object
required:
- outcome_id
- label
- is_terminal
additionalProperties: false
description: >
A resolution state the module can reach. Conditions are checked each tick.
The first matching outcome is applied. is_terminal = true ends the module.
properties:
outcome_id:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
label:
type: string
description:
type: string
description: "What this outcome means narratively."
is_terminal:
type: boolean
description: "If true, this outcome ends the module permanently."
is_expiry:
type: boolean
default: false
description: >
If true, this is the quiet-exit outcome when the player never engages.
Every module must include exactly one expiry outcome.
conditions:
type: object
additionalProperties: false
description: "ALL conditions must be true to reach this outcome."
properties:
facts_known:
type: array
items: { type: string }
description: "Player must know all these facts."
facts_not_known:
type: array
items: { type: string }
description: "Player must NOT know any of these facts."
flags_set:
type: array
items: { type: string }
description: "All these module flags must be set."
flags_not_set:
type: array
items: { type: string }
description: "None of these module flags may be set."
events_fired:
type: array
items: { type: string }
description: "All these events must have fired."
ticks_since_activation:
type: integer
description: "Module has been active for at least this many ticks."
effects:
type: array
items:
$ref: "#/$defs/outcome_effect"
description: "Effects applied when this outcome is reached."
# Outcome-level effects (broader scope than event effects)
outcome_effect:
type: object
required:
- type
oneOf:
- # NPC disposition toward player changes
properties:
type: { type: string, const: "npc_disposition" }
npc_role: { type: string }
shift:
type: string
enum: [hostile, suspicious, neutral, friendly]
description: { type: string }
required: [type, npc_role, shift]
additionalProperties: false
- # Faction reaction
properties:
type: { type: string, const: "faction_reaction" }
faction: { type: string }
reaction:
type: string
enum: [hostile, suspicious, neutral, friendly, grateful]
description: { type: string }
required: [type, faction, reaction]
additionalProperties: false
- # Location becomes restricted or opens up
properties:
type: { type: string, const: "location_access_change" }
location: { type: string }
change:
type: string
enum: [restricted, locked, open]
description: { type: string }
required: [type, location, change]
additionalProperties: false
- # A fact is now permanently known/unknown
properties:
type: { type: string, const: "fact_state" }
fact_id: { type: string }
state:
type: string
enum: [known, hidden, destroyed]
description: { type: string }
required: [type, fact_id, state]
additionalProperties: false
- # NPC leaves the district or changes role
properties:
type: { type: string, const: "npc_exit" }
npc_role: { type: string }
description: { type: string }
required: [type, npc_role]
additionalProperties: false
Binary file not shown.
+49 -15
View File
@@ -20,7 +20,11 @@ use crate::knowledge::{resolve_culture, CultureResolver};
///
/// Built once during `BookmarkPlugin::build`, read-only at runtime.
/// `BTreeMap` for deterministic iteration (D-010 principle 4, D-041).
#[derive(Resource, Debug, Default)]
///
/// `Clone` is required by `BookmarkPlugin::build` which moves the registry into
/// the Bevy `App` via `insert_resource` while retaining the value from `self`
/// (#862 injection pattern).
#[derive(Resource, Debug, Default, Clone)]
pub struct BookmarkRegistry {
entries: BTreeMap<String, BookmarkDefinition>,
}
@@ -88,18 +92,15 @@ impl BookmarkRegistry {
}
/// 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.
/// Populated when `ConfirmBookmark` is processed. `None` fields during the
/// character-creation phase (before confirm) and in a fresh session.
///
/// Serialized into `SaveStateV1.selected_bookmark` (#863) so that a loaded
/// game remembers which bookmark and starting location were chosen.
///
/// Downstream systems (apartment generator, skill seeder) read from this resource.
// TODO(sprint-37): serialize — see #863
#[derive(Resource, Debug, Clone, Default)]
#[derive(Resource, Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct SelectedBookmark {
pub bookmark_id: Option<String>,
pub starting_location_id: Option<String>,
@@ -109,14 +110,47 @@ pub struct SelectedBookmark {
///
/// Registers `BookmarkRegistry`, `SelectedBookmark`, and the startup system that
/// stages the initial catalog in `SnapshotBuffer` for tick-0 delivery.
pub struct BookmarkPlugin;
///
/// **Default construction** uses the canonical tycoon registry:
/// ```ignore
/// app.add_plugins(BookmarkPlugin::default());
/// ```
///
/// **Injection** substitutes a custom registry (for tests and future TOML loading):
/// ```ignore
/// let mut registry = BookmarkRegistry::default();
/// // populate ...
/// app.add_plugins(BookmarkPlugin::new(registry));
/// ```
pub struct BookmarkPlugin {
registry: BookmarkRegistry,
}
impl BookmarkPlugin {
/// Create a plugin with an injected registry.
///
/// Useful in tests (inject a minimal registry with fixed entries) and
/// for future TOML loading (caller constructs the registry from disk,
/// then hands it to the plugin).
pub fn new(registry: BookmarkRegistry) -> Self {
Self { registry }
}
}
impl Default for BookmarkPlugin {
/// Default plugin pre-populates the canonical tycoon registry via
/// `register_default_bookmarks`. Equivalent to the v0.2 behaviour before
/// injection was introduced.
fn default() -> Self {
let mut registry = BookmarkRegistry::default();
register_default_bookmarks(&mut registry);
Self { registry }
}
}
impl Plugin for BookmarkPlugin {
fn build(&self, app: &mut App) {
let mut registry = BookmarkRegistry::default();
register_default_bookmarks(&mut registry);
app.insert_resource(registry)
app.insert_resource(self.registry.clone())
.init_resource::<SelectedBookmark>()
.add_systems(Startup, prime_initial_catalog);
+5 -5
View File
@@ -96,17 +96,17 @@ impl SimBridge for LocalBridge {
}
fn send_handshake(&self) -> Result<(), BridgeError> {
use super::types::{HandshakeMessage, PROTOCOL_VERSION};
let msg = HandshakeMessage {
protocol_version: PROTOCOL_VERSION,
};
// D-192: HandshakeMessage carries no version. Send an empty marker so the
// client knows to begin the startup sequence (send StartupMessage next).
use super::types::HandshakeMessage;
let msg = HandshakeMessage {};
let payload = rmp_serde::to_vec_named(&msg)?;
let mut writer = self
.writer
.lock()
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
write_framed(writer.get_mut(), &payload)?;
tracing::info!("sent handshake: protocol_version={}", PROTOCOL_VERSION);
tracing::info!("sent handshake");
Ok(())
}
+5 -5
View File
@@ -158,10 +158,10 @@ impl SimBridge for TcpBridge {
}
fn send_handshake(&self) -> Result<(), BridgeError> {
use super::types::{HandshakeMessage, PROTOCOL_VERSION};
let msg = HandshakeMessage {
protocol_version: PROTOCOL_VERSION,
};
// D-192: HandshakeMessage carries no version. Send an empty marker so the
// client knows to begin the startup sequence (send StartupMessage next).
use super::types::HandshakeMessage;
let msg = HandshakeMessage {};
let payload = rmp_serde::to_vec_named(&msg)?;
let mut writer = self
.writer
@@ -173,7 +173,7 @@ impl SimBridge for TcpBridge {
let result = write_framed(stream, &payload);
stream.set_nonblocking(true).map_err(BridgeError::Io)?;
result?;
tracing::info!("sent handshake: protocol_version={}", PROTOCOL_VERSION);
tracing::info!("sent handshake");
Ok(())
}
-2
View File
@@ -220,7 +220,6 @@ mod tests {
fn make_snapshot() -> ObserverSnapshot {
ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 42,
game_time: GameTime {
day: 0,
@@ -427,7 +426,6 @@ mod tests {
#[test]
fn empty_snapshot_no_panic() {
let snap = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 0,
game_time: GameTime {
day: 0,
+27 -105
View File
@@ -10,34 +10,24 @@ pub use crate::knowledge::types::{
};
pub use crate::simulation::time::{DayPhase, TickRate};
/// Wire protocol version for ObserverSnapshot.
///
/// Versioning strategy: flat struct + serde defaults for field evolution.
/// Client and server are co-versioned (subprocess IPC per D-020), so protocol
/// 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 = 23;
/// Handshake message sent as the very first framed message after connection (#555).
/// Client reads this before entering the normal tick loop and validates
/// `protocol_version` against its own `PROTOCOL_VERSION` constant.
/// Client reads this before entering the normal tick loop, then sends StartupMessage.
/// Wire format: MessagePack, same 4-byte length-prefixed framing as ObserverSnapshot.
///
/// No version field — D-192 dropped the lockstep version check. Client and server
/// are always co-shipped (D-005); genuine schema drift surfaces as a downstream
/// MessagePack missing-field error rather than an eager handshake rejection.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HandshakeMessage {
/// Must match client's PROTOCOL_VERSION or the client should disconnect.
pub protocol_version: u8,
}
pub struct HandshakeMessage {}
/// Startup message sent by the client after receiving HandshakeMessage (#175).
/// Contains the world seed for deterministic simulation (D-010, D-029).
///
/// Protocol flow:
/// Protocol flow (D-192: no version field, no validation step):
/// 1. Server sends HandshakeMessage (server → client)
/// 2. Client validates protocol_version
/// 3. Client sends StartupMessage (client → server)
/// 4. Server reads world_seed, initializes SimRng
/// 5. Normal tick loop begins
/// 2. Client sends StartupMessage (client → server)
/// 3. Server reads world_seed, initializes SimRng
/// 4. Normal tick loop begins
///
/// Wire format: MessagePack, same 4-byte length-prefixed framing.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -46,12 +36,6 @@ pub struct StartupMessage {
/// Generated by SessionManager.new_game() on the client.
/// Same seed → same EntanglementConfig → same NPC population (D-029).
pub world_seed: u64,
/// Character archetype selected by the player (#587).
/// Gates monologue pool selection, verb labels, and examine text.
/// Defaults to Detective for backward compatibility (old clients
/// that omit this field).
#[serde(default)]
pub character_archetype: CharacterArchetype,
}
/// The ONLY data structure crossing the client-server boundary (D-020)
@@ -79,18 +63,25 @@ pub struct StartupMessage {
/// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick),
/// sim_errors (#85, structured error reporting to client).
/// v18 adds: debug_response (#580, debug console server — command/response wire).
/// v19 adds: character_archetype on StartupMessage (#587), current_ticker (#591).
/// v19 adds: current_ticker (#591). (character_archetype on StartupMessage was
/// added in #587 and removed in Sprint 37 — see "Sprint 37 wire-format shifts" below.)
/// 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).
/// Sprint 37 wire-format shifts (no snapshot-version bump needed — PROTOCOL_VERSION lockstep gone):
/// - D-192 (#874): `PROTOCOL_VERSION` field removed from `HandshakeMessage`. The
/// handshake is now an empty marker ("server ready"); there is no negotiated
/// version field on the wire. Genuine schema drift surfaces as MessagePack
/// missing-field errors downstream — that is the intended signal per D-192.
/// - #878 (cascade cleanup): `character_archetype` field removed from
/// `StartupMessage`. This protocol break rides the D-192 drop.
///
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
/// Protocol version for forward compatibility. See [`PROTOCOL_VERSION`].
pub version: u8,
/// Simulation tick when this snapshot was produced
pub tick: u64,
/// Game time data for client HUD display (D-031)
@@ -495,28 +486,6 @@ pub enum ObjectType {
Furniture,
}
/// Character archetype for Phase 2 verb filtering (#422) and monologue pool
/// selection. Determines how the character perceives and labels interactions.
/// v0.1: Smuggler and Detective (the two playable characters).
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum CharacterArchetype {
/// Smuggler character — sees Move/Stash on containers, physical manipulation verbs
Smuggler,
/// Detective character — sees Scan/Flag on containers, investigation verbs
#[default]
Detective,
}
impl CharacterArchetype {
/// String key for monologue pool filtering (#587).
pub fn as_monologue_key(&self) -> &'static str {
match self {
Self::Smuggler => "smuggler",
Self::Detective => "detective",
}
}
}
/// Semantic player actions, not raw key events (D-020)
/// Timestamped for deterministic processing
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -1105,73 +1074,28 @@ mod tests {
#[test]
fn handshake_message_roundtrip() {
let msg = HandshakeMessage {
protocol_version: PROTOCOL_VERSION,
};
// D-192: HandshakeMessage carries no version field; roundtrip verifies
// the empty struct serialises and deserialises cleanly.
let msg = HandshakeMessage {};
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded, msg);
assert_eq!(decoded.protocol_version, PROTOCOL_VERSION);
}
#[test]
fn handshake_message_rejects_wrong_version() {
let msg = HandshakeMessage {
protocol_version: PROTOCOL_VERSION,
};
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
// Simulate client-side validation: version mismatch should be detectable
let wrong_version = PROTOCOL_VERSION.wrapping_add(1);
assert_ne!(decoded.protocol_version, wrong_version);
}
#[test]
fn startup_message_roundtrip() {
let msg = StartupMessage {
world_seed: 0xDEADBEEF,
character_archetype: CharacterArchetype::Detective,
};
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded, msg);
assert_eq!(decoded.world_seed, 0xDEADBEEF);
assert_eq!(decoded.character_archetype, CharacterArchetype::Detective);
}
#[test]
fn startup_message_smuggler_roundtrip() {
let msg = StartupMessage {
world_seed: 42,
character_archetype: CharacterArchetype::Smuggler,
};
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded, msg);
assert_eq!(decoded.character_archetype, CharacterArchetype::Smuggler);
}
#[test]
fn startup_message_missing_archetype_defaults_to_detective() {
// Simulate an old client that sends only world_seed (no character_archetype).
// serde(default) on StartupMessage.character_archetype should default to Detective.
#[derive(Serialize)]
struct OldStartupMessage {
world_seed: u64,
}
let old = OldStartupMessage { world_seed: 99 };
let bytes = rmp_serde::to_vec_named(&old).expect("serialize");
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.world_seed, 99);
assert_eq!(decoded.character_archetype, CharacterArchetype::Detective);
}
#[test]
fn startup_message_zero_seed() {
let msg = StartupMessage {
world_seed: 0,
character_archetype: CharacterArchetype::default(),
};
let msg = StartupMessage { world_seed: 0 };
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.world_seed, 0);
@@ -1181,7 +1105,6 @@ mod tests {
fn startup_message_max_seed() {
let msg = StartupMessage {
world_seed: u64::MAX,
character_archetype: CharacterArchetype::default(),
};
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
@@ -1191,10 +1114,9 @@ mod tests {
#[test]
fn handshake_is_distinct_from_snapshot() {
// HandshakeMessage and ObserverSnapshot are different types on the wire.
// A HandshakeMessage should NOT deserialize as an ObserverSnapshot.
let msg = HandshakeMessage {
protocol_version: PROTOCOL_VERSION,
};
// A HandshakeMessage (empty map) must NOT deserialize as an ObserverSnapshot
// because ObserverSnapshot has required fields (tick, game_time, etc.).
let msg = HandshakeMessage {};
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let result = rmp_serde::from_slice::<ObserverSnapshot>(&bytes);
assert!(
+9 -28
View File
@@ -153,7 +153,7 @@ 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);
app.add_plugins(settled_reach_server::bookmark::BookmarkPlugin::default());
// Initialize culture resolver (#679, D-128).
// systems.db is shipped read-only alongside the binary.
@@ -216,20 +216,17 @@ fn main() {
),
);
// Character archetype from client's StartupMessage (#587).
let archetype = startup.character_archetype;
// Gauntlet test world for --test-mode, proof room for normal mode.
if test_mode {
#[cfg(feature = "gauntlet")]
settled_reach_server::test_world::setup_gauntlet(&mut app, archetype);
settled_reach_server::test_world::setup_gauntlet(&mut app);
#[cfg(not(feature = "gauntlet"))]
{
eprintln!("--test-mode requires the 'gauntlet' feature");
std::process::exit(1);
}
} else {
setup_proof_room(&mut app, archetype, seed);
setup_proof_room(&mut app, seed);
}
tracing::info!(
@@ -325,7 +322,6 @@ fn send_panic_error(app: &App, panic_msg: &str) {
// Build a minimal snapshot carrying the panic error
let snapshot = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick,
game_time: GameTime {
day: 0,
@@ -394,7 +390,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.add_plugins(settled_reach_server::bookmark::BookmarkPlugin::default());
app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(0));
// Access Schedules resource directly — schedules are populated by plugins
@@ -422,11 +418,7 @@ fn dump_schedule_graph() {
/// Proof room: 32x32 map, wall at (16,14), player at (16,16), 3 NPCs.
/// Extracted from the original inline setup for reuse by both test-mode and normal mode.
fn setup_proof_room(
app: &mut App,
archetype: settled_reach_server::bridge::types::CharacterArchetype,
world_seed: u64,
) {
fn setup_proof_room(app: &mut App, world_seed: u64) {
use settled_reach_server::knowledge::registry::EntityRegistry;
use settled_reach_server::knowledge::KnowledgeGraph;
use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph};
@@ -459,19 +451,9 @@ fn setup_proof_room(
let mut registry = EntityRegistry::new(0);
// Player at (16,16) — archetype from StartupMessage (#587, D-053)
let profile = match archetype {
settled_reach_server::bridge::types::CharacterArchetype::Smuggler => {
MovementProfile::smuggler()
}
settled_reach_server::bridge::types::CharacterArchetype::Detective => {
MovementProfile::detective()
}
};
let monologue_state = MonologueState {
character: archetype.as_monologue_key().to_string(),
..Default::default()
};
// Player at (16,16). Archetype-specific spawn logic was removed in Sprint 37
// (D-032 purge); per-culture/per-role voice is reintroduced in Phase 6.
let profile = MovementProfile::default();
let player = app
.world_mut()
.spawn((
@@ -480,12 +462,11 @@ fn setup_proof_room(
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
monologue_state,
MonologueState::default(),
MonologueBuffer::default(),
SprintAnomalyQueue::default(),
CognitiveDelay::default(),
ListeningFocus::new(TilePosition::new(16, 16, 0)),
archetype,
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
+2 -56
View File
@@ -78,7 +78,6 @@ pub fn compute_observer_snapshot(
&mut NearbyInteractionBuffer,
&mut MonologueBuffer,
Option<&Stance>,
Option<&CharacterArchetype>,
Option<&mut SprintAnomalyQueue>,
Option<&CognitiveDelay>,
Option<&mut DialogueResponseBuffer>,
@@ -114,7 +113,6 @@ pub fn compute_observer_snapshot(
mut interaction_buffer,
mut monologue_buffer,
stance_opt,
archetype_opt,
mut anomaly_queue_opt,
cognitive_delay_opt,
mut dialogue_response_opt,
@@ -135,8 +133,6 @@ pub fn compute_observer_snapshot(
.map(|f| f.0)
.unwrap_or(FacingDirection::default());
let archetype = archetype_opt.copied().unwrap_or_default();
// Collect player inventory (D-065 info boundary: only own items)
let player_inventory = registry
.to_stable(observer_entity)
@@ -197,7 +193,7 @@ pub fn compute_observer_snapshot(
// Take interactions and apply Phase 2 verb filter (D-057, #422)
let mut nearby_interactions = interaction_buffer.take();
apply_phase2_verb_filter(&mut nearby_interactions, observer_kg, archetype);
apply_phase2_verb_filter(&mut nearby_interactions, observer_kg);
tracing::trace!(
"compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}",
@@ -453,7 +449,6 @@ pub fn compute_observer_snapshot(
};
buffer.snapshot = Some(ObserverSnapshot {
version: crate::bridge::types::PROTOCOL_VERSION,
tick: time.tick,
game_time,
player_facing: facing,
@@ -654,17 +649,12 @@ fn collect_remembered_entities(
/// 1. POI priority flips (D-060) — ExamineNpc above Talk for POI entities
/// 2. Confront injection — adds Confront verb for NPCs when KnowsDetails+
/// 3. Contradiction marking — sets contradicted flag when entity knowledge is Contradicted
/// 4. Archetype label relabeling — smuggler/detective see different labels for same verb
///
/// Phase boundary: Phase 1 (interaction.rs) determines verb availability from
/// ObjectType + proximity. Phase 2 (here) reads the observer's KnowledgeGraph
/// to filter, augment, and relabel. This separation keeps D-010 principle 1
/// (info boundary) clean — simulation doesn't know what the observer knows.
fn apply_phase2_verb_filter(
interactions: &mut [NearbyInteraction],
observer_kg: &KnowledgeGraph,
archetype: CharacterArchetype,
) {
fn apply_phase2_verb_filter(interactions: &mut [NearbyInteraction], observer_kg: &KnowledgeGraph) {
for interaction in interactions.iter_mut() {
let stable_id = StableId(interaction.entity_id);
let knowledge = observer_kg.entity_knowledge(&stable_id);
@@ -713,18 +703,6 @@ fn apply_phase2_verb_filter(
}
}
// --- Archetype label relabeling ---
// Phase 2 swaps verb labels based on character archetype.
// The VerbKind stays the same (same handler), only the display label changes.
// This implements D-057: "Character differentiation via Phase 2 observer
// filter, not separate verb systems."
for verb in &mut interaction.verbs {
if let Some(label) = archetype_verb_label(archetype, interaction.object_type, verb.kind)
{
verb.label = label.into();
}
}
// Re-sort after priority changes and verb additions
interaction
.verbs
@@ -732,37 +710,5 @@ fn apply_phase2_verb_filter(
}
}
/// Archetype-specific verb label overrides (#422, D-057).
///
/// Returns a replacement label for the given (archetype, object_type, verb_kind)
/// combination, or None to keep the Phase 1 default label.
///
/// v0.1: Container verbs differ by archetype. Other object types keep defaults.
/// Add match arms here for future archetype-specific labels.
fn archetype_verb_label(
archetype: CharacterArchetype,
object_type: Option<ObjectType>,
kind: VerbKind,
) -> Option<&'static str> {
match (archetype, object_type, kind) {
// Smuggler: Container verbs — physical manipulation vocabulary
(CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Open) => Some("Move"),
(CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Search) => {
Some("Stash")
}
// Detective: Container verbs — investigation vocabulary
(CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Open) => {
Some("Scan")
}
(CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Search) => {
Some("Flag")
}
// All other combinations: keep Phase 1 default label
_ => None,
}
}
#[cfg(test)]
mod tests;
+127 -170
View File
@@ -59,7 +59,6 @@ fn player_always_visible_in_snapshot() {
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(snapshot.entities.len(), 1);
assert!(matches!(snapshot.entities[0].kind, EntityKind::Player));
assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible);
@@ -679,10 +678,6 @@ fn snapshot_v6_fields_default_through_pipeline() {
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
assert_eq!(
snapshot.version, PROTOCOL_VERSION,
"should be current protocol version"
);
assert_eq!(
snapshot.player_stance,
MovementStance::Walk,
@@ -694,29 +689,6 @@ fn snapshot_v6_fields_default_through_pipeline() {
);
}
#[test]
fn snapshot_v6_version_is_protocol_version() {
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(
snapshot.version,
crate::bridge::types::PROTOCOL_VERSION,
"snapshot version must match PROTOCOL_VERSION constant"
);
}
// -----------------------------------------------------------------------
// Phase 2 verb filter tests (#422, D-057)
// -----------------------------------------------------------------------
@@ -967,148 +939,13 @@ fn phase2_no_contradiction_for_active_knowledge() {
);
}
#[test]
fn phase2_smuggler_relabels_container_verbs() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// Container at close range, north of player
let container = world
.spawn((
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
ObjectType::Container,
))
.id();
registry.register(container);
// Smuggler player
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
CharacterArchetype::Smuggler,
))
.id();
registry.register(player);
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
let interaction = &snapshot.nearby_interactions[0];
// Container at close range: Open→"Move", Search→"Stash", Observe stays "Observe"
let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open);
let search_verb = interaction
.verbs
.iter()
.find(|v| v.kind == VerbKind::Search);
let observe_verb = interaction
.verbs
.iter()
.find(|v| v.kind == VerbKind::Observe);
assert_eq!(open_verb.unwrap().label, "Move", "smuggler Open→Move");
assert_eq!(search_verb.unwrap().label, "Stash", "smuggler Search→Stash");
assert_eq!(observe_verb.unwrap().label, "Observe", "Observe unchanged");
}
#[test]
fn phase2_detective_relabels_container_verbs() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let container = world
.spawn((
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
ObjectType::Container,
))
.id();
registry.register(container);
// Detective player (explicit)
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
CharacterArchetype::Detective,
))
.id();
registry.register(player);
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
let interaction = &snapshot.nearby_interactions[0];
let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open);
let search_verb = interaction
.verbs
.iter()
.find(|v| v.kind == VerbKind::Search);
assert_eq!(open_verb.unwrap().label, "Scan", "detective Open→Scan");
assert_eq!(search_verb.unwrap().label, "Flag", "detective Search→Flag");
}
#[test]
fn phase2_default_archetype_is_detective() {
// When no CharacterArchetype component attached, defaults to Detective
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let container = world
.spawn((
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
ObjectType::Container,
))
.id();
registry.register(container);
// Player WITHOUT CharacterArchetype component
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
let interaction = &snapshot.nearby_interactions[0];
// Default = Detective labels
let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open);
assert_eq!(
open_verb.unwrap().label,
"Scan",
"default archetype should use Detective labels"
);
}
#[test]
fn phase2_non_container_keeps_default_labels() {
// Readable objects should keep their default labels regardless of archetype
// Readable objects keep their Phase 1 verb labels unchanged through Phase 2.
// Regression guard: archetype-verb differentiation is Phase 6 detail — not present
// in the current server per the development cascade (CLAUDE.md). D-057 superseded.
// If this test fails, a character-class relabelling branch was reintroduced before
// Phase 6 scope is confirmed by the team lead.
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
@@ -1129,7 +966,6 @@ fn phase2_non_container_keeps_default_labels() {
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
CharacterArchetype::Smuggler,
))
.id();
registry.register(player);
@@ -1145,7 +981,7 @@ fn phase2_non_container_keeps_default_labels() {
assert_eq!(
read_verb.unwrap().label,
"Read",
"Readable labels unchanged for smuggler"
"Readable labels unchanged"
);
}
@@ -2764,3 +2600,124 @@ fn tell_state_none_when_npc_has_no_derived_tell_component() {
"NPC without DerivedTellState component should have tell_state = None"
);
}
// -----------------------------------------------------------------------
// Regression: Phase 2 container verb labels are uniform (D-057 / #878)
// -----------------------------------------------------------------------
#[test]
fn phase2_container_verb_labels_uniform_regardless_of_player_state() {
// Regression guard (D-057 superseded, #878 cascade purge):
// apply_phase2_verb_filter no longer has an archetype branch that relabels
// container verbs. Labels must be the Phase-1 defaults — "Open", "Search",
// "Observe" — regardless of the observer's KnowledgeGraph contents or
// relationship state with other entities.
//
// This test FAILS if a character-class verb-label branch is reintroduced
// without a confirmed Phase 6 scope decision from the team lead.
// --- Trial A: empty KnowledgeGraph ---
{
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let container = world
.spawn((
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
ObjectType::Container,
))
.id();
registry.register(container);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
let labels: Vec<&str> = snapshot.nearby_interactions[0]
.verbs
.iter()
.map(|v| v.label.as_str())
.collect();
assert_eq!(
labels,
["Open", "Search", "Observe"],
"Trial A (empty KG): container verb labels must equal Phase-1 defaults"
);
}
// --- Trial B: KG with PersonOfInterest NPC nearby ---
// Player has a non-trivial knowledge state; container labels must still be
// the Phase-1 defaults — Phase 2 NPC-specific logic must not bleed into
// ObjectType::Container interactions.
{
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let container = world
.spawn((
TilePosition::new(16, 15, 0),
crate::simulation::interaction::Interactable,
ObjectType::Container,
))
.id();
registry.register(container);
// NPC out of close-range so Confront is not injected; still in KG as POI.
let npc = world
.spawn((
crate::npc::Npc,
TilePosition::new(16, 13, 0),
crate::simulation::interaction::Interactable,
))
.id();
let npc_sid = registry.register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 13, 0), 5);
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
world.insert_resource(registry);
run_full_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
let container_interaction = snapshot
.nearby_interactions
.iter()
.find(|i| i.object_type == Some(ObjectType::Container))
.expect("container interaction must be present");
let labels: Vec<&str> = container_interaction
.verbs
.iter()
.map(|v| v.label.as_str())
.collect();
assert_eq!(
labels,
["Open", "Search", "Observe"],
"Trial B (POI NPC in KG): container verb labels must equal Phase-1 defaults"
);
}
}
+48 -77
View File
@@ -14,7 +14,6 @@
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::bridge::types::CharacterArchetype;
use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
use crate::knowledge::EntityRegistry;
use crate::npc::mood::{MoodState, NpcMood};
@@ -37,11 +36,12 @@ pub struct ExamineRequest {
pub target: Entity,
}
/// Character-filtered examination result for snapshot delivery.
/// Examination result for snapshot delivery.
///
/// Content differs per CharacterArchetype:
/// Smuggler — physical threat read, cargo-handling posture, opportunity windows.
/// Detective — procedural tells, behavioral inconsistencies, stress indicators.
/// Phase 6 note: per-archetype text variants (smuggler/detective flavor) were
/// removed during the cascade cleanup. Text is a single unified "subject read"
/// until archetype differentiation is reintroduced per the culture-driven
/// voice system (D-121) in a later cascade phase.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExamineResultEvent {
/// Character-filtered observation text for client display.
@@ -106,12 +106,15 @@ fn has_trait(traits_opt: Option<&PersonalityTraits>, t: PersonalityTrait) -> boo
traits_opt.map(|p| p.traits.contains(&t)).unwrap_or(false)
}
/// Generate character-filtered examination text from NPC component state.
/// Generate examination text from NPC component state.
/// All logic is pure, deterministic, and integer-based (D-010).
///
/// Archetype-specific text variants were removed during the cascade cleanup
/// (D-032 / Sprint 37). Reintroduce per-culture voice when the cascade reaches
/// the Phase 6 character/NPC layer (see D-121).
pub fn generate_examine_text(
mood: NpcMood,
ratio: u8,
archetype: CharacterArchetype,
traits_opt: Option<&PersonalityTraits>,
) -> String {
let stress_label = match ratio {
@@ -123,53 +126,29 @@ pub fn generate_examine_text(
let mood_label = mood_word(mood);
match archetype {
CharacterArchetype::Smuggler => {
// Physical threat read + cargo opportunity window
let threat = if matches!(mood, NpcMood::Hostile | NpcMood::Suspicious) {
"Threat posture. Don't push it."
} else if has_trait(traits_opt, PersonalityTrait::Bold) {
"Confident bearing. Will push back if cornered."
} else if has_trait(traits_opt, PersonalityTrait::Cautious) {
"Nervous type. Predictable under pressure."
} else {
"No obvious threat read."
};
let tell = if has_trait(traits_opt, PersonalityTrait::Deceptive) {
"Controlled affect — practiced concealment."
} else if matches!(mood, NpcMood::Anxious | NpcMood::Frustrated) {
"Involuntary stress markers present."
} else if matches!(mood, NpcMood::Suspicious) {
"Scanning. Aware of being observed."
} else if matches!(mood, NpcMood::Hostile) {
"Threat posture. Aware of being observed."
} else {
"Baseline presentation."
};
let window = if ratio > 60 {
"Too distracted to track cargo movement."
} else if matches!(mood, NpcMood::Focused) {
"Paying close attention to this section."
} else {
"Standard patrol pattern. Window is there."
};
let read = if ratio > 60 {
"Under pressure — potential liability or asset."
} else if matches!(mood, NpcMood::Content | NpcMood::Warm) {
"Comfortable. Less guarded than usual."
} else if matches!(mood, NpcMood::Focused) {
"Paying close attention."
} else {
"Routine behavior pattern."
};
format!("Appears {mood_label}, {stress_label}. {threat} {window}")
}
CharacterArchetype::Detective => {
// Procedural tells + behavioral read
let tell = if has_trait(traits_opt, PersonalityTrait::Deceptive) {
"Controlled affect — practiced concealment."
} else if matches!(mood, NpcMood::Anxious | NpcMood::Frustrated) {
"Involuntary stress markers present."
} else if matches!(mood, NpcMood::Suspicious) {
"Scanning. Aware of being observed."
} else {
"Baseline presentation."
};
let read = if ratio > 60 {
"Under pressure — potential liability or asset."
} else if matches!(mood, NpcMood::Content | NpcMood::Warm) {
"Comfortable. Less guarded than usual."
} else {
"Routine behavior pattern."
};
format!("Subject: {mood_label}, {stress_label}. {tell} {read}")
}
}
format!("Subject: {mood_label}, {stress_label}. {tell} {read}")
}
// ---------------------------------------------------------------------------
@@ -196,7 +175,6 @@ pub fn process_examine_interaction(
Entity,
&TilePosition,
&ExamineRequest,
Option<&CharacterArchetype>,
&mut ExamineResultBuffer,
),
With<PlayerCharacter>,
@@ -212,14 +190,12 @@ pub fn process_examine_interaction(
>,
examine_text_query: Query<(&TilePosition, Option<&ExamineText>)>,
) {
let Ok((player_entity, player_pos, examine_req, archetype_opt, mut result_buffer)) =
player_query.single_mut()
let Ok((player_entity, player_pos, examine_req, mut result_buffer)) = player_query.single_mut()
else {
return;
};
let target = examine_req.target;
let archetype = archetype_opt.copied().unwrap_or_default();
// Try NPC examine path first
if let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) {
@@ -238,7 +214,7 @@ pub fn process_examine_interaction(
let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral);
let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0);
let text = generate_examine_text(mood, ratio, archetype, traits_opt);
let text = generate_examine_text(mood, ratio, traits_opt);
kg_events.push(KnowledgeEvent {
observer: player_entity,
@@ -323,8 +299,8 @@ mod tests {
}
#[test]
fn smuggler_hostile_npc_gives_threat_read() {
let text = generate_examine_text(NpcMood::Hostile, 20, CharacterArchetype::Smuggler, None);
fn hostile_npc_gives_threat_read() {
let text = generate_examine_text(NpcMood::Hostile, 20, None);
assert!(
text.contains("Threat posture"),
"expected threat read, got: {text}"
@@ -332,32 +308,27 @@ mod tests {
}
#[test]
fn smuggler_focused_npc_notes_attention() {
let text = generate_examine_text(NpcMood::Focused, 30, CharacterArchetype::Smuggler, None);
fn focused_npc_notes_attention() {
let text = generate_examine_text(NpcMood::Focused, 30, None);
assert!(
text.contains("close attention"),
text.contains("Paying close attention"),
"expected attention note, got: {text}"
);
}
#[test]
fn smuggler_high_stress_identifies_distraction() {
let text = generate_examine_text(NpcMood::Anxious, 80, CharacterArchetype::Smuggler, None);
fn high_stress_identifies_pressure() {
let text = generate_examine_text(NpcMood::Anxious, 80, None);
assert!(
text.contains("Too distracted"),
"expected distraction read, got: {text}"
text.contains("Under pressure"),
"expected pressure read, got: {text}"
);
}
#[test]
fn detective_deceptive_npc_notes_concealment() {
fn deceptive_npc_notes_concealment() {
let t = traits(&[PersonalityTrait::Deceptive]);
let text = generate_examine_text(
NpcMood::Neutral,
20,
CharacterArchetype::Detective,
Some(&t),
);
let text = generate_examine_text(NpcMood::Neutral, 20, Some(&t));
assert!(
text.contains("Controlled affect"),
"expected concealment note, got: {text}"
@@ -365,8 +336,8 @@ mod tests {
}
#[test]
fn detective_anxious_npc_notes_stress_markers() {
let text = generate_examine_text(NpcMood::Anxious, 50, CharacterArchetype::Detective, None);
fn anxious_npc_notes_stress_markers() {
let text = generate_examine_text(NpcMood::Anxious, 50, None);
assert!(
text.contains("stress markers"),
"expected stress markers, got: {text}"
@@ -374,8 +345,8 @@ mod tests {
}
#[test]
fn detective_content_npc_notes_low_guard() {
let text = generate_examine_text(NpcMood::Content, 10, CharacterArchetype::Detective, None);
fn content_npc_notes_low_guard() {
let text = generate_examine_text(NpcMood::Content, 10, None);
assert!(
text.contains("Less guarded"),
"expected low guard note, got: {text}"
-3
View File
@@ -72,8 +72,6 @@ pub type EconomicModifier = String;
pub type FactionModifier = String;
/// Condition modifier on a zone palette (worn, pristine, damaged). Stub.
pub type ConditionModifier = String;
/// Heritage root modifier (Settled Reach cultural grammar layer). Stub.
pub type HeritageRoot = String;
/// Season modifier (affects palette and ambient conditions). Stub.
pub type Season = String;
/// Role slot within a social site template. Stub.
@@ -366,7 +364,6 @@ pub enum PaletteModifier {
Era(Era),
FactionPresence(FactionModifier),
Condition(ConditionModifier),
Heritage(HeritageRoot),
Season(Season),
}
+100 -18
View File
@@ -119,7 +119,7 @@ impl PostConversationQueue {
/// Tracks monologue state for cooldown and trigger detection.
/// Attached to the PlayerCharacter entity.
#[derive(Component, Debug)]
#[derive(Component, Debug, Default)]
pub struct MonologueState {
/// Tick when the last monologue was fired.
pub last_fired_tick: u64,
@@ -131,29 +131,12 @@ pub struct MonologueState {
pub entered: bool,
/// IDs of lines already shown (dedup within session).
pub shown_ids: BTreeSet<String>,
/// Character type for pool filtering. Set from CharacterArchetype (#587).
pub character: String,
/// Tick of the last observation event we reacted to (#119, observe_npc).
/// Observation events arrive one tick after the snapshot that caused them,
/// so we track which tick's events we've already processed.
pub last_observation_tick: u64,
}
impl Default for MonologueState {
fn default() -> Self {
Self {
last_fired_tick: 0,
last_position: None,
idle_ticks: 0,
entered: false,
shown_ids: BTreeSet::new(),
// Default to detective; overridden by CharacterArchetype at spawn (#587)
character: "detective".to_string(),
last_observation_tick: 0,
}
}
}
/// Buffer holding the monologue event to include in the next snapshot.
/// `take()` drains the buffer (consumed once per snapshot).
#[derive(Component, Debug, Default)]
@@ -2142,4 +2125,103 @@ mod tests {
"queue should be empty after monologue consumed the event"
);
}
// -----------------------------------------------------------------------
// Regression: monologue pool selection is uniform (D-032 / #878)
// -----------------------------------------------------------------------
#[test]
fn monologue_pool_selection_uniform_no_archetype_key() {
// Regression guard (D-032 cascade purge, #878):
// trigger_event_monologue previously partitioned pool selection by
// CharacterArchetype key (MonologueState.character). That field is gone.
// Pool selection is now by trigger string only — "observe_npc", "hear_sound",
// "post_conversation" — and the line IDs are drawn exclusively from the
// corresponding hardcoded constant (OBSERVE_NPC_LINES et al.).
//
// This test asserts the POSITIVE behaviour: an observe_npc trigger always
// produces a line whose ID begins with "observe_npc_", regardless of any
// additional observer state. It FAILS if an archetype-keyed dispatch path
// is reintroduced (which would produce IDs outside that prefix or panic on
// a missing archetype field).
// Known line IDs from OBSERVE_NPC_LINES (compile-checked below).
const VALID_OBSERVE_NPC_IDS: &[&str] =
&["observe_npc_01", "observe_npc_02", "observe_npc_03"];
// --- Observer A: minimal state (no extra components) ---
let line_a = {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
world
.resource_mut::<ObservationEventQueue>()
.push(ObservationEvent {
tick: 1,
trigger: ObservationTrigger::NewEntity {
entity: StableId(10),
location: TilePosition::new(12, 12, 0),
},
observer: player,
});
run_event_system(&mut world);
let buf = world.get::<MonologueBuffer>(player).unwrap();
buf.event
.as_ref()
.expect("observe_npc trigger must fire a monologue")
.id
.clone()
};
// --- Observer B: player has heard a previous sound (last_fired_tick set) ---
// Simulates a player with non-default MonologueState — the pool key must
// still resolve to OBSERVE_NPC_LINES, not an archetype-partitioned variant.
let line_b = {
let mut world = setup_event_world();
let player = spawn_event_player(&mut world);
// Pre-populate state to exercise a non-fresh observer
world.resource_mut::<SimulationTime>().tick = 10;
{
let mut state = world.get_mut::<MonologueState>(player).unwrap();
state.last_fired_tick = 3;
}
world
.resource_mut::<ObservationEventQueue>()
.push(ObservationEvent {
tick: 5,
trigger: ObservationTrigger::NewEntity {
entity: StableId(20),
location: TilePosition::new(14, 14, 0),
},
observer: player,
});
run_event_system(&mut world);
let buf = world.get::<MonologueBuffer>(player).unwrap();
buf.event
.as_ref()
.expect("observe_npc trigger must fire for observer B")
.id
.clone()
};
// Both observers must produce IDs from the unified observe_npc pool.
assert!(
VALID_OBSERVE_NPC_IDS.contains(&line_a.as_str()),
"Observer A line_id '{}' is not from OBSERVE_NPC_LINES — \
archetype-keyed pool dispatch may have been reintroduced",
line_a
);
assert!(
VALID_OBSERVE_NPC_IDS.contains(&line_b.as_str()),
"Observer B line_id '{}' is not from OBSERVE_NPC_LINES — \
archetype-keyed pool dispatch may have been reintroduced",
line_b
);
}
}
+79 -1
View File
@@ -14,6 +14,7 @@ use std::path::{Path, PathBuf};
use bevy_ecs::prelude::*;
use thiserror::Error;
use crate::bookmark::SelectedBookmark;
use crate::bridge::types::SaveLoadResultWire;
use crate::bridge::types::SnapshotBuffer;
use crate::knowledge::graph::KnowledgeGraph;
@@ -160,6 +161,10 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError>
last_activation_tick: world
.get_resource::<ActivationState>()
.and_then(|a| a.last_activation_tick),
selected_bookmark: world
.get_resource::<SelectedBookmark>()
.cloned()
.unwrap_or_default(),
};
let bytes = state
@@ -285,6 +290,10 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro
last_activation_tick: state.last_activation_tick,
});
// Restore bookmark selection (#863) — the player's confirmed bookmark and
// starting location survive save/load so downstream systems stay consistent.
world.insert_resource(state.selected_bookmark);
// Reset event queues and transient buffers — prevent stale events/history
// from the pre-load world leaking into the post-load simulation.
world.insert_resource(ContaminationEventQueue::default());
@@ -415,8 +424,11 @@ mod tests {
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_path() -> PathBuf {
// Include the process ID so nextest processes (each starts COUNTER at 0)
// do not collide on the same filename when running concurrently.
let pid = std::process::id();
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("settled_reach_save_io_test_{}.msgpack", id))
std::env::temp_dir().join(format!("settled_reach_save_io_test_{}_{}.msgpack", pid, id))
}
fn minimal_world() -> World {
@@ -623,6 +635,7 @@ mod tests {
contamination_active: false,
activated_count: 0,
last_activation_tick: None,
selected_bookmark: SelectedBookmark::default(),
};
let bytes = bad_state.to_bytes().expect("serialize");
let path = temp_path();
@@ -955,4 +968,69 @@ mod tests {
let _ = std::fs::remove_file(&path);
}
// -----------------------------------------------------------------------
// SelectedBookmark round-trip (#863)
// -----------------------------------------------------------------------
#[test]
fn save_load_preserves_selected_bookmark() {
let mut world = minimal_world();
// Set a bookmark selection before saving.
world.insert_resource(SelectedBookmark {
bookmark_id: Some("tycoon".to_string()),
starting_location_id: Some("new-stockholm".to_string()),
});
let path = temp_path();
save_to_file(&path, &mut world).expect("save");
// Clear the resource to prove load restores it, not the pre-existing value.
world.insert_resource(SelectedBookmark::default());
assert!(
world.resource::<SelectedBookmark>().bookmark_id.is_none(),
"bookmark must be cleared before load"
);
load_from_file(&path, &mut world).expect("load");
let restored = world.resource::<SelectedBookmark>();
assert_eq!(
restored.bookmark_id.as_deref(),
Some("tycoon"),
"bookmark_id must survive round-trip"
);
assert_eq!(
restored.starting_location_id.as_deref(),
Some("new-stockholm"),
"starting_location_id must survive round-trip"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn save_load_selected_bookmark_defaults_when_unset() {
// Save without a bookmark selection (default = both None).
// Load must produce SelectedBookmark::default(), not error.
let mut world = minimal_world();
// SelectedBookmark not explicitly inserted — should default to no selection.
let path = temp_path();
save_to_file(&path, &mut world).expect("save");
load_from_file(&path, &mut world).expect("load");
let restored = world.resource::<SelectedBookmark>();
assert!(
restored.bookmark_id.is_none(),
"unset bookmark_id must survive as None"
);
assert!(
restored.starting_location_id.is_none(),
"unset starting_location_id must survive as None"
);
let _ = std::fs::remove_file(&path);
}
}
+65
View File
@@ -39,6 +39,7 @@ use bevy_ecs::entity::Entity;
use bevy_ecs::world::World;
use serde::{Deserialize, Serialize};
use crate::bookmark::SelectedBookmark;
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::StableEntityId;
use crate::knowledge::types::StableId;
@@ -122,6 +123,11 @@ pub struct SaveStateV1 {
/// `None` if no activation yet. Persisted alongside `activated_count`.
#[serde(default)]
pub last_activation_tick: Option<u64>,
/// Bookmark and starting location confirmed by the player at session start (#863, #614).
/// Both fields are `None` in saves created before #863 or before character creation
/// completes. Defaults to `SelectedBookmark::default()` for backward compatibility.
#[serde(default)]
pub selected_bookmark: SelectedBookmark,
}
/// Per-NPC state snapshot for `SaveStateV1`.
@@ -435,6 +441,7 @@ mod tests {
contamination_active: false,
activated_count: 0,
last_activation_tick: None,
selected_bookmark: crate::bookmark::SelectedBookmark::default(),
}
}
@@ -854,6 +861,7 @@ mod tests {
contamination_active: false,
activated_count: 0,
last_activation_tick: None,
selected_bookmark: crate::bookmark::SelectedBookmark::default(),
};
let bytes = save.to_bytes().expect("serialize");
@@ -878,6 +886,63 @@ mod tests {
assert!(result.is_err(), "must panic without StableEntityId");
}
// -----------------------------------------------------------------------
// SelectedBookmark round-trip (#863)
// -----------------------------------------------------------------------
#[test]
fn selected_bookmark_with_values_survives_roundtrip() {
// Spec (#863): SelectedBookmark persisted in SaveStateV1 must survive a
// full MessagePack serialize → deserialize cycle with field values intact.
let mut state = minimal_save_state();
state.selected_bookmark = crate::bookmark::SelectedBookmark {
bookmark_id: Some("tycoon".to_string()),
starting_location_id: Some("GJ 35".to_string()),
};
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
assert_eq!(
recovered.selected_bookmark.bookmark_id,
Some("tycoon".to_string()),
"bookmark_id must survive roundtrip"
);
assert_eq!(
recovered.selected_bookmark.starting_location_id,
Some("GJ 35".to_string()),
"starting_location_id must survive roundtrip"
);
// Idempotent re-serialize: bytes must be stable
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(
bytes, bytes2,
"SelectedBookmark roundtrip must be idempotent"
);
}
#[test]
fn selected_bookmark_default_survives_roundtrip() {
// Spec (#863): saves from before Sprint 37 (both fields None) must load
// cleanly via serde(default) on the SaveStateV1 field.
let state = minimal_save_state();
assert!(state.selected_bookmark.bookmark_id.is_none());
assert!(state.selected_bookmark.starting_location_id.is_none());
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
assert!(
recovered.selected_bookmark.bookmark_id.is_none(),
"default bookmark_id (None) must roundtrip"
);
assert!(
recovered.selected_bookmark.starting_location_id.is_none(),
"default starting_location_id (None) must roundtrip"
);
}
// -----------------------------------------------------------------------
// Modifications stub round-trip (#567, D-111/D-112)
// -----------------------------------------------------------------------
+1 -1
View File
@@ -535,7 +535,7 @@ pub fn activation_pass(
}
if candidates.is_empty() {
tracing::warn!(
tracing::debug!(
"activation_pass: no co-present NPC is assigned to a Simmering triangle at tick {} — holding",
time.tick
);
+8 -28
View File
@@ -95,7 +95,7 @@ pub const MAP_HEIGHT: i32 = 125;
/// is intentional for deterministic test setups but should be revisited
/// if Gauntlet is ever served by the production startup pipeline.
#[cfg(feature = "gauntlet")]
pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterArchetype) {
pub fn setup_gauntlet(app: &mut App) {
// Start with a fully blocked map, then carve rooms and corridors.
let mut walkability = WalkabilityMap::new_blocked(MAP_WIDTH, MAP_HEIGHT, 1);
@@ -190,12 +190,8 @@ pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterA
// --- Player (StableId 0) ---
// Spawn at Hub center: absolute (50, 58)
let profile = MovementProfile::smuggler();
let profile = MovementProfile::default();
let player_pos = TilePosition::new(50, 58, 0);
let monologue_state = MonologueState {
character: archetype.as_monologue_key().to_string(),
..Default::default()
};
let player = app
.world_mut()
.spawn((
@@ -204,7 +200,7 @@ pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterA
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
monologue_state,
MonologueState::default(),
MonologueBuffer::default(),
SprintAnomalyQueue::default(),
ScanEventBuffer::default(),
@@ -216,7 +212,6 @@ pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterA
crate::simulation::pressure::CharacterPressure::default(),
))
.id();
app.world_mut().entity_mut(player).insert(archetype);
registry.register(player);
// --- Hub signs (StableId 1-4) ---
@@ -699,10 +694,7 @@ mod tests {
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(
&mut app,
crate::bridge::types::CharacterArchetype::default(),
);
setup_gauntlet(&mut app);
// Run all 29 world-query invariants against the fully-initialized gauntlet world.
invariants::run_invariants(app.world_mut());
@@ -722,10 +714,7 @@ mod tests {
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(
&mut app,
crate::bridge::types::CharacterArchetype::default(),
);
setup_gauntlet(&mut app);
let wm = app.world().resource::<WalkabilityMap>();
// Hub center at (50, 58) must be walkable
@@ -739,10 +728,7 @@ mod tests {
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(
&mut app,
crate::bridge::types::CharacterArchetype::default(),
);
setup_gauntlet(&mut app);
let wm = app.world().resource::<WalkabilityMap>();
// North wall segment at absolute (90, 54) should be blocked
@@ -758,10 +744,7 @@ mod tests {
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(
&mut app,
crate::bridge::types::CharacterArchetype::default(),
);
setup_gauntlet(&mut app);
let wm = app.world().resource::<WalkabilityMap>();
// corridor-E center should be walkable
@@ -775,10 +758,7 @@ mod tests {
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(
&mut app,
crate::bridge::types::CharacterArchetype::default(),
);
setup_gauntlet(&mut app);
let registry = app.world().resource::<EntityRegistry>();
-154
View File
@@ -1,154 +0,0 @@
//! Regression tests: character archetype flows end-to-end to MonologueState (#595, D-032).
//!
//! Verifies that when a session starts with a given CharacterArchetype, the
//! player entity's MonologueState.character reflects it correctly. This is the
//! guard against the default "detective" string leaking into smuggler sessions.
//!
//! Two complementary approaches:
//! 1. Unit-level: CharacterArchetype::as_monologue_key() mapping is correct.
//! 2. Integration (gauntlet): setup_gauntlet() correctly wires archetype → MonologueState.
//!
//! Spec refs:
//! D-032: character tag is a hard pool partition, not a filter — wrong character string
//! silently serves wrong content.
//! D-010: no player identity baked into game loop — archetype is a configuration.
//! #587: character_archetype added to StartupMessage; monologue key derived from it.
//! #595: MonologueState.character initialized from CharacterArchetype at session start.
use settled_reach_server::bridge::types::CharacterArchetype;
// ---------------------------------------------------------------------------
// Layer 1 — pure unit tests, no ECS
// ---------------------------------------------------------------------------
#[test]
fn smuggler_archetype_maps_to_monologue_key() {
assert_eq!(
CharacterArchetype::Smuggler.as_monologue_key(),
"smuggler",
"Smuggler must produce the exact pool key 'smuggler' used in monologue YAML"
);
}
#[test]
fn detective_archetype_maps_to_monologue_key() {
assert_eq!(
CharacterArchetype::Detective.as_monologue_key(),
"detective",
"Detective must produce the exact pool key 'detective' used in monologue YAML"
);
}
#[test]
fn default_archetype_is_detective() {
// D-010: the safe fallback is Detective (the original single-character game).
// If serde default fires (old client, missing field), Detective must be chosen.
assert_eq!(
CharacterArchetype::default(),
CharacterArchetype::Detective,
"Default archetype must be Detective for backward compatibility (#587)"
);
}
#[test]
fn archetype_keys_are_distinct() {
// Sanity guard: the two keys must differ. If they were the same, pool partitioning
// (D-032) would be broken and both characters would see identical monologue lines.
assert_ne!(
CharacterArchetype::Smuggler.as_monologue_key(),
CharacterArchetype::Detective.as_monologue_key(),
"Smuggler and Detective monologue keys must be distinct (D-032 hard partition)"
);
}
// ---------------------------------------------------------------------------
// Layer 2 — integration: setup_gauntlet wires archetype → MonologueState
// ---------------------------------------------------------------------------
#[cfg(feature = "gauntlet")]
mod gauntlet_integration {
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use settled_reach_server::{
bridge::types::CharacterArchetype,
simulation::{monologue::MonologueState, movement::PlayerCharacter, SimulationPlugin},
test_world,
};
/// Build a minimal Gauntlet app with the given archetype and run one tick.
fn boot_gauntlet(archetype: CharacterArchetype) -> App {
let mut app = App::new();
app.add_plugins(SimulationPlugin { seed: 0 });
test_world::setup_gauntlet(&mut app, archetype);
app.update();
app
}
#[test]
fn smuggler_archetype_sets_monologue_character_to_smuggler() {
let mut app = boot_gauntlet(CharacterArchetype::Smuggler);
let mut query = app
.world_mut()
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
let state = query
.single(app.world())
.expect("player entity with MonologueState must exist after gauntlet setup");
assert_eq!(
state.character, "smuggler",
"Smuggler archetype must produce MonologueState.character = 'smuggler' (D-032, #587)"
);
}
#[test]
fn detective_archetype_sets_monologue_character_to_detective() {
let mut app = boot_gauntlet(CharacterArchetype::Detective);
let mut query = app
.world_mut()
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
let state = query
.single(app.world())
.expect("player entity with MonologueState must exist after gauntlet setup");
assert_eq!(
state.character, "detective",
"Detective archetype must produce MonologueState.character = 'detective' (D-032, #587)"
);
}
#[test]
fn smuggler_and_detective_produce_different_monologue_characters() {
// Regression guard: if both sessions return the same character string, D-032
// partitioning is broken. This test catches copy-paste mistakes in setup paths.
let mut smuggler_app = boot_gauntlet(CharacterArchetype::Smuggler);
let mut detective_app = boot_gauntlet(CharacterArchetype::Detective);
let smuggler_char = {
let mut q = smuggler_app
.world_mut()
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
q.single(smuggler_app.world())
.expect("smuggler player must exist")
.character
.clone()
};
let detective_char = {
let mut q = detective_app
.world_mut()
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
q.single(detective_app.world())
.expect("detective player must exist")
.character
.clone()
};
assert_ne!(
smuggler_char, detective_char,
"Smuggler and Detective sessions must have different MonologueState.character values \
(D-032 hard partition: same key means both characters see each other's monologue pool)"
);
}
}
-1
View File
@@ -33,7 +33,6 @@ fn snapshot_roundtrip_over_unix_socket() {
let bridge = LocalBridge::accept(&server_path).expect("failed to accept");
let snapshot = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 42,
game_time: GameTime {
day: 0,
-1
View File
@@ -19,7 +19,6 @@ fn snapshot_roundtrip_over_tcp() {
let bridge = TcpBridge::accept_on(listener).expect("failed to accept");
let snapshot = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 42,
game_time: GameTime {
day: 0,
@@ -432,6 +432,7 @@ fn minimal_save() -> SaveStateV1 {
contamination_active: false,
activated_count: 0,
last_activation_tick: None,
selected_bookmark: settled_reach_server::bookmark::SelectedBookmark::default(),
}
}
-1
View File
@@ -268,7 +268,6 @@ fn snapshot_with_sim_errors_roundtrips() {
use settled_reach_server::simulation::time::{DayPhase, TickRate};
let snapshot = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 10,
game_time: GameTime {
day: 0,
-1
View File
@@ -68,7 +68,6 @@ fn player_moves_north_through_full_pipeline() {
rmp_serde::from_slice(&response).expect("deserialize snapshot");
// Snapshot captures state at end of tick 0 (before advance_tick increments to 1)
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(snapshot.tick, 0);
assert_eq!(snapshot.entities.len(), 1);
+3 -6
View File
@@ -16,10 +16,9 @@ fn write_fixture(name: &str, bytes: &[u8]) {
eprintln!("Wrote {} ({} bytes)", path.display(), bytes.len());
}
/// Helper to create a minimal v2 snapshot for fixtures
/// Helper to create a minimal snapshot for fixtures (D-192: no version field)
fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
ObserverSnapshot {
version: PROTOCOL_VERSION,
tick,
game_time: GameTime {
day: 0,
@@ -181,7 +180,6 @@ fn generate_msgpack_fixtures() {
// v2 snapshot with visible_tiles and game_time populated
let snapshot_v2_full = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 500,
game_time: GameTime {
day: 1,
@@ -302,7 +300,7 @@ fn generate_msgpack_fixtures() {
// === #271 fixtures: named fixtures for cross-language Layer 1 testing ===
// snapshot_minimal: version=PROTOCOL_VERSION, tick=0, one Player entity, all optionals absent
// snapshot_minimal: tick=0, one Player entity, all optionals absent
let snapshot_minimal = fixture_snapshot(
0,
vec![VisibleEntity {
@@ -322,9 +320,8 @@ fn generate_msgpack_fixtures() {
&rmp_serde::to_vec_named(&snapshot_minimal).unwrap(),
);
// snapshot_full: version=PROTOCOL_VERSION, tick=42, monologue + dialogue + inventory + POIs + KG dump
// snapshot_full: tick=42, monologue + dialogue + inventory + POIs + KG dump
let snapshot_full = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 42,
game_time: GameTime {
day: 3,
+1 -4
View File
@@ -45,10 +45,7 @@ fn build_gauntlet(seed: u64) -> App {
app.add_plugins(NpcPlugin);
app.insert_resource(SimRng::new(seed));
test_world::setup_gauntlet(
&mut app,
settled_reach_server::bridge::types::CharacterArchetype::default(),
);
test_world::setup_gauntlet(&mut app);
app
}
+1
View File
@@ -214,6 +214,7 @@ fn save_state_npc_kg_isolation() {
contamination_active: false,
activated_count: 0,
last_activation_tick: None,
selected_bookmark: settled_reach_server::bookmark::SelectedBookmark::default(),
};
// Roundtrip: serialize → deserialize.
+2 -6
View File
@@ -99,13 +99,9 @@ fn ipc_round_trip_latency() {
let handshake_bytes = read_framed(&mut reader)
.expect("read handshake")
.expect("server closed before sending HandshakeMessage");
let handshake: HandshakeMessage =
// D-192: HandshakeMessage carries no version field. Verify it deserialises cleanly.
let _handshake: HandshakeMessage =
rmp_serde::from_slice(&handshake_bytes).expect("deserialize HandshakeMessage");
assert_eq!(
handshake.protocol_version, PROTOCOL_VERSION,
"handshake version mismatch: server={}, client={}",
handshake.protocol_version, PROTOCOL_VERSION
);
let make_input = |tick: u64| PlayerInput {
tick,
+3 -15
View File
@@ -76,19 +76,12 @@ fn server_subprocess_sends_snapshot_on_connect() {
let handshake_frame = read_framed(&mut reader)
.expect("read handshake frame")
.expect("server closed connection before sending handshake");
let handshake: HandshakeMessage =
// D-192: HandshakeMessage carries no version field. Verify it deserialises cleanly.
let _handshake: HandshakeMessage =
rmp_serde::from_slice(&handshake_frame).expect("deserialize HandshakeMessage");
assert_eq!(
handshake.protocol_version, PROTOCOL_VERSION,
"handshake protocol_version mismatch: got {}, expected {}",
handshake.protocol_version, PROTOCOL_VERSION
);
// 5. Send StartupMessage with world_seed (#175)
let startup = StartupMessage {
world_seed: 42,
character_archetype: settled_reach_server::bridge::types::CharacterArchetype::default(),
};
let startup = StartupMessage { world_seed: 42 };
let startup_payload = rmp_serde::to_vec_named(&startup).expect("serialize StartupMessage");
write_framed(&mut writer, &startup_payload).expect("send StartupMessage to server");
@@ -108,11 +101,6 @@ fn server_subprocess_sends_snapshot_on_connect() {
rmp_serde::from_slice(&response).expect("deserialize ObserverSnapshot");
// 8. Assert protocol correctness (D-020)
assert_eq!(
snapshot.version, PROTOCOL_VERSION,
"protocol version mismatch: got {}, expected {}",
snapshot.version, PROTOCOL_VERSION
);
assert!(
snapshot.entities.len() > 0,
"snapshot should contain at least one entity (the player), got 0"
+3 -84
View File
@@ -4,10 +4,9 @@ use settled_reach_server::bridge::types::*;
use settled_reach_server::simulation::time::{DayPhase, TickRate};
use std::fs;
/// Helper to create a minimal v2 snapshot for tests
/// Helper to create a minimal snapshot for tests (D-192: no version field)
fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
ObserverSnapshot {
version: PROTOCOL_VERSION,
tick,
game_time: GameTime {
day: 0,
@@ -65,7 +64,6 @@ fn observer_snapshot_roundtrip() {
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.version, PROTOCOL_VERSION);
assert_eq!(decoded.tick, 42);
assert_eq!(decoded.entities.len(), 1);
assert_eq!(decoded.entities[0].entity_id, 1);
@@ -171,18 +169,13 @@ fn all_fixtures_deserialize() {
let bytes = fs::read(&path).unwrap_or_else(|_| panic!("read fixture {}", name));
if name.starts_with("snapshot_boundary") {
// Boundary snapshot fixtures (#472): tick may exceed PROTOCOL_VERSION check
// Boundary snapshot fixtures (#472)
rmp_serde::from_slice::<ObserverSnapshot>(&bytes).unwrap_or_else(|e| {
panic!("deserialize boundary snapshot fixture {}: {}", name, e)
});
} else if name.starts_with("snapshot") {
let snap = rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
.unwrap_or_else(|e| panic!("deserialize snapshot fixture {}: {}", name, e));
assert_eq!(
snap.version, PROTOCOL_VERSION,
"fixture {} has wrong version",
name
);
} else if name.starts_with("input_batch") {
rmp_serde::from_slice::<Vec<PlayerInput>>(&bytes)
.unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e));
@@ -244,7 +237,6 @@ fn all_entity_kind_variants_roundtrip() {
#[test]
fn snapshot_v2_fields_roundtrip() {
let snapshot = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 100,
game_time: GameTime {
day: 3,
@@ -311,7 +303,6 @@ fn snapshot_v2_fields_roundtrip() {
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.version, PROTOCOL_VERSION);
assert_eq!(decoded.game_time.day, 3);
assert_eq!(decoded.game_time.time_of_day, 720);
assert_eq!(decoded.game_time.day_phase, DayPhase::Evening);
@@ -354,17 +345,6 @@ fn entity_to_bits_roundtrip() {
}
}
/// PROTOCOL_VERSION constant matches snapshot version field
#[test]
fn protocol_version_constant_matches_snapshot() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(
PROTOCOL_VERSION, 21,
"bump this assertion when protocol version changes"
);
}
/// All FacingDirection variants round-trip
#[test]
fn all_facing_direction_variants_roundtrip() {
@@ -381,7 +361,6 @@ fn all_facing_direction_variants_roundtrip() {
for dir in directions {
let snapshot = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 0,
game_time: GameTime {
day: 0,
@@ -525,7 +504,6 @@ fn v5_payload_deserializes_into_v6_struct() {
.expect("v5 payload should deserialize into v6 struct via serde(default)");
// New fields should get their defaults
assert_eq!(decoded.version, 5, "version field preserved from v5");
assert_eq!(decoded.tick, 42);
assert_eq!(
decoded.player_stance,
@@ -717,23 +695,6 @@ fn verb_kind_confront_roundtrip() {
assert_eq!(decoded.nearby_interactions[0].verbs[0].label, "Confront");
}
/// CharacterArchetype enum round-trips through MessagePack (#422).
/// Used in Phase 2 label relabeling — must survive the wire.
#[test]
fn all_character_archetype_variants_roundtrip() {
let archetypes = [CharacterArchetype::Smuggler, CharacterArchetype::Detective];
for archetype in archetypes {
let bytes = rmp_serde::to_vec_named(&archetype).expect("serialize");
let decoded: CharacterArchetype = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(
decoded, archetype,
"CharacterArchetype::{:?} roundtrip failed",
archetype
);
}
}
/// NearbyInteraction.contradicted=true round-trips through MessagePack (#422).
/// Guards the contradiction flag survives serialization.
#[test]
@@ -1247,7 +1208,6 @@ fn v8_payload_deserializes_into_v9_struct() {
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
.expect("v8 payload should deserialize into v9 struct via serde(default)");
assert_eq!(decoded.version, 8, "version field preserved from v8");
assert_eq!(decoded.tick, 100);
assert!(
decoded.blocked_entities.is_empty(),
@@ -1321,7 +1281,6 @@ fn v9_payload_deserializes_into_v10_struct() {
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
.expect("v9 payload should deserialize into v10 struct via serde(default)");
assert_eq!(decoded.version, 9, "version field preserved from v9");
assert_eq!(decoded.tick, 200);
assert_eq!(
decoded.rng_seed, None,
@@ -1397,7 +1356,6 @@ fn v10_payload_deserializes_into_v11_struct() {
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
.expect("v10 payload should deserialize into v11 struct via serde(default)");
assert_eq!(decoded.version, 10, "version field preserved from v10");
assert_eq!(decoded.tick, 300);
assert_eq!(decoded.visible_tiles.len(), 1);
assert_eq!(
@@ -1458,8 +1416,6 @@ fn serde_default_fields_fill_in_when_missing_from_wire() {
let decoded: ObserverSnapshot =
serde_json::from_value(minimal_json).expect("minimal JSON must deserialize");
// Version matches what was in the wire
assert_eq!(decoded.version, 13);
assert_eq!(decoded.tick, 42);
assert_eq!(decoded.entities.len(), 1);
@@ -1492,28 +1448,6 @@ fn serde_default_fields_fill_in_when_missing_from_wire() {
);
}
/// A snapshot with version != PROTOCOL_VERSION can be detected by checking
/// the version field after deserialization (#232 compatibility checking).
#[test]
fn snapshot_version_mismatch_is_detectable() {
let mut snapshot = test_snapshot(0, vec![]);
let future_version: u8 = PROTOCOL_VERSION + 1;
snapshot.version = future_version;
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
// The version field faithfully preserves the value — caller detects mismatch
assert_eq!(
decoded.version, future_version,
"version field must survive round-trip unchanged"
);
assert_ne!(
decoded.version, PROTOCOL_VERSION,
"client should detect this as a version mismatch"
);
}
/// tell_state=None is skipped in msgpack serialization (skip_serializing_if).
/// A snapshot with tell_state=None produces fewer bytes than one with
/// tell_state=Some(Nervous) — demonstrates the skip_serializing_if contract.
@@ -1627,19 +1561,6 @@ fn all_verb_kind_variants_roundtrip_v232() {
}
}
/// PROTOCOL_VERSION u8 type fits in one byte — wire overhead is minimal (#232).
/// This guards against accidental widening of the version type.
#[test]
fn protocol_version_fits_in_u8() {
// u8 max is 255 — enough for ~242 more protocol iterations.
// If PROTOCOL_VERSION ever reaches 200, consider migrating to u16.
assert!(
PROTOCOL_VERSION <= 200,
"PROTOCOL_VERSION={} is approaching u8 saturation; consider widening the type",
PROTOCOL_VERSION
);
}
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
/// Verifies object_type=Some(Container) survives the wire.
#[test]
@@ -1687,7 +1608,6 @@ fn fixture_snapshot_minimal_fields() {
let snap: ObserverSnapshot =
rmp_serde::from_slice(&bytes).expect("deserialize snapshot_minimal");
assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch");
assert_eq!(snap.tick, 0, "tick should be 0");
assert_eq!(snap.entities.len(), 1, "should have exactly 1 entity");
assert_eq!(snap.entities[0].entity_id, 1);
@@ -1707,7 +1627,6 @@ fn fixture_snapshot_full_fields() {
let bytes = read_named_fixture("snapshot_full");
let snap: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize snapshot_full");
assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch");
assert_eq!(snap.tick, 42, "tick should be 42");
// Monologue
+2 -4
View File
@@ -108,12 +108,10 @@ fn no_deviation_component_does_not_produce_deviation_tell() {
/// Set up a minimal gauntlet-based app with storyteller plugin running.
#[cfg(feature = "gauntlet")]
fn build_storyteller_app() -> App {
use settled_reach_server::{
bridge::types::CharacterArchetype, simulation::SimulationPlugin, test_world,
};
use settled_reach_server::{simulation::SimulationPlugin, test_world};
let mut app = App::new();
app.add_plugins(SimulationPlugin { seed: 0 });
test_world::setup_gauntlet(&mut app, CharacterArchetype::default());
test_world::setup_gauntlet(&mut app);
app
}
-328
View File
@@ -1,328 +0,0 @@
//! v0.1 integration playthrough test (#593, D-027).
//!
//! Validates the full session lifecycle from StartupMessage to storyteller activation:
//! D-027 criterion 1: player sees opening monologue on session start
//! D-027 criterion 4: NPC RoutineDeviation tell observable after triangle activation
//! D-036: news ticker headline visible in The Last Shift zone
//!
//! Test structure:
//! - `test_smuggler_opening_monologue`: asserts smuggler pool fires on tick 1 (runs now)
//! - `test_detective_opening_monologue`: asserts detective pool fires on tick 1 (runs now)
//! - `test_v0_1_integration_playthrough`: full E2E proof (#[ignore] until #589, #591 land)
//!
//! Uses Layer 3 pattern: real server subprocess, TCP IPC, no mocks.
//!
//! Prerequisites to unblock:
//! #589: escalate_tells_on_activation system (for RoutineDeviation assertion)
//! #591: TickerPool + current_ticker in snapshot (for ticker assertion)
use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::types::*;
use settled_reach_server::npc::tell_state::TellCategory;
use std::io::{BufRead, BufReader, BufWriter};
use std::net::TcpStream;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// Timeout for the server to emit LISTENING:{port} on stdout.
const LISTEN_TIMEOUT: Duration = Duration::from_secs(15);
/// Timeout for any individual snapshot read.
const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(15);
// ---------------------------------------------------------------------------
// Server lifecycle helpers
// ---------------------------------------------------------------------------
struct TestServer {
child: std::process::Child,
reader: BufReader<TcpStream>,
writer: BufWriter<TcpStream>,
}
impl TestServer {
/// Boot the server binary in test mode (gauntlet), send StartupMessage,
/// return a connected handle ready to receive snapshots.
fn boot_gauntlet(world_seed: u64, archetype: CharacterArchetype) -> Self {
let server_bin = env!("CARGO_BIN_EXE_settled-reach-server");
let mut child = Command::new(server_bin)
.args(["--test-mode", "--port", "0"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("failed to spawn server binary");
let stdout = child.stdout.take().expect("stdout not captured");
let mut stdout_reader = BufReader::new(stdout);
// Parse LISTENING:{port}
let port = {
let deadline = Instant::now() + LISTEN_TIMEOUT;
let mut line = String::new();
loop {
line.clear();
match stdout_reader.read_line(&mut line) {
Ok(0) => panic!("server stdout closed before LISTENING signal"),
Ok(_) => {
let trimmed = line.trim();
if let Some(port_str) = trimmed.strip_prefix("LISTENING:") {
break port_str.parse::<u16>().expect("invalid port");
}
}
Err(e) => panic!("failed to read server stdout: {}", e),
}
assert!(
Instant::now() < deadline,
"timed out waiting for LISTENING signal"
);
}
};
let addr = format!("127.0.0.1:{}", port);
let stream = TcpStream::connect(&addr).expect("client connect");
stream
.set_read_timeout(Some(SNAPSHOT_TIMEOUT))
.expect("set timeout");
let mut reader = BufReader::new(stream.try_clone().expect("clone stream"));
let mut writer = BufWriter::new(stream);
// Protocol handshake
let hf = read_framed(&mut reader)
.expect("read handshake")
.expect("connection closed");
let _: HandshakeMessage = rmp_serde::from_slice(&hf).expect("deserialize handshake");
// StartupMessage with chosen archetype
let startup = StartupMessage {
world_seed,
character_archetype: archetype,
};
let startup_payload = rmp_serde::to_vec_named(&startup).expect("serialize startup");
write_framed(&mut writer, &startup_payload).expect("send startup");
TestServer {
child,
reader,
writer,
}
}
/// Send a tick's worth of inputs (empty = idle tick) and read back one snapshot.
fn tick(&mut self, inputs: Vec<PlayerInput>) -> ObserverSnapshot {
let payload = rmp_serde::to_vec_named(&inputs).expect("serialize inputs");
write_framed(&mut self.writer, &payload).expect("send inputs");
let frame = read_framed(&mut self.reader)
.expect("read snapshot frame")
.expect("server closed connection");
rmp_serde::from_slice(&frame).expect("deserialize snapshot")
}
/// Send a debug command and get the next snapshot.
fn send_debug(&mut self, cmd: DebugCommandKind) -> ObserverSnapshot {
self.tick(vec![PlayerInput {
tick: 0,
action: PlayerAction::DebugCommand(cmd),
}])
}
fn shutdown(mut self) {
drop(self.reader);
drop(self.writer);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
match self.child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if Instant::now() > deadline {
self.child.kill().ok();
self.child.wait().ok();
break;
}
std::thread::sleep(Duration::from_millis(50));
}
Err(_) => {
self.child.kill().ok();
break;
}
}
}
}
}
// ---------------------------------------------------------------------------
// Tests: opening monologue archetype partitioning (runs now — no #[ignore])
// ---------------------------------------------------------------------------
#[test]
fn test_smuggler_opening_monologue() {
// Boot with Smuggler, advance 1 tick, assert opening monologue fires from smuggler pool.
// Monologue IDs from smuggler/opening.yaml start with "pc-smuggler_".
// This verifies: archetype → MonologueState.character → pool selection (D-032, #587, #595).
let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler);
let snapshot = server.tick(vec![PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
}]);
assert_eq!(
snapshot.version, PROTOCOL_VERSION,
"protocol version mismatch"
);
let monologue = snapshot.current_monologue;
assert!(
monologue.is_some(),
"Smuggler session must fire opening monologue on tick 1 (enter_location trigger, D-027 criterion 1). \
Got None either MonologueState.character is wrong or opening.yaml lines are not loaded."
);
let monologue = monologue.unwrap();
assert!(
monologue.id.starts_with("pc-smuggler_"),
"Smuggler opening monologue ID must start with 'pc-smuggler_' (D-032 hard partition). \
Got id='{}'. Likely cause: MonologueState.character defaulted to 'detective' despite Smuggler archetype.",
monologue.id
);
server.shutdown();
}
#[test]
fn test_detective_opening_monologue() {
// Boot with Detective, advance 1 tick, assert opening monologue fires from detective pool.
// Monologue IDs from detective/opening.yaml start with "pc-detective_".
let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Detective);
let snapshot = server.tick(vec![PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
}]);
assert_eq!(
snapshot.version, PROTOCOL_VERSION,
"protocol version mismatch"
);
let monologue = snapshot.current_monologue;
assert!(
monologue.is_some(),
"Detective session must fire opening monologue on tick 1 (enter_location trigger). \
Got None either MonologueState.character is wrong or opening.yaml lines are not loaded."
);
let monologue = monologue.unwrap();
assert!(
monologue.id.starts_with("pc-detective_"),
"Detective opening monologue ID must start with 'pc-detective_' (D-032 hard partition). \
Got id='{}'. Likely cause: archetype defaulted incorrectly.",
monologue.id
);
server.shutdown();
}
#[test]
fn test_smuggler_and_detective_get_different_opening_monologue_ids() {
// Regression guard: two sessions with different archetypes must never produce
// the same monologue ID on tick 1. If they do, D-032 partitioning is broken.
let mut smug = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler);
let smug_snap = smug.tick(vec![]);
let smug_id = smug_snap
.current_monologue
.as_ref()
.map(|m| m.id.clone())
.unwrap_or_default();
smug.shutdown();
let mut det = TestServer::boot_gauntlet(12345, CharacterArchetype::Detective);
let det_snap = det.tick(vec![]);
let det_id = det_snap
.current_monologue
.as_ref()
.map(|m| m.id.clone())
.unwrap_or_default();
det.shutdown();
assert_ne!(
smug_id, det_id,
"Smuggler and Detective must fire different opening monologue IDs (D-032). \
Both got '{}' pool partitioning is broken.",
smug_id
);
}
// ---------------------------------------------------------------------------
// Full v0.1 playthrough proof (blocked until #589 + #591 land)
// ---------------------------------------------------------------------------
#[test]
#[ignore = "blocked: TeleportToLocation debug command not implemented (needs location tile_bounds from ContentStore). Criteria 1+2 covered by non-ignored tests above."]
fn test_v0_1_integration_playthrough() {
// Full E2E proof per D-027 v0.1 success criteria:
// 1. Opening monologue fires in correct character pool
// 2. After activation, anchor NPC shows RoutineDeviation tell
// 3. News ticker visible when player is in "bar" zone
// (Manual criterion: walk to terminal, observe Kael, see fog-and-tension)
let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler);
// === Criterion 1: Opening monologue (Smuggler) ===
let tick1 = server.tick(vec![]);
let monologue = tick1
.current_monologue
.expect("Opening monologue must fire on tick 1");
assert!(
monologue.id.starts_with("pc-smuggler_"),
"Tick-1 monologue must be from smuggler pool. Got: {}",
monologue.id
);
// === Skip to contamination phase (fast-forward via debug) ===
let _skip_snap = server.send_debug(DebugCommandKind::SkipToContamination);
let _contaminate = server.send_debug(DebugCommandKind::ForceContaminationActivate);
// === Run ticks and watch for triangle activation ===
let mut triangle_crisis_observed = false;
for _ in 0..20 {
let snap = server.tick(vec![]);
if !snap.triangle_crisis_events.is_empty() {
triangle_crisis_observed = true;
break;
}
}
assert!(
triangle_crisis_observed,
"Triangle crisis event must appear within 20 ticks after contamination activation (#589)"
);
// === Criterion 2 (D-027 criterion 4): RoutineDeviation tell visible ===
// After activation, at least one NPC must show RoutineDeviation tell in the snapshot.
let mut deviation_observed = false;
for _ in 0..5 {
let snap = server.tick(vec![]);
if snap
.entities
.iter()
.any(|e| e.tell_state == Some(TellCategory::RoutineDeviation))
{
deviation_observed = true;
break;
}
}
assert!(
deviation_observed,
"After triangle activation, at least one NPC must show RoutineDeviation tell (D-027 criterion 4, #589)"
);
// === Criterion 3 (D-036): News ticker visible in bar zone ===
// Teleport to The Last Shift bar zone and check current_ticker is Some.
let _teleport = server.send_debug(DebugCommandKind::TeleportToLocation(
"the-last-shift".into(),
));
let bar_snap = server.tick(vec![]);
assert!(
bar_snap.current_ticker.is_some(),
"current_ticker must be Some when player is in 'the-last-shift' zone (D-036, #591)"
);
server.shutdown();
}
+3 -1
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# tests/run-all: Run all test suites in order (D-030)
# Invokes run-rust, run-godot, run-ipc-fixtures, run-ipc-protocol, run-ipc-integration.
# Invokes run-rust, run-godot, run-ipc-fixtures, run-ipc-protocol,
# run-ipc-integration, run-visual, run-atlas-determinism.
# Exit: 0 = all suites pass, non-zero = any suite failed
# Stdout: {"suite":"all","total":N,"passed":N,"failed":N,"duration_ms":N,"suites":[...]}
set -euo pipefail
@@ -26,6 +27,7 @@ SUITES=(
run-ipc-protocol
run-ipc-integration
run-visual
run-atlas-determinism
)
START_MS=$(date +%s%3N)
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# tests/run-atlas-determinism: Determinism smoke test for generate_atlas.py (#847)
#
# Imports generate_atlas as a Python module, calls process_body() twice with
# seed=42 and dry_run=True, compares the returned markers dicts as JSON.
# No wiki files are written or modified.
#
# Purpose: cheap guardrail against determinism regressions in terrain analysis,
# city placement, A* road routing, infrastructure MST, and gate terminal
# placement. GJ892f is a domed body (population=300, 1 city) — the smallest
# well-exercised case in the atlas pipeline.
#
# Spec ref: #847
# Exit: 0 = deterministic (pass), non-zero = failure
# Stdout: {"suite":"atlas-determinism","total":1,"passed":N,"failed":N,"duration_ms":N}
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
START_MS=$(date +%s%3N)
# Write the comparison script to a real file so that the generate_atlas venv
# bootstrap (os.execv) can re-exec it from the venv Python when needed.
# A heredoc (python3 - <<'EOF') does not work after os.execv because stdin
# has already been consumed.
HELPER=$(mktemp /tmp/atlas_det_helper.XXXXXX.py)
trap "rm -f '$HELPER'" EXIT
cat > "$HELPER" << 'PYEOF'
"""Atlas determinism helper — called by tests/run-atlas-determinism (#847).
Imports generate_atlas as a module and calls process_body() twice with
dry_run=True. Compares the returned markers dicts as JSON. Exits 0 if
identical, 1 if they differ, 2 on setup/import failure.
"""
import json
import os
import sqlite3
import sys
from pathlib import Path
REPO_ROOT = Path(os.environ["SR_REPO_ROOT"])
sys.path.insert(0, str(REPO_ROOT / "tooling" / "planet-gen"))
try:
import generate_atlas
except ImportError as e:
print(f"SKIP: generate_atlas import failed (missing deps?): {e}", file=sys.stderr)
sys.exit(2)
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
BODY_ID = "GJ892f" # domed, pop=300 → exactly 1 city; minimal and fast
SEED = 42
if not DB_PATH.exists():
print(f"SKIP: systems.db not found: {DB_PATH}", file=sys.stderr)
sys.exit(2)
conn = sqlite3.connect(str(DB_PATH))
conn.execute("PRAGMA foreign_keys=ON")
row = conn.execute("""
SELECT b.body_id, b.system_id, b.terrain_reference, b.population,
b.settlement_pattern, b.planet_class, b.economic_role,
COALESCE(b.cultural_corridor, s.cultural_corridor)
FROM bodies b JOIN star_systems s ON b.system_id = s.system_id
WHERE b.body_id = ?
""", (BODY_ID,)).fetchone()
conn.close()
if not row:
print(f"SKIP: {BODY_ID} not found in systems.db", file=sys.stderr)
sys.exit(2)
body_info = dict(zip(
("body_id", "system_id", "terrain_reference", "population",
"settlement_pattern", "planet_class", "economic_role", "cultural_corridor"),
row,
))
body_info["population"] = body_info["population"] or 0
kwargs = dict(
body_info=body_info,
seed=SEED,
noise_factor=0.25,
dry_run=True, # no files written
force=True, # skip the already-populated check
verbose=False,
)
a = generate_atlas.process_body(**kwargs)
b = generate_atlas.process_body(**kwargs)
if a["status"] != "generated":
print(f"FAIL: run 1 status={a['status']} — {a.get('message', '')}", file=sys.stderr)
sys.exit(1)
if b["status"] != "generated":
print(f"FAIL: run 2 status={b['status']} — {b.get('message', '')}", file=sys.stderr)
sys.exit(1)
json_a = json.dumps(a["markers"], indent=2)
json_b = json.dumps(b["markers"], indent=2)
if json_a == json_b:
print(f"PASS: {BODY_ID} markers identical on two runs (seed={SEED})", file=sys.stderr)
sys.exit(0)
else:
import difflib
diff = "\n".join(list(difflib.unified_diff(
json_a.splitlines(), json_b.splitlines(), lineterm="",
fromfile="run1", tofile="run2",
))[:40])
print(f"FAIL: {BODY_ID} markers differ between run 1 and run 2 (seed={SEED})", file=sys.stderr)
print(diff, file=sys.stderr)
sys.exit(1)
PYEOF
set +e
SR_REPO_ROOT="$REPO_ROOT" python3 "$HELPER" 2>&1 >&2
EXIT_CODE=$?
set -e
END_MS=$(date +%s%3N)
DURATION_MS=$((END_MS - START_MS))
# Exit code 2 = setup failure / missing deps → count as 0 tests (skip, not fail)
if [[ $EXIT_CODE -eq 0 ]]; then
PASSED=1; FAILED=0; TOTAL=1
elif [[ $EXIT_CODE -eq 2 ]]; then
PASSED=0; FAILED=0; TOTAL=0
echo " [atlas-determinism] SKIPPED (import failure or missing DB)" >&2
else
PASSED=0; FAILED=1; TOTAL=1
fi
printf '{"suite":"atlas-determinism","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \
"$TOTAL" "$PASSED" "$FAILED" "$DURATION_MS"
# Exit code 2 = venv/DB missing → treated as skip, not failure (exit 0 for run-all).
# Exit code 1 = determinism failure → exit 1 to fail CI.
[[ $EXIT_CODE -eq 2 ]] && exit 0 || exit $EXIT_CODE
+131 -4
View File
@@ -1855,7 +1855,9 @@ def process_body(
for feat in features:
if not _is_blank(feat.get("name")):
ft = feature_type_fn(feat)
corpus.setdefault((system_id, ft), set()).add(feat["name"])
# Corpus key: (corridor, feature_type) for cross-system dedup
# within the same cultural corridor (#853 §1, §2).
corpus.setdefault((corridor, ft), set()).add(feat["name"])
counts["preserved"] += 1
else:
blank.append(feat)
@@ -1872,8 +1874,9 @@ def process_body(
for ft, feats in by_type.items():
need = len(feats)
# Build taken list from corpus (cross-body dedup)
taken = list(corpus.get((system_id, ft), set()))
# Build taken list from corpus (cross-body corridor-scoped dedup).
# Two bodies in the same corridor never get the same city/mountain name.
taken = list(corpus.get((corridor, ft), set()))
# Also include body_used to avoid cross-type collisions on same body
taken_full = taken + list(body_used)
@@ -1896,14 +1899,26 @@ def process_body(
)
# Assign names to features in order
body_proper = ctx.get("body_proper_name") or body_id
for i, feat in enumerate(feats):
if i < len(names):
feat["name"] = names[i]
counts[count_key] += 1
generated[count_key].append(names[i])
corpus.setdefault((system_id, ft), set()).add(names[i])
corpus.setdefault((corridor, ft), set()).add(names[i])
body_used.add(names[i])
changed = True
elif section_key == "mountain_ranges":
# Empty-name fallback for mountains (#853 §2):
# If Gemma returned fewer names than needed, use a
# deterministic fallback rather than leave the field blank.
fallback = f"{body_proper} Range {i + 1}"
feat["name"] = fallback
counts[count_key] += 1
generated[count_key].append(fallback)
corpus.setdefault((corridor, ft), set()).add(fallback)
body_used.add(fallback)
changed = True
_batch_fill("cities", _feature_type_for_city, "cities")
_batch_fill("rivers", lambda f: "river", "rivers")
@@ -1911,6 +1926,94 @@ def process_body(
_batch_fill("mountain_ranges", lambda f: "mountain_range", "mountain_ranges")
_batch_fill("pois", _feature_type_for_poi, "pois")
# Mountain suffix monotony check (#853 §3):
# If >40% of mountain names on a single body share a trailing word,
# flag it. We don't re-query in the batch pipeline (no voice access here)
# but record a warning so the batch runner can surface bodies that need
# a targeted re-run.
mountain_names = [
f.get("name", "") for f in (markers.get("mountain_ranges") or [])
if f.get("name")
]
if len(mountain_names) >= 3:
suffix_counts: dict[str, int] = {}
for mn in mountain_names:
words = mn.split()
if words:
suffix_counts[words[-1].lower()] = suffix_counts.get(words[-1].lower(), 0) + 1
dominant = max(suffix_counts, key=lambda k: suffix_counts[k])
dominant_frac = suffix_counts[dominant] / len(mountain_names)
if dominant_frac > 0.40:
counts["suffix_monotony_warning"] = (
f"mountain suffix '{dominant}' on "
f"{suffix_counts[dominant]}/{len(mountain_names)} "
f"features ({dominant_frac:.0%}) — re-run targeting this body"
)
# Infrastructure naming (#853 §7):
# Assign deterministic city-pair names to unnamed roads and railroads.
# Convention: "{CityA}{CityB} {corridor_suffix}"
# (e.g. "AldrenForgehaven Corridor" for core,
# "MatambaDakar Estrada" for south_reach)
# This is a deterministic post-pass — no LLM needed.
_ROAD_SUFFIX: dict[str, str] = {
"core": "Corridor", "sol-gateway-axis": "Corridor",
"inner_corridor": "Corridor", "inner_orbit": "Corridor",
"north_reach": "Road", "south_reach": "Estrada",
"east_reach": "Road", "west_reach": "Strasse",
"deep_frontier": "Track", "frontier": "Track",
}
_RAIL_SUFFIX: dict[str, str] = {
"core": "Express", "sol-gateway-axis": "Express",
"inner_corridor": "Express", "inner_orbit": "Express",
"north_reach": "Line", "south_reach": "Linha",
"east_reach": "Line", "west_reach": "Bahn",
"deep_frontier": "Run", "frontier": "Run",
}
road_sfx = _ROAD_SUFFIX.get(corridor, "Road")
rail_sfx = _RAIL_SUFFIX.get(corridor, "Line")
cities_list = markers.get("cities") or []
def _nearest_city_name(path: list, cities: list[dict]) -> str:
"""Return the proper_name of the city nearest to a path endpoint."""
if not cities or not path:
return ""
endpoint = path[0] # first path point
if not isinstance(endpoint, (list, tuple)) or len(endpoint) < 2:
return ""
er, ec = endpoint[0], endpoint[1]
best_name = ""
best_dist = float("inf")
for city in cities:
center = city.get("center")
if not center or len(center) < 2:
continue
cr, cc = center[0], center[1]
dist = abs(er - cr) + abs(ec - cc)
if dist < best_dist and city.get("name"):
best_dist = dist
best_name = city["name"]
return best_name
for section_key, suffix in (("roads", road_sfx), ("railroads", rail_sfx)):
infra_list = markers.get(section_key) or []
for infra in infra_list:
if infra.get("name"):
continue # already named
path = infra.get("path") or []
if len(path) < 2:
continue
city_a = _nearest_city_name(path[:1], cities_list)
city_b = _nearest_city_name(path[-1:], cities_list)
if city_a and city_b and city_a != city_b:
infra["name"] = f"{city_a}{city_b} {suffix}"
elif city_a:
infra["name"] = f"{city_a} {suffix}"
else:
continue
changed = True
if changed:
markers_path.write_text(json.dumps(markers, indent=2) + "\n")
# Refresh atlas_* rows for this body so DB queries pick up the
@@ -2148,6 +2251,30 @@ def main():
corpus: dict[tuple, set[str]] = {}
# Seed corpus from existing atlas_* names so re-runs don't collide with
# names that were already committed to the DB on a previous pass (#853 §1).
# Key: (cultural_corridor, feature_type) — corridor-scoped dedup.
_ATLAS_SEED_QUERIES: list[tuple[str, str]] = [
("atlas_cities", "city"),
("atlas_rivers", "river"),
("atlas_mountain_ranges", "mountain_range"),
("atlas_oceans", "ocean"),
("atlas_pois", "poi_transit"),
]
try:
for table, ft in _ATLAS_SEED_QUERIES:
rows = conn.execute(
f"SELECT c.name, b.cultural_corridor "
f"FROM {table} c "
f"JOIN bodies b ON c.body_id = b.body_id "
f"WHERE c.name IS NOT NULL AND c.name != ''"
).fetchall()
for name, corridor_val in rows:
key = (corridor_val or "core", ft)
corpus.setdefault(key, set()).add(name)
except Exception as e:
log(f" warning: corpus seeding from DB failed ({e}) — cross-run dedup disabled")
# Cache of system_id → proper_name so we can emit a header line the
# first time we hit each system without re-querying per body.
system_name_cache: dict[str, str] = {
+29 -9
View File
@@ -16,9 +16,13 @@ Version history:
- minimum name length raised to 3 chars
- bracket/number rejection in is_valid_name
- parse_batch_response filters few-shot examples
0.3 2026-04-21 Generator-patch follow-up (#853)
- compass-direction negative example in build_batch_prompt
- river flow/current filter in is_valid_name(feature_type)
- parse_batch_response / name_features_batch pass feature_type
"""
__version__ = "0.2"
__version__ = "0.3"
import hashlib
@@ -151,10 +155,14 @@ FEWSHOT_BLOCKLIST = {
}
def is_valid_name(name: str) -> bool:
def is_valid_name(name: str, feature_type: str = "") -> bool:
"""Filter out garbage: too short, too long, contains periods/brackets,
looks like a prompt fragment, matches a few-shot example, or contains
digits."""
digits.
Pass feature_type="river" to also reject navigational vocabulary
(Flow, Current) that bleeds from ocean naming into river names (#853).
"""
if not name or len(name) < 3 or len(name) > 50:
return False
# Brackets, periods, digits — structural garbage
@@ -174,15 +182,24 @@ def is_valid_name(name: str) -> bool:
# Few-shot example bleed
if low in FEWSHOT_BLOCKLIST:
return False
# River-specific: reject navigational/oceanic vocabulary (#853 §6)
# "X Flow", "X Current" read oddly for rivers — these are ocean terms.
if feature_type == "river":
words = low.split()
if words and words[-1] in ("flow", "current"):
return False
return True
def parse_batch_response(raw: str) -> list[str]:
def parse_batch_response(raw: str, feature_type: str = "") -> list[str]:
"""Parse a batch naming response into a list of clean, unique name strings.
Takes the first line only (model often continues with explanations
or more styles), splits on commas, strips quotes/whitespace,
filters invalid names, and deduplicates (preserving order).
Pass feature_type to enable feature-specific filtering (e.g. river
flow/current rejection via is_valid_name).
"""
first_line = raw.strip().split("\n")[0] if raw.strip() else ""
candidates = [
@@ -193,7 +210,7 @@ def parse_batch_response(raw: str) -> list[str]:
seen: set[str] = set()
unique: list[str] = []
for n in candidates:
if is_valid_name(n) and n.lower() not in seen:
if is_valid_name(n, feature_type) and n.lower() not in seen:
unique.append(n)
seen.add(n.lower())
return unique
@@ -238,9 +255,12 @@ def build_batch_prompt(
preamble = (
f"Settlers {verb} {subject} after themselves, after what they saw, "
f"or after places back home. Most names are mundane, short, and "
f"direct — a surname, a compass direction, a feature, a practical "
f"direct — a surname, a landform, a family name, a practical "
f"description.{mood_clause}Avoid the obvious choice. Each name must "
f"be distinct — no two names may share a root word.\n"
f"be distinct — no two names may share a root word. "
f"Do NOT name features after compass directions "
f"(Eastern Range, Northern Heights, Western Pass — "
f"settlers name places after people and events, not bearings).\n"
f"Reply with ONLY a comma-separated list, no numbering, no markdown."
)
@@ -349,7 +369,7 @@ def name_features_batch(
except RuntimeError:
raw = ""
candidates = parse_batch_response(raw)
candidates = parse_batch_response(raw, feature_type)
selected = select_distinct(candidates, count, taken)
# Refill from adjacent register if we didn't fill the quota
@@ -388,7 +408,7 @@ def name_features_batch(
except RuntimeError:
refill_raw = ""
refill_candidates = parse_batch_response(refill_raw)
refill_candidates = parse_batch_response(refill_raw, feature_type)
extra = select_distinct(refill_candidates, shortfall, refill_taken)
selected.extend(extra)
+36
View File
@@ -0,0 +1,36 @@
---
title: "Arbour Aggregates"
description: "Planetary extraction cooperative at Arbour (GJ 338B) — stone quarrying and managed timber harvest supplying the inner corridor's construction sector since the system's founding generation"
slug: arbour-aggregates
category: corporation
status: canonical
created: 2026-04-21
updated: 2026-04-21
scope: GJ 338B local; north corridor secondary
faction_type: economic
headquarters: Arbour (GJ 338B)
tags: [stone, timber, extraction, tractus]
decision_refs: [D-175]
cross_refs: []
---
# Arbour Aggregates
**Type:** Corporation — Stone and Timber Extraction Cooperative
**Also Known As:** Arbour Agg, AAC
**Status:** Canonical
**Scope:** Arbour system primary; corridor construction supply secondary
**Headquarters:** Arbour (GJ 338B) — surface operations, cooperative ownership
**Classification:** Extraction cooperative; producer behavioral archetype
---
## Overview
Arbour was settled early and settled well. The planet's mixed biome — temperate forest belts alongside sedimentary stone formations — gave the founding cooperative two resource streams that corridor construction has needed ever since.
Arbour Aggregates handles both. Stone quarrying supplies aggregate and cut stone to corridor station builders; managed timber harvest (certified regrowth cycles, 80-year rotation) supplies structural panel manufacturers who can't rely on synthetic composite alone.
**Primary operations:** Open-face stone quarrying at Arbour's central plateau, managed softwood and hardwood forest operations in the temperate belt.
**Market position:** Reliable bulk supplier to north and core corridor. ESG dominates Sol-side stone; Arbour Aggregates holds the east-corridor share where transit distances from Sol make ESG supply expensive.
+1 -1
View File
@@ -9,7 +9,7 @@ updated: 2026-04-05
scope: regional
faction_type: economic
headquarters: Isivikelo (GJ 1111)
tags: [metallic_ore, refined_metals, mining, south_reach, korean, assembly]
tags: [metallic_ore, rare_minerals, refined_metals, mining, south_reach, korean, assembly]
decision_refs: [D-175]
cross_refs: []
---
+2 -2
View File
@@ -9,8 +9,8 @@ updated: 2026-04-19
scope: regional
faction_type: economic
headquarters: Bergtor (GJ 505A)
tags: [tech_premium, west_reach, mark, electronics]
decision_refs: [D-189]
tags: [drive_cores, tech_premium, west_reach, mark, electronics]
decision_refs: [D-175, D-189]
cross_refs: []
---
+2 -2
View File
@@ -9,8 +9,8 @@ updated: 2026-03-30
scope: west_reach outer corridor; inner-corridor premium market
faction_type: economic
headquarters: Nyrheim (GJ 3737)
tags: []
decision_refs: []
tags: [stone, quarrying, west_reach, mark]
decision_refs: [D-175]
cross_refs: []
---
+36
View File
@@ -0,0 +1,36 @@
---
title: "Earth Standard Group"
description: "Old Earth industrial conglomerate — quarrying, stone processing, and advanced-alloy manufacturing at Sol system scale, supplying the wider Reach via gate transit since the early colonization era"
slug: earth-standard-group
category: corporation
status: canonical
created: 2026-04-21
updated: 2026-04-21
scope: reach-wide
faction_type: economic
headquarters: Earth (GJ 0)
tags: [stone, advanced_alloys, industrial, sol_system]
decision_refs: [D-175]
cross_refs: []
---
# Earth Standard Group
**Type:** Corporation — Heavy Industry and Materials
**Also Known As:** ESG, Earth Standard
**Status:** Canonical
**Scope:** Reach-wide — Sol system production, export via gate network
**Headquarters:** Earth (GJ 0, Sol system) — long-established industrial concern
**Classification:** Industrial conglomerate; producer behavioral archetype
---
## Overview
Three centuries of colonization outpaced the Reach's ability to supply itself. Earth Standard Group filled the gap that the colonization wave left behind — old-world industrial capacity on Sol-system scale, producing the stone aggregate and advanced alloys that new settlements needed before their own extraction infrastructure came online.
ESG is not glamorous. It is large, methodical, and has survived every economic cycle since the first gate opened by doing one thing well: producing reliable bulk materials at Sol-system throughput and shipping them corridor-wide.
**Primary operations:** Stone quarrying and aggregate processing (Luna surface operations, Martian regolith processing), advanced alloy fabrication at orbital foundries.
**Market position:** Dominant in corridor construction supply during early settlement phases; retains long-term supply contracts with the Gate Corporation and major station builders.
+2 -2
View File
@@ -9,8 +9,8 @@ updated: 2026-03-14
scope: reach-wide
faction_type: economic
headquarters: Renaissance (GJ 251)
tags: []
decision_refs: [D-095]
tags: [fusion_fuel, gate_infrastructure, reach_wide, monopolist]
decision_refs: [D-095, D-175]
cross_refs: []
---
+1 -1
View File
@@ -9,7 +9,7 @@ updated: 2026-04-05
scope: regional
faction_type: economic
headquarters: Changwon (GJ 860B)
tags: [electronics, precision_instruments, east_reach, korean, assembly]
tags: [lattice_substrate, electronics, precision_instruments, east_reach, korean, assembly]
decision_refs: [D-175]
cross_refs: []
---
+2 -2
View File
@@ -9,8 +9,8 @@ updated: 2026-04-19
scope: regional
faction_type: economic
headquarters: Yeongwol (GJ 268)
tags: [tech_premium, east_reach, tractus, electronics]
decision_refs: [D-189]
tags: [lattice_grade_material, lattice_substrate, tech_premium, east_reach, tractus, electronics]
decision_refs: [D-175, D-189]
cross_refs: []
---
+2 -2
View File
@@ -9,8 +9,8 @@ updated: 2026-03-16
scope: west_reach outer corridor
faction_type: economic
headquarters: Stillvakt (GJ 534)
tags: []
decision_refs: []
tags: [timber, forestry, west_reach, mark]
decision_refs: [D-175]
cross_refs: []
---
+2 -2
View File
@@ -9,8 +9,8 @@ updated: 2026-04-19
scope: regional
faction_type: economic
headquarters: Nyrheim (GJ 3737)
tags: [heritage_craft, west_reach, mark, artisan]
decision_refs: [D-189]
tags: [timber, heritage_craft, west_reach, mark, artisan]
decision_refs: [D-175, D-189]
cross_refs: []
---
+36
View File
@@ -0,0 +1,36 @@
---
title: "Rush Mining"
description: "Frontier extraction operation at Rush (GJ 725B) — metallic ore, rare minerals, and lattice-grade material from Struve's belt and surface deposits, supplying corridor processors who can't source from the established south_reach operations"
slug: rush-mining
category: corporation
status: canonical
created: 2026-04-21
updated: 2026-04-21
scope: GJ 725B local; outer corridor secondary
faction_type: economic
headquarters: Rush (GJ 725B)
tags: [metallic_ore, rare_minerals, lattice_grade_material, mining, frontier, independent]
decision_refs: [D-175]
cross_refs: []
---
# Rush Mining
**Type:** Corporation — Multi-Resource Extraction
**Also Known As:** Rush, Rush Mining Co.
**Status:** Canonical
**Scope:** Struve system primary; outer corridor spot market secondary
**Headquarters:** Rush (GJ 725B, Struve system) — surface and orbital operations
**Classification:** Mining operation; producer behavioral archetype
---
## Overview
Struve's belt is productive but awkward — the system is off the main transit corridors, which keeps extraction costs high and competition low. Rush Mining has operated here for four generations, making a virtue of the location: without corridor competitors, they've developed deep extraction expertise across the belt's varied ore profile.
The company mines a wider commodity range than most single-system operations: metallic ore from the main belt deposits, rare mineral concentrates from the inner system's geologically active secondary bodies, and lattice-grade material from a fractured lunar body that proved unexpectedly rich. Each stream is sold independently into the corridor spot market when the gate schedule permits transit.
**Primary operations:** Belt extraction (metallic ore, rare minerals), surface and sub-surface mining of lattice-grade material on Struve's secondary moon.
**Market position:** Frontier supplier with niche advantage on lattice-grade material quality — Rare Vein Survey (the primary reach-wide supplier) doesn't consistently reach Struve volumes. Rush Mining fills the gap for east and outer corridor buyers.
+1 -1
View File
@@ -9,7 +9,7 @@ updated: 2026-04-05
scope: reach-wide
faction_type: economic
headquarters: Matamba (GJ 884)
tags: [medical_goods, chemicals, south_reach, tractus, assembly, distributor]
tags: [chemical_feedstock, medical_goods, chemicals, south_reach, tractus, assembly, distributor]
decision_refs: [D-175]
cross_refs: []
---
@@ -0,0 +1,36 @@
---
title: "Scapa Flow Industries"
description: "Station-based industrial manufacturer at Scapa Flow (GJ 570A) — fusion fuel bunkering and structural panel fabrication for the Bastion corridor, operating as the system's primary heavy industrial concern"
slug: scapa-flow-industries
category: corporation
status: canonical
created: 2026-04-21
updated: 2026-04-21
scope: GJ 570A local; Bastion corridor secondary
faction_type: economic
headquarters: Scapa Flow (GJ 570A)
tags: [fusion_fuel, structural_panels, manufacturing, tractus]
decision_refs: [D-175]
cross_refs: []
---
# Scapa Flow Industries
**Type:** Corporation — Industrial Manufacturing and Fuel Bunkering
**Also Known As:** Scapa Flow, SFI
**Status:** Canonical
**Scope:** Bastion system primary; outer corridor secondary
**Headquarters:** Scapa Flow station (GJ 570A, Bastion system) — orbital industrial platform
**Classification:** Industrial manufacturer; producer behavioral archetype
---
## Overview
Scapa Flow station was built as a fuel depot and grew into something larger. The outer corridor systems need bunkering infrastructure, and Scapa Flow's position in Bastion made it the logical hub — ships transiting the outer routes pass through here, and the infrastructure investment compounded over generations.
Scapa Flow Industries now runs two parallel operations from the station: fuel bunkering (buying fusion fuel from frontier suppliers and reselling at a Bastion-corridor price) and structural panel fabrication for station-scale construction projects in the outer systems. The panel operation started as a necessity — outer systems had long lead times on materials from the inner corridor — and became a competitive product in its own right.
**Primary operations:** Fusion fuel bunkering and resale, structural composite panel fabrication for station construction.
**Market position:** Dominant in Bastion system; cost-competitive in outer corridor against inner-corridor suppliers due to reduced transit costs.
+36
View File
@@ -0,0 +1,36 @@
---
title: "Sede Chemical Works"
description: "Industrial chemical processor at Sede (GJ 559B) — feedstock refining and chemical synthesis for the ACB corridor, operating under long-term supply agreements with pharmaceutical and manufacturing clients"
slug: sede-chemical-works
category: corporation
status: canonical
created: 2026-04-21
updated: 2026-04-21
scope: GJ 559B local; ACB corridor secondary
faction_type: economic
headquarters: Sede (GJ 559B)
tags: [chemical_feedstock, chemicals, manufacturing, tractus]
decision_refs: [D-175]
cross_refs: []
---
# Sede Chemical Works
**Type:** Corporation — Chemical Feedstock Processing and Synthesis
**Also Known As:** Sede Chemical, SCW
**Status:** Canonical
**Scope:** ACB corridor primary; reach-wide specialty supply secondary
**Headquarters:** Sede (GJ 559B) — industrial processing campus
**Classification:** Chemical manufacturer; producer behavioral archetype
---
## Overview
ACB system sits at a transit intersection that made it a logical location for chemical processing: raw feedstocks can arrive from multiple corridor directions, and finished chemical products distribute outward on the same gate network.
Sede Chemical Works was built to service that intersection. The company processes raw chemical feedstock into pharmaceutical-grade and industrial-grade outputs, operating under strict Assembly environmental protocols. Their location at Sede means most ACB corridor pharmaceutical producers carry SCW as a primary supplier.
**Primary operations:** Chemical feedstock fractionation, industrial chemical synthesis, pharmaceutical precursor production.
**Market position:** Dominant ACB corridor supplier; competes with Société Chimique on reach-wide accounts but holds the ACB corridor share due to lower transit costs.
+2 -2
View File
@@ -9,8 +9,8 @@ updated: 2026-04-19
scope: regional
faction_type: economic
headquarters: Crown's Hollow (GJ 661A)
tags: [heritage_craft, north_reach, tractus, artisan]
decision_refs: [D-189]
tags: [textiles, heritage_craft, north_reach, tractus, artisan]
decision_refs: [D-175, D-189]
cross_refs: []
---
+1 -1
View File
@@ -9,7 +9,7 @@ updated: 2026-04-05
scope: reach-wide
faction_type: economic
headquarters: Confluent (GJ 395)
tags: [chemicals, organic_compounds, manufacturing, tractus, assembly, french_heritage]
tags: [chemical_feedstock, chemicals, organic_compounds, manufacturing, tractus, assembly, french_heritage]
decision_refs: [D-175]
cross_refs: []
---
+2 -2
View File
@@ -9,8 +9,8 @@ updated: 2026-03-15
scope: south_reach primary; west_reach secondary; reach-wide in extraction sectors
faction_type: economic
headquarters: Nowa Huta (GJ 896A)
tags: []
decision_refs: []
tags: [refined_metals, advanced_alloys, structural_panels, heavy_equipment, south_reach, tractus]
decision_refs: [D-175]
cross_refs: []
---
+2 -2
View File
@@ -9,8 +9,8 @@ updated: 2026-04-19
scope: regional
faction_type: economic
headquarters: Nyrheim (GJ 3737)
tags: [terroir, west_reach, mark, scarcity_class_rare]
decision_refs: [D-189]
tags: [water, terroir, west_reach, mark, scarcity_class_rare]
decision_refs: [D-175, D-189]
cross_refs: []
---
+2 -2
View File
@@ -9,8 +9,8 @@ updated: 2026-03-16
scope: north_reach primary; transit corridor fashion markets secondary
faction_type: economic
headquarters: Braemar (GJ 475)
tags: []
decision_refs: []
tags: [textiles, clothing, north_reach, tractus, cooperative]
decision_refs: [D-175]
cross_refs: [calloway-distillery, mercado-travessia]
---
@@ -0,0 +1,36 @@
---
title: "Threshold Fuel Syndicate"
description: "Ice harvesting and fusion fuel production at Tau Ceti — the corridor's most reliable frontier fuel supplier, operating from Threshold's outer ice bodies where water supply is consistent and competition is thin"
slug: threshold-fuel-syndicate
category: corporation
status: canonical
created: 2026-04-21
updated: 2026-04-21
scope: tau_ceti local; east corridor secondary
faction_type: economic
headquarters: Threshold (GJ 71)
tags: [fusion_fuel, water, ice_harvesting, frontier, independent]
decision_refs: [D-175]
cross_refs: []
---
# Threshold Fuel Syndicate
**Type:** Corporation — Ice Harvesting and Fusion Fuel Refinery
**Also Known As:** Threshold Fuel, TFS
**Status:** Canonical
**Scope:** Tau Ceti primary; corridor fuel supply secondary
**Headquarters:** Threshold (GJ 71, Tau Ceti system) — outer system operations
**Classification:** Resource extraction syndicate; monopolist behavioral archetype (local)
---
## Overview
Tau Ceti's outer ice bodies contain one of the most accessible water reserves in the east corridor. Threshold Fuel Syndicate was formed by a consortium of Threshold settlers who realized that controlling that water supply meant controlling fuel production for every ship passing through.
Three generations later, TFS operates a vertically integrated operation: ice extraction, water processing, and fusion fuel refinery all under one contract structure. Local competitors have tried and withdrawn; the capital cost of orbital ice-cracking infrastructure is a high barrier.
**Primary operations:** Comet and ice-body water extraction, electrolytic processing, fusion fuel synthesis at Threshold orbital platform.
**Market position:** Dominant fuel supplier for Tau Ceti system; significant spot-market presence in the east corridor where Lagrange Fuel Systems has thinner coverage.
+2 -2
View File
@@ -9,8 +9,8 @@ updated: 2026-04-19
scope: regional
faction_type: economic
headquarters: Miryang (GJ 754)
tags: [tech_premium, east_reach, tractus, electronics]
decision_refs: [D-189]
tags: [drive_cores, tech_premium, east_reach, tractus, electronics]
decision_refs: [D-175, D-189]
cross_refs: []
---