diff --git a/CHANGELOG.md b/CHANGELOG.md index b7f20483b..c1ffa8447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `** (#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 diff --git a/Makefile b/Makefile index 2f3ac9a94..57ae9ea82 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/decisions/content.md b/decisions/content.md index a60c55b87..610e05fc1 100644 --- a/decisions/content.md +++ b/decisions/content.md @@ -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\): 8 moods for v0.1 — D-028 Layer 4 weighted selection - `tags` (list\): 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 diff --git a/decisions/perception.md b/decisions/perception.md index 23cbcfcc6..fc2ad0bec 100644 --- a/decisions/perception.md +++ b/decisions/perception.md @@ -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). diff --git a/docs/architecture/sprint-36-bookmark-spec.md b/docs/architecture/sprint-36-bookmark-spec.md index f36e639e8..e9f1ea2f7 100644 --- a/docs/architecture/sprint-36-bookmark-spec.md +++ b/docs/architecture/sprint-36-bookmark-spec.md @@ -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, pub starting_location_id: Option, @@ -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 #` -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 diff --git a/docs/architecture/sprint-37-878-audit.md b/docs/architecture/sprint-37-878-audit.md new file mode 100644 index 000000000..6986dda21 --- /dev/null +++ b/docs/architecture/sprint-37-878-audit.md @@ -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 1–3 +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. diff --git a/server/content/modules/tier1/smuggling_ring_v0_1.yaml b/server/content/modules/tier1/smuggling_ring_v0_1.yaml deleted file mode 100644 index 9e968575c..000000000 --- a/server/content/modules/tier1/smuggling_ring_v0_1.yaml +++ /dev/null @@ -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." diff --git a/server/content/schemas/drama_module.schema.yaml b/server/content/schemas/drama_module.schema.yaml deleted file mode 100644 index 93c2df1e5..000000000 --- a/server/content/schemas/drama_module.schema.yaml +++ /dev/null @@ -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 (1–10). 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 diff --git a/server/data/systems.db b/server/data/systems.db index 815b849e0..feabcec23 100644 Binary files a/server/data/systems.db and b/server/data/systems.db differ diff --git a/server/src/bookmark/mod.rs b/server/src/bookmark/mod.rs index 23e21b254..30aec9153 100644 --- a/server/src/bookmark/mod.rs +++ b/server/src/bookmark/mod.rs @@ -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, } @@ -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, pub starting_location_id: Option, @@ -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::() .add_systems(Startup, prime_initial_catalog); diff --git a/server/src/bridge/local.rs b/server/src/bridge/local.rs index 549a287ee..30d38a657 100644 --- a/server/src/bridge/local.rs +++ b/server/src/bridge/local.rs @@ -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(()) } diff --git a/server/src/bridge/tcp.rs b/server/src/bridge/tcp.rs index 8fffb9b6d..a3df7c985 100644 --- a/server/src/bridge/tcp.rs +++ b/server/src/bridge/tcp.rs @@ -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(()) } diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index bbe7c05d2..742999df4 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -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, diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index d5d6a4b2d..6d6cf17f3 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -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::(&bytes); assert!( diff --git a/server/src/main.rs b/server/src/main.rs index c07ba57cc..133c613f1 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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(), diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 950e2dd89..32afa9194 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -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, - 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; diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index 4ee5f93d6..9ca572f89 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -59,7 +59,6 @@ fn player_always_visible_in_snapshot() { let buffer = world.resource::(); 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::(); 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::(); - 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::(); - 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::(); - 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::(); - 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::(); + 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::(); + 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" + ); + } +} diff --git a/server/src/simulation/examine.rs b/server/src/simulation/examine.rs index c13943fa3..e07c7b1ee 100644 --- a/server/src/simulation/examine.rs +++ b/server/src/simulation/examine.rs @@ -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, @@ -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}" diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index 35c81dd3b..427c19038 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -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), } diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index e9680777d..2b853ee85 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -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, - /// 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::() + .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::(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::().tick = 10; + { + let mut state = world.get_mut::(player).unwrap(); + state.last_fired_tick = 3; + } + + world + .resource_mut::() + .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::(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 + ); + } } diff --git a/server/src/simulation/save_io.rs b/server/src/simulation/save_io.rs index f101421b2..f78605290 100644 --- a/server/src/simulation/save_io.rs +++ b/server/src/simulation/save_io.rs @@ -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::() .and_then(|a| a.last_activation_tick), + selected_bookmark: world + .get_resource::() + .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::().bookmark_id.is_none(), + "bookmark must be cleared before load" + ); + + load_from_file(&path, &mut world).expect("load"); + + let restored = world.resource::(); + 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::(); + 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); + } } diff --git a/server/src/simulation/save_state.rs b/server/src/simulation/save_state.rs index 685c5c810..637209ca5 100644 --- a/server/src/simulation/save_state.rs +++ b/server/src/simulation/save_state.rs @@ -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, + /// 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) // ----------------------------------------------------------------------- diff --git a/server/src/storyteller/mod.rs b/server/src/storyteller/mod.rs index 8d3255c41..bcab0ce07 100644 --- a/server/src/storyteller/mod.rs +++ b/server/src/storyteller/mod.rs @@ -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 ); diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index 196d0ac25..c19831ed1 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -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::(); // 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::(); // 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::(); // 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::(); diff --git a/server/tests/archetype_monologue.rs b/server/tests/archetype_monologue.rs deleted file mode 100644 index b1fb8669a..000000000 --- a/server/tests/archetype_monologue.rs +++ /dev/null @@ -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>(); - 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>(); - 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>(); - 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>(); - 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)" - ); - } -} diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index 2d47d4c72..a9182648c 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -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, diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 299cf24e0..8dac6af4b 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -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, diff --git a/server/tests/environmental_interaction.rs b/server/tests/environmental_interaction.rs index 0ab87a443..637bf48bc 100644 --- a/server/tests/environmental_interaction.rs +++ b/server/tests/environmental_interaction.rs @@ -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(), } } diff --git a/server/tests/error_handling.rs b/server/tests/error_handling.rs index bf1086e40..8ebafd5d4 100644 --- a/server/tests/error_handling.rs +++ b/server/tests/error_handling.rs @@ -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, diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs index 61e903de2..2b2c08f03 100644 --- a/server/tests/game_loop.rs +++ b/server/tests/game_loop.rs @@ -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); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index f8079fff7..bbb33c75d 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -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) -> 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, diff --git a/server/tests/gen_gauntlet_fixtures.rs b/server/tests/gen_gauntlet_fixtures.rs index aa6cb05d1..68f777380 100644 --- a/server/tests/gen_gauntlet_fixtures.rs +++ b/server/tests/gen_gauntlet_fixtures.rs @@ -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 } diff --git a/server/tests/information_boundaries.rs b/server/tests/information_boundaries.rs index fb9325efc..5d52147f3 100644 --- a/server/tests/information_boundaries.rs +++ b/server/tests/information_boundaries.rs @@ -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. diff --git a/server/tests/ipc_bench.rs b/server/tests/ipc_bench.rs index bb4a6e1e7..b103cb365 100644 --- a/server/tests/ipc_bench.rs +++ b/server/tests/ipc_bench.rs @@ -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, diff --git a/server/tests/layer3.rs b/server/tests/layer3.rs index dc7832caa..c528752fa 100644 --- a/server/tests/layer3.rs +++ b/server/tests/layer3.rs @@ -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" diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index b783c5d2d..4bf12ac2e 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -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) -> 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::(&bytes).unwrap_or_else(|e| { panic!("deserialize boundary snapshot fixture {}: {}", name, e) }); } else if name.starts_with("snapshot") { - let snap = rmp_serde::from_slice::(&bytes) + rmp_serde::from_slice::(&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::>(&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 diff --git a/server/tests/tell_escalation.rs b/server/tests/tell_escalation.rs index a30f75555..e83c3111e 100644 --- a/server/tests/tell_escalation.rs +++ b/server/tests/tell_escalation.rs @@ -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 } diff --git a/server/tests/v01_integration_playthrough.rs b/server/tests/v01_integration_playthrough.rs deleted file mode 100644 index 2856a8292..000000000 --- a/server/tests/v01_integration_playthrough.rs +++ /dev/null @@ -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, - writer: BufWriter, -} - -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::().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) -> 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(); -} diff --git a/tests/run-all b/tests/run-all index 6d7706667..9c4b80842 100755 --- a/tests/run-all +++ b/tests/run-all @@ -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) diff --git a/tests/run-atlas-determinism b/tests/run-atlas-determinism new file mode 100755 index 000000000..10e49180e --- /dev/null +++ b/tests/run-atlas-determinism @@ -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 diff --git a/tooling/planet-gen/gemma_naming.py b/tooling/planet-gen/gemma_naming.py index 494d59369..d7b8d35cc 100755 --- a/tooling/planet-gen/gemma_naming.py +++ b/tooling/planet-gen/gemma_naming.py @@ -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. "Aldren–Forgehaven Corridor" for core, + # "Matamba–Dakar 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] = { diff --git a/tooling/planet-gen/naming_core.py b/tooling/planet-gen/naming_core.py index 67a7c1e73..f7837f587 100644 --- a/tooling/planet-gen/naming_core.py +++ b/tooling/planet-gen/naming_core.py @@ -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) diff --git a/wiki/corporations/arbour-aggregates.md b/wiki/corporations/arbour-aggregates.md new file mode 100644 index 000000000..f67fd9a23 --- /dev/null +++ b/wiki/corporations/arbour-aggregates.md @@ -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. diff --git a/wiki/corporations/baektu-mining-alliance.md b/wiki/corporations/baektu-mining-alliance.md index 0d8562ef7..9263565b4 100644 --- a/wiki/corporations/baektu-mining-alliance.md +++ b/wiki/corporations/baektu-mining-alliance.md @@ -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: [] --- diff --git a/wiki/corporations/bergkraft-antriebswerke.md b/wiki/corporations/bergkraft-antriebswerke.md index b47510fbd..5f8dc5eb7 100644 --- a/wiki/corporations/bergkraft-antriebswerke.md +++ b/wiki/corporations/bergkraft-antriebswerke.md @@ -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: [] --- diff --git a/wiki/corporations/bifrost-marmor.md b/wiki/corporations/bifrost-marmor.md index c1ea70896..216806787 100644 --- a/wiki/corporations/bifrost-marmor.md +++ b/wiki/corporations/bifrost-marmor.md @@ -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: [] --- diff --git a/wiki/corporations/earth-standard-group.md b/wiki/corporations/earth-standard-group.md new file mode 100644 index 000000000..05658fefb --- /dev/null +++ b/wiki/corporations/earth-standard-group.md @@ -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. diff --git a/wiki/corporations/gate-corporation.md b/wiki/corporations/gate-corporation.md index 2ca61d2be..2318d3865 100644 --- a/wiki/corporations/gate-corporation.md +++ b/wiki/corporations/gate-corporation.md @@ -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: [] --- diff --git a/wiki/corporations/hanyang-precision.md b/wiki/corporations/hanyang-precision.md index 9adb394e8..ae0997ff2 100644 --- a/wiki/corporations/hanyang-precision.md +++ b/wiki/corporations/hanyang-precision.md @@ -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: [] --- diff --git a/wiki/corporations/jeju-lattice.md b/wiki/corporations/jeju-lattice.md index 17b63ecc6..1b163bf82 100644 --- a/wiki/corporations/jeju-lattice.md +++ b/wiki/corporations/jeju-lattice.md @@ -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: [] --- diff --git a/wiki/corporations/nordmark-skog.md b/wiki/corporations/nordmark-skog.md index 7802b1554..416225722 100644 --- a/wiki/corporations/nordmark-skog.md +++ b/wiki/corporations/nordmark-skog.md @@ -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: [] --- diff --git a/wiki/corporations/norrland-woodcraft.md b/wiki/corporations/norrland-woodcraft.md index dc070b29b..672190211 100644 --- a/wiki/corporations/norrland-woodcraft.md +++ b/wiki/corporations/norrland-woodcraft.md @@ -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: [] --- diff --git a/wiki/corporations/rush-mining.md b/wiki/corporations/rush-mining.md new file mode 100644 index 000000000..b122cf289 --- /dev/null +++ b/wiki/corporations/rush-mining.md @@ -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. diff --git a/wiki/corporations/salud-alliance.md b/wiki/corporations/salud-alliance.md index eac8af32f..3d0e6ca1c 100644 --- a/wiki/corporations/salud-alliance.md +++ b/wiki/corporations/salud-alliance.md @@ -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: [] --- diff --git a/wiki/corporations/scapa-flow-industries.md b/wiki/corporations/scapa-flow-industries.md new file mode 100644 index 000000000..ace890def --- /dev/null +++ b/wiki/corporations/scapa-flow-industries.md @@ -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. diff --git a/wiki/corporations/sede-chemical-works.md b/wiki/corporations/sede-chemical-works.md new file mode 100644 index 000000000..7c5afac80 --- /dev/null +++ b/wiki/corporations/sede-chemical-works.md @@ -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. diff --git a/wiki/corporations/shetland-wool.md b/wiki/corporations/shetland-wool.md index 5221466d6..927d1a3f4 100644 --- a/wiki/corporations/shetland-wool.md +++ b/wiki/corporations/shetland-wool.md @@ -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: [] --- diff --git a/wiki/corporations/societe-chimique.md b/wiki/corporations/societe-chimique.md index 9112ea46c..d75046f0d 100644 --- a/wiki/corporations/societe-chimique.md +++ b/wiki/corporations/societe-chimique.md @@ -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: [] --- diff --git a/wiki/corporations/stalownia-kowalski.md b/wiki/corporations/stalownia-kowalski.md index 6b6b39e2d..2ab212985 100644 --- a/wiki/corporations/stalownia-kowalski.md +++ b/wiki/corporations/stalownia-kowalski.md @@ -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: [] --- diff --git a/wiki/corporations/svanevann-waters.md b/wiki/corporations/svanevann-waters.md index 5c19f7b40..32b118970 100644 --- a/wiki/corporations/svanevann-waters.md +++ b/wiki/corporations/svanevann-waters.md @@ -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: [] --- diff --git a/wiki/corporations/thrds.md b/wiki/corporations/thrds.md index 812260cbe..a78dc1f50 100644 --- a/wiki/corporations/thrds.md +++ b/wiki/corporations/thrds.md @@ -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] --- diff --git a/wiki/corporations/threshold-fuel-syndicate.md b/wiki/corporations/threshold-fuel-syndicate.md new file mode 100644 index 000000000..ff6f1dc04 --- /dev/null +++ b/wiki/corporations/threshold-fuel-syndicate.md @@ -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. diff --git a/wiki/corporations/tongyeong-drive.md b/wiki/corporations/tongyeong-drive.md index a901e0345..724df7438 100644 --- a/wiki/corporations/tongyeong-drive.md +++ b/wiki/corporations/tongyeong-drive.md @@ -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: [] ---