Merge remote-tracking branch 'origin/sprint-37/server'
This commit is contained in:
@@ -12,9 +12,24 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- **`make check-systems-db`** — verifies the meta stamp matches current generator sources
|
||||
- **`make install-hooks`** — installs pre-push and pre-commit hooks in one step
|
||||
- **`tooling/db/decision show <D-NNN>`** (#723) — drill-down view of a decision with implementing tickets and cross-refs
|
||||
- **Atlas determinism smoke test** (#847) — `make test-atlas-determinism` runs `generate_atlas.process_body()` twice with a fixed seed and diffs the output to catch determinism regressions in terrain analysis, city placement, A* routing, and naming
|
||||
- **SelectedBookmark save/load** (#863) — bookmark and starting-location choice now persist across save/load; replaces the v0.2-deferred TODO on `SelectedBookmark`
|
||||
- **`BookmarkPlugin::new(registry)` injection** (#862) — test-friendly plugin construction for future TOML bookmark loading; default constructor still wires the canonical tycoon registry
|
||||
- **Six new corporation wiki pages** (#860) — Arbour Aggregates, Earth Standard Group, Rush Mining, Scapa Flow Industries, Sede Chemical Works, Threshold Fuel Syndicate
|
||||
- **Atlas naming corridor-scoped dedup, compass-direction filter, river vocab filter, infra pair-naming** (#853) — city and mountain names deduplicate across bodies within a corridor; compass-direction defaults blocked in the few-shot prompt; navigational vocabulary (`Flow`, `Current`) rejected for rivers; unnamed roads and railroads receive deterministic `{CityA}–{CityB} {corridor_suffix}` names
|
||||
|
||||
### Changed
|
||||
- `decisions-coverage` Makefile target now lists implementing ticket IDs per decision instead of aggregate counts
|
||||
- **Economy coverage gate** (#860) now passes end-to-end — closes the 21 raw-commodity / system gaps that blocked Phase 2 demand simulation
|
||||
- Tag updates on 15 existing corporation wiki pages to match commodity coverage needs
|
||||
|
||||
### Fixed
|
||||
- Storyteller `activation_pass` "no Simmering triangles — holding" no longer fires as `warn` during normal early-game state — downgraded to `debug` (#789)
|
||||
|
||||
### Removed
|
||||
- **`PROTOCOL_VERSION` lockstep handshake** (#874, D-192) — `HandshakeMessage` is now empty; schema drift surfaces as MessagePack missing-field errors downstream, which is the intended signal
|
||||
- **`HeritageRoot` type alias and `ZonePaletteModifier::Heritage` variant** (#877, D-167) — last stubs of the abstract heritage-root system retired in favour of the corridor cultural framework
|
||||
- **`CharacterArchetype` (Smuggler/Detective) trace from server** (#878) — enum, IPC field, verb-differentiation branch in the observer Phase 2 filter (D-057 superseded), monologue pool partitioning, Gauntlet plumbing, drama-module schema, archetype-dependent integration tests. Per the development cascade, character/NPC differentiation is Phase 6 work and the running trace was pre-cascade filler, not production. Client-side cleanup tracked in #882.
|
||||
|
||||
## [v0.1.36] — 2026-04-21
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -44,9 +44,10 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
|
||||
- **Dissent:** None
|
||||
- **Amendment (2026-03-05, Where's the Fun? Workshop):** The 30/50/20 population split rationale survives but implementation context changes. [D-122](#d-122-all-npcs-generated--no-named-hand-authored-characters) (all NPCs generated) means no NPC is hand-authored. The "entangled 20%" are generated NPCs whose triangles happen to be flagged for intrigue content. For the tycoon v0.2 bookmark ([D-117](scope.md#d-117-tycoon-is-the-v02-bookmark--zero-investigation-content)), the split applies to economic, social, and mundane triangles rather than investigation-intrigue triangles. The specific ratios will be revisited after the generator spike ([D-119](scope.md#d-119-generator-spike-confirmed-for-sprint-25--critical-path)) proves what population density the generator can sustain.
|
||||
|
||||
### D-032: Separate monologue pools per character [SUPERSEDED]
|
||||
### D-032: Separate monologue pools per character [SUPERSEDED — deferred to Phase 6]
|
||||
- **Date:** 2026-02-11
|
||||
- **Superseded by:** [D-117](scope.md#d-117-tycoon-is-the-v02-bookmark--zero-investigation-content) (single tycoon character in v0.2 eliminates the smuggler/detective hard partition). The principle of character-specific monologue pools survives — the tycoon has their own monologue pool. The hard partition between smuggler and detective does not apply when there is only one playable character. Per [D-127](#d-127-player-choices-are-the-content--rimworld-model-job-as-rails), the player's monologue reflects their character background. The partition design is preserved as a pattern for when multiple playable characters are reintroduced.
|
||||
- **Superseded by:** Development cascade (CLAUDE.md) — character/NPC monologue content is Phase 6 detail-coloring, below the current Phase 1 (wiki content). The smuggler/detective archetype enum and its hard-partitioned monologue pools were pre-cascade scaffolding and have been fully stripped from the server codebase (Sprint 37, #878, PR #137). The original D-117 supersession framing (single tycoon character in v0.2) is itself obsolete now that v0.2 is dropped (CLAUDE.md: "v0.2 target is dropped"). The partition *design pattern* is preserved in this record for when culture-driven / generator-produced monologue is reintroduced in Phase 6, but no corresponding code or content exists today.
|
||||
- **Amendment (2026-04-22, Sprint 37, PR #137):** Monologue pool selection is now unkeyed by archetype until a Phase 6 character system exists. `MonologueState.character` field and `CharacterArchetype` enum deleted from `server/src/bridge/types.rs`, `server/src/simulation/monologue.rs`, observer relabeling logic, and `server/content/modules/tier1/smuggling_ring_v0_1.yaml`. Uniform single-pool behavior is the intended end state pre-Phase-6 — not scaffolding deferred in place with a stub, but retired pending the real character system. Lead override recorded in `docs/architecture/sprint-37-878-audit.md` (2026-04-21 override section). Reintroduction gate: a confirmed Phase 6 character-model design is a prerequisite before this decision is revived.
|
||||
- **Decision:** Internal monologue content is hard-partitioned by playable character. The smuggler and detective have completely separate monologue pools — no shared lines. The `character` tag on monologue lines is a hard partition, not a filter. File structure uses separate files per character per location (e.g., `monologue-smuggler.yaml`, `monologue-detective.yaml`).
|
||||
- **Rationale:** Shared monologue would dilute character voice and undermine the dual-lens experience. Each character's internal voice must be independently coherent. Same trigger, different pool — this is how mirror moments work without either pool knowing about the other.
|
||||
- **Cross-reference:** Dialogue lines remain character-agnostic — the access tier system (D-028 Layer 1) handles per-character filtering without separate pools.
|
||||
@@ -80,7 +81,7 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
|
||||
- `mood` (list\<enum\>): 8 moods for v0.1 — D-028 Layer 4 weighted selection
|
||||
- `tags` (list\<string\>): freeform escape hatch for author intent
|
||||
- **Monologue-specific additions:**
|
||||
- `character` (enum): `smuggler`, `detective` — hard partition per D-032 **[Obsolete post-D-117: smuggler/detective eliminated. v0.2 uses culture-driven voice per D-121; this enum is unused.]**
|
||||
- `character` (enum): historically `smuggler`, `detective` — removed. Per [D-032 SUPERSEDED] and the development cascade (CLAUDE.md), character-partitioned monologue is Phase 6 and has been stripped from the codebase (Sprint 37, #878). Field is unused; do not reintroduce without a confirmed Phase 6 design.
|
||||
- `trigger` (enum): 9 trigger types (enter_location, observe_npc, hear_sound, observe_anomaly, post_conversation, discover_evidence, witness_interaction, time_idle, return_visit)
|
||||
- `prerequisite` (map or null): knowledge state gate
|
||||
- **Authoring-only tags (not consumed by engine):** `dual_lens` (map, per-character notes), `notes` (string)
|
||||
@@ -92,6 +93,7 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
|
||||
- **Amendment (Sprint 14):** Mood vocabulary renamed to match voice guide (monologue-voice-guide.md). Old → new: `fond`→`warm`, `comfortable`→`content`, `worried`→`anxious`, `concerned`→`frustrated`. Dropped: `analytical` (merged into `focused`), `conflicted` (modeled as `suspicious`+`warm` collision). Added: `hostile`. Final 8 moods: `anxious`, `frustrated`, `content`, `suspicious`, `warm`, `hostile`, `relieved`, `focused`. Neutral = untagged.
|
||||
- **Amendment (Sprint 24):** `triangle_activated` added as 15th situation (fires post-TriangleActivated when player observes anchor NPCs). Two freeform tags registered as conventions: `triangle-signal` (line is part of the triangle activation sequence) and `tell-observation` (line observes a behavioral tell without naming its cause). `npc_in_los` prerequisite added for LOS-gated monologue lines. Schema updated to match.
|
||||
- **Amendment (Sprint 15):** Line ID namespace changed from location-scoped to NPC-scoped. Old scheme: `{location_slug}_{d|m}_{###}` (e.g., `the-terminal_d_039`) — all NPCs at a location share one ID sequence, requiring cross-file coordination and causing collisions at scale. New scheme: `{npc-slug}_{d|m}_{###}` for dialogue, `{npc-slug}_m_{s|d}_{###}` for monologue (e.g., `kael-davan_d_001`, `dock-worker_d_001`). Each NPC's IDs are independent — no cross-file coordination needed. Auto-generated NPCs use their generated slug. Schema regex patterns unchanged (prefix is still `^[a-z][a-z0-9-]*`), only the `description` field and convention documentation update. Migration: mechanical rename of all existing line IDs across ~20 dialogue files and monologue pools.
|
||||
- **Amendment (2026-04-22, Sprint 37, PR #137):** The `character` monologue-specific tag (historically `smuggler | detective`) is retired pending a Phase 6 character-model design — not deferred in place with a stub enum. Per the development cascade (CLAUDE.md), character-partitioned monologue is Phase 6 detail-coloring; the codebase has been stripped of the `CharacterArchetype` enum and the `MonologueState.character` field that keyed pool selection (#878). Pool selection is now archetype-independent. Authoring files that carry historical `character:` tags are content artifacts and will be re-evaluated when the Phase 6 character system is designed; engine consumption of the field is gone. Reintroduction gate: a confirmed Phase 6 character-model design is a prerequisite. See `docs/architecture/sprint-37-878-audit.md` (lead override section) for the cascade rationale.
|
||||
|
||||
### D-036: Sova Transit District / Van Maanen's Star as v0.1 setting
|
||||
- **Date:** 2026-02-11
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -213,15 +213,12 @@ Note: `ConfirmBookmark` is the **trigger** for transitioning from the character-
|
||||
|
||||
```rust
|
||||
/// The confirmed bookmark selection for the current session.
|
||||
/// Populated when `ConfirmBookmark` is processed. `None` during the
|
||||
/// character-creation phase (before confirm) and always `None` in a
|
||||
/// fresh session.
|
||||
/// Populated when `ConfirmBookmark` is processed. `None` fields during the
|
||||
/// character-creation phase (before confirm) and in a fresh session.
|
||||
///
|
||||
/// **v0.2 scope: transient only.** Not serialized — save/load of
|
||||
/// `SelectedBookmark` is deferred to Sprint 37 (follow-up ticket
|
||||
/// filed alongside #614). Add `Serialize`/`Deserialize` derives and
|
||||
/// wire into `SaveState` when that ticket is claimed.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
/// Serialized into `SaveStateV1.selected_bookmark` (#863) so that a loaded
|
||||
/// game remembers which bookmark and starting location were chosen.
|
||||
#[derive(Resource, Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SelectedBookmark {
|
||||
pub bookmark_id: Option<String>,
|
||||
pub starting_location_id: Option<String>,
|
||||
@@ -231,15 +228,11 @@ pub struct SelectedBookmark {
|
||||
Downstream systems (apartment generator, skill seeder) read from this
|
||||
resource.
|
||||
|
||||
**Save/load scope (v0.2 deferred):** `SelectedBookmark` is transient for
|
||||
v0.2 — it lives in-memory from `ConfirmBookmark` through session end and
|
||||
is not persisted. A reload after quit returns the player to the
|
||||
character-creation screen. Promotion to persistent state (adding
|
||||
`Serialize`/`Deserialize` and threading into `SaveState` / #553) is
|
||||
tracked in a follow-up ticket for Sprint 37. `SelectedBookmark` must
|
||||
carry an inline `// TODO(sprint-37): serialize — see #<follow-up ticket>`
|
||||
comment in `server/src/bookmark/mod.rs` pointing at the follow-up so the
|
||||
omission is greppable.
|
||||
**Save/load scope (Sprint 37, #863):** `SelectedBookmark` is persisted into
|
||||
`SaveStateV1.selected_bookmark`. After `load_from_file` completes, the resource
|
||||
reflects the bookmark confirmed at session-start. Saves created before Sprint 37
|
||||
will deserialize the field as `SelectedBookmark::default()` (both fields `None`)
|
||||
via `#[serde(default)]` on the `SaveStateV1` field.
|
||||
|
||||
## 5. Content source — how bookmarks get into the registry
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -1,692 +0,0 @@
|
||||
# yaml-language-server: $schema=../../schemas/drama_module.schema.yaml
|
||||
#
|
||||
# Tier 1 Drama Module: The Smuggling Ring (v0.1)
|
||||
# The vertical slice Tier 1 module for D-027.
|
||||
#
|
||||
# NARRATIVE CORE:
|
||||
# A logistics worker (the smuggler PC, if played) is embedded in a small ring
|
||||
# smuggling unlicensed lattice components through Sova Transit District (D-037).
|
||||
# The ring is led by Voss from The Terminal. Kael Davan — a ring member and the
|
||||
# smuggler's FRIEND — is quietly trying to exit to protect his partner Naia Tamm.
|
||||
# Sera Venn (the detective's FRIEND) has noticed Kael's manifest discrepancies
|
||||
# but hasn't reported them, protecting Naia by proxy.
|
||||
#
|
||||
# DUAL-LENS EXPERIENCE:
|
||||
# Smuggler plays INSIDE the ring: manage drops, cover tracks, notice Kael going cold.
|
||||
# Detective plays OUTSIDE: cargo anomalies → follow Kael → witness secret meeting →
|
||||
# confront or protect.
|
||||
#
|
||||
# SUCCESS CRITERIA (D-027):
|
||||
# #1: 30 minutes of daily-life play before the ring activates (min_play_ticks: 2100)
|
||||
# #3: Player names Kael as an NPC they felt conflicted about
|
||||
# #4: observe→notice→follow→discover emerges from systems, not scripts
|
||||
|
||||
module_id: smuggling_ring_v0_1
|
||||
display_name: "The Smuggling Ring"
|
||||
version: "0.1"
|
||||
tier: 1
|
||||
description: >
|
||||
A small ring of logistics workers smuggling unlicensed lattice components through
|
||||
Sova Transit District. The ring's weakest link — Kael Davan — is trying to exit
|
||||
to protect his partner. The detective investigates cargo anomalies. The smuggler
|
||||
manages ring operations and navigates Kael's loyalty crisis. Neither character
|
||||
knows the other's full picture until confrontation forces it.
|
||||
|
||||
notes: >
|
||||
This module IS the vertical slice (D-027). It exercises every system at full depth:
|
||||
dual-lens NPC observation, tell progression, trust-gated dialogue, knowledge graph
|
||||
confidence accumulation, confrontation weight (D-063), walk-away consequences (D-064),
|
||||
and THE FRIEND contradiction arc (D-034). All outcome paths must feel earned.
|
||||
No outcome is "the right answer" — Kael's situation has no clean resolution.
|
||||
|
||||
dual_lens:
|
||||
smuggler: >
|
||||
You're inside the ring. Voss manages operations; you handle logistics cover.
|
||||
Kael used to be reliable. Lately he's absent, distracted, making excuses.
|
||||
The drop schedule is at risk. Do you pressure him, cover for him, or cut him?
|
||||
You don't know he's trying to get out. He doesn't know you've noticed.
|
||||
detective: >
|
||||
Cargo manifest discrepancies in The Terminal. Small, systematic, deniable.
|
||||
Your analytical lattice flags them before your conscious mind does.
|
||||
Follow the thread: discrepancy → dock worker with odd schedule → Kael Davan →
|
||||
maintenance corridors → someone he shouldn't be meeting. And then what?
|
||||
Arrest a man trying to leave a ring he never wanted to join?
|
||||
|
||||
pool:
|
||||
weight: 8
|
||||
compatible_districts:
|
||||
- sova-transit
|
||||
max_concurrent: 1
|
||||
|
||||
# ── ENTRY CONDITIONS ─────────────────────────────────────────────────────────
|
||||
# Ring activity begins after player has had time to establish routine (D-027 #1).
|
||||
# The ring is already running at game start — the module activates when the
|
||||
# storyteller decides the tension has built enough to surface.
|
||||
|
||||
entry_conditions:
|
||||
world_state:
|
||||
- type: npc_present
|
||||
role: ring-leader
|
||||
- type: npc_present
|
||||
role: ring-member-exiting
|
||||
- type: location_accessible
|
||||
location: the-terminal
|
||||
- type: location_accessible
|
||||
location: maintenance-corridors
|
||||
|
||||
activation:
|
||||
trigger: storyteller_push
|
||||
min_play_ticks: 2100 # ~35 minutes at 1 tick/second — D-027 criterion #1
|
||||
# The storyteller pushes activation when player has established presence
|
||||
# in The Terminal or The Last Shift through routine interaction.
|
||||
# Proximity trigger (maintenance-corridors) is a secondary activation path
|
||||
# if the player wanders there early.
|
||||
|
||||
# ── NPC REQUIREMENTS ─────────────────────────────────────────────────────────
|
||||
# All core roles are named (hand-authored NPCs from the vertical slice).
|
||||
# No generated NPC slots in v0.1 — the smuggling ring uses the 15 authored NPCs.
|
||||
|
||||
npc_requirements:
|
||||
- role: ring-leader
|
||||
display_hint: >
|
||||
Runs the ring from The Terminal. Logistics authority = cover.
|
||||
Never handles contraband directly. Pressure source for Kael.
|
||||
binding: named
|
||||
named_npc: "npc:voss"
|
||||
must_have_motivation: HANDLER
|
||||
|
||||
- role: ring-member-exiting
|
||||
display_hint: >
|
||||
Kael Davan. Dock worker, ring member, smuggler's FRIEND.
|
||||
Trying to exit quietly to protect Naia. This is THE FRIEND contradiction.
|
||||
Every event sequence runs through this role.
|
||||
binding: named
|
||||
named_npc: "npc:kael-davan"
|
||||
must_have_pattern: FRIEND
|
||||
must_have_motivation: TURNCOAT
|
||||
|
||||
- role: partner-uninvolved
|
||||
display_hint: >
|
||||
Naia Tamm. Kael's partner. Does not know about the ring.
|
||||
Her safety is Kael's motivation for exiting. Her ignorance is the moral weight.
|
||||
Discovery of her connection to Kael is a late-investigation revelation.
|
||||
binding: named
|
||||
named_npc: "npc:naia-tamm"
|
||||
must_have_motivation: CIVILIAN
|
||||
|
||||
- role: evidence-holder
|
||||
display_hint: >
|
||||
Sera Venn. Detective's FRIEND. Commission field tech.
|
||||
She has noticed Kael's manifest discrepancies but hasn't reported them —
|
||||
she knows Naia, and filing means Kael's arrest and Naia's exposure.
|
||||
Her silence IS the detective's investigation blocker in phase 1.
|
||||
binding: named
|
||||
named_npc: "npc:sera-venn"
|
||||
must_have_pattern: FRIEND
|
||||
must_have_motivation: WITNESS
|
||||
|
||||
- role: ring-operative
|
||||
display_hint: >
|
||||
The ring's operational member in maintenance corridors.
|
||||
Handles physical drops. Not a speaking character — observable behavior only.
|
||||
Can be the anonymous contact Kael meets.
|
||||
binding: named
|
||||
named_npc: "npc:nils-davan"
|
||||
is_optional: false
|
||||
|
||||
- role: institutional-watcher
|
||||
display_hint: >
|
||||
Maret Korr. A Commission observer embedded at The Terminal.
|
||||
Her growing attention is the external pressure that accelerates the timeline.
|
||||
She doesn't know about the ring specifically — she's tracking cargo patterns.
|
||||
binding: named
|
||||
named_npc: "npc:maret-korr"
|
||||
must_have_motivation: OPERATOR
|
||||
is_optional: true # Module runs without Maret, but with degraded tension arc
|
||||
|
||||
# ── EVENTS ───────────────────────────────────────────────────────────────────
|
||||
# Two sequences + one pool.
|
||||
# Sequence A: Kael's exit arc (the FRIEND contradiction backbone)
|
||||
# Sequence B: Investigation pressure arc (escalating discovery opportunities)
|
||||
# Pool: ambient ring activity (fires opportunistically throughout the module)
|
||||
|
||||
events:
|
||||
|
||||
sequences:
|
||||
|
||||
# SEQUENCE A: Kael's Exit Arc
|
||||
# The narrative spine. Each step makes Kael's contradiction more visible.
|
||||
# Observable to both characters, interpreted differently.
|
||||
|
||||
- sequence_id: kael_exit_arc
|
||||
label: "Kael's Exit Arc"
|
||||
description: >
|
||||
Kael Davan's progressive attempt to leave the ring.
|
||||
Tells intensify. Routine deviations appear. The secret meeting is the
|
||||
pivot point — after it fires, both characters' understanding shifts.
|
||||
steps:
|
||||
|
||||
- event_id: kael_goes_cold
|
||||
label: "Kael Goes Cold"
|
||||
description: >
|
||||
Kael starts missing social patterns he'd normally keep — fewer bar visits,
|
||||
shorter responses at The Terminal, leaving early. His tell system activates:
|
||||
the shoulder-check behavior appears. Nothing dramatic. Just absence where
|
||||
there was presence. The smuggler notices because they work together.
|
||||
The detective might notice if they've been tracking Kael's baseline.
|
||||
triggers:
|
||||
- type: ticks_since_activation
|
||||
ticks: 300 # ~5 minutes after module activates
|
||||
effects:
|
||||
- type: npc_routine_deviation
|
||||
npc_role: ring-member-exiting
|
||||
description: >
|
||||
Kael skips his usual post-shift drink at The Last Shift.
|
||||
Leaves the terminal 15 minutes early. No explanation.
|
||||
- type: tell_intensify
|
||||
npc_role: ring-member-exiting
|
||||
description: >
|
||||
Kael's shoulder-check behavior activates at The Terminal.
|
||||
Visible to any character with forward vision cone in his direction.
|
||||
sets_flag: kael_behavior_changed
|
||||
|
||||
- event_id: drop_happens_without_kael
|
||||
label: "Scheduled Drop — Kael Absent"
|
||||
description: >
|
||||
A ring drop occurs in maintenance corridor C-7. Kael was supposed
|
||||
to verify the cargo. He wasn't there. Nils covered it.
|
||||
The smuggler notices the irregularity in the paperwork.
|
||||
The detective — if watching cargo patterns — sees a manifest entry
|
||||
with no verifying signature where one is normally present.
|
||||
triggers:
|
||||
- type: ticks_since_event
|
||||
after_event: kael_goes_cold
|
||||
ticks: 450 # ~7.5 minutes after goes-cold
|
||||
effects:
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.kael_missed_verification"
|
||||
discoverable_by: any
|
||||
discovery_method: >
|
||||
Smuggler: check the cargo manifest in The Terminal office.
|
||||
Detective: analytical lattice flags unsigned verification entry.
|
||||
- type: location_state
|
||||
location: maintenance-corridors
|
||||
description: "An unsigned cargo verification entry exists in corridor C-7's log."
|
||||
sets_flag: kael_missed_drop
|
||||
|
||||
- event_id: kael_secret_meeting
|
||||
label: "Kael's Secret Meeting"
|
||||
description: >
|
||||
Kael meets an off-district contact in maintenance corridor B-7.
|
||||
This is the observable contradiction (D-034): Kael, in a restricted
|
||||
area he has no logged reason to be in, talking to someone who's
|
||||
not in any district NPC roster. His body language is tense.
|
||||
If the player is in visual range: this is the pivot moment.
|
||||
If not: the meeting happens anyway — the world doesn't wait.
|
||||
triggers:
|
||||
- type: ticks_since_event
|
||||
after_event: drop_happens_without_kael
|
||||
ticks: 600 # ~10 minutes after the dropped verification
|
||||
- type: player_proximity
|
||||
target_type: location
|
||||
target: maintenance-corridors
|
||||
radius_tiles: 12 # Player wandering near triggers the meeting early
|
||||
effects:
|
||||
- type: npc_routine_deviation
|
||||
npc_role: ring-member-exiting
|
||||
description: >
|
||||
Kael enters maintenance corridor B-7. Locked door to restricted
|
||||
supply closet. Emerges with the ring-operative 8 minutes later.
|
||||
Neither acknowledges the encounter publicly.
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.kael_unauthorized_corridor_access"
|
||||
discoverable_by: any
|
||||
discovery_method: >
|
||||
Player must be in visual range of corridor B-7.
|
||||
Or examine the corridor door access log (investigative action).
|
||||
- type: tell_intensify
|
||||
npc_role: ring-member-exiting
|
||||
description: >
|
||||
After the meeting, Kael's shoulder-check frequency doubles.
|
||||
Also: he avoids eye contact with the smuggler at The Terminal.
|
||||
sets_flag: secret_meeting_occurred
|
||||
|
||||
- event_id: kael_sends_message
|
||||
label: "Kael Sends the Message"
|
||||
description: >
|
||||
Kael sends an encrypted Meridian message to an off-district contact.
|
||||
The detective's analytical lattice can detect an anomalous outgoing
|
||||
packet from the district node — not the content, just the pattern
|
||||
(frequent, encrypted, sent from cargo bay terminals, not personal devices).
|
||||
The smuggler won't see this unless they're specifically watching Kael.
|
||||
triggers:
|
||||
- type: ticks_since_event
|
||||
after_event: kael_secret_meeting
|
||||
ticks: 200
|
||||
effects:
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.kael_unusual_meridian_activity"
|
||||
discoverable_by: detective
|
||||
discovery_method: >
|
||||
Detective's analytical lattice flags the outgoing packet pattern.
|
||||
Requires player to be in or adjacent to The Terminal cargo bay.
|
||||
sets_flag: kael_message_sent
|
||||
|
||||
- event_id: ring_leader_confronts_kael
|
||||
label: "Voss Confronts Kael"
|
||||
description: >
|
||||
Voss calls Kael into The Terminal supervisor's office.
|
||||
Closed door. Raised voices (audible only from adjacent room/position).
|
||||
Kael emerges pale. Voss emerges neutral. The smuggler can witness
|
||||
the approach/departure without hearing content. The detective can
|
||||
observe Kael's state immediately after if in The Terminal.
|
||||
This is Voss applying pressure. Kael is now visibly under strain.
|
||||
triggers:
|
||||
- type: flag_set
|
||||
flag: kael_message_sent
|
||||
- type: ticks_since_event
|
||||
after_event: kael_message_sent
|
||||
ticks: 400
|
||||
effects:
|
||||
- type: npc_routine_deviation
|
||||
npc_role: ring-leader
|
||||
description: "Voss calls Kael into the supervisor's office. Door closed."
|
||||
- type: npc_routine_deviation
|
||||
npc_role: ring-member-exiting
|
||||
description: >
|
||||
Kael emerges from the meeting looking strained. His shoulder-check
|
||||
is now constant. He takes an unscheduled break outside, alone.
|
||||
- type: tell_intensify
|
||||
npc_role: ring-member-exiting
|
||||
description: >
|
||||
Kael's contentment hits lowest observed level. He now actively avoids
|
||||
the ring-operative (Nils) in public. The disconnection is visible.
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.voss_kael_private_meeting"
|
||||
discoverable_by: any
|
||||
discovery_method: >
|
||||
Observe the meeting room door (spatial). Or ask Kael directly
|
||||
after (trust-gated dialogue unlocks "Are you alright?" option).
|
||||
sets_flag: voss_pressure_applied
|
||||
|
||||
# SEQUENCE B: Investigation Pressure Arc
|
||||
# External pressure that escalates the timeline.
|
||||
# Fires in parallel with Sequence A.
|
||||
|
||||
- sequence_id: investigation_pressure
|
||||
label: "Investigation Pressure Arc"
|
||||
description: >
|
||||
Maret Korr's institutional attention creates a closing window.
|
||||
Her growing interest is the reason the module can't stay in equilibrium forever.
|
||||
She doesn't know about the ring — she's a pattern-watcher. But patterns
|
||||
are what the detective investigates too. Their paths converge.
|
||||
steps:
|
||||
|
||||
- event_id: maret_flags_anomaly
|
||||
label: "Maret Flags the Cargo Anomaly"
|
||||
description: >
|
||||
Maret Korr files an internal Commission note flagging The Terminal's
|
||||
cargo variance rate as statistically unusual. Not an investigation —
|
||||
just a flag. The detective's institutional access can pull this note.
|
||||
The smuggler has no way to know it exists (unless the detective tells them).
|
||||
triggers:
|
||||
- type: ticks_since_activation
|
||||
ticks: 900 # ~15 minutes after activation
|
||||
effects:
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.commission_cargo_flag"
|
||||
discoverable_by: detective
|
||||
discovery_method: >
|
||||
Detective queries Commission data via institutional access
|
||||
(authority access tier, Terminal records).
|
||||
sets_flag: commission_flag_exists
|
||||
|
||||
- event_id: maret_increases_presence
|
||||
label: "Maret Increases Her Presence"
|
||||
description: >
|
||||
Maret starts spending more time in The Terminal. More frequent
|
||||
walkthroughs during shift changes. Her attention to the cargo bay
|
||||
area is noticeable to anyone watching. Ring members are unnerved.
|
||||
Voss starts accelerating the timeline to close operations before
|
||||
institutional attention becomes formal investigation.
|
||||
triggers:
|
||||
- type: ticks_since_event
|
||||
after_event: maret_flags_anomaly
|
||||
ticks: 600
|
||||
- type: player_action
|
||||
action: examine
|
||||
target_role: institutional-watcher
|
||||
effects:
|
||||
- type: npc_routine_deviation
|
||||
npc_role: institutional-watcher
|
||||
description: >
|
||||
Maret adds two extra Terminal walkthroughs per shift cycle.
|
||||
Spends 15 minutes studying the cargo bay manifest terminals.
|
||||
- type: tell_intensify
|
||||
npc_role: ring-leader
|
||||
description: >
|
||||
Voss becomes quieter, more deliberate. Less casual conversation.
|
||||
His tell — the stillness before speaking — becomes more frequent.
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.voss_accelerating_timeline"
|
||||
discoverable_by: any
|
||||
discovery_method: >
|
||||
Smuggler: Voss mentions "the schedule moving up" in a guarded
|
||||
conversation (trust-gated, ring-insider access required).
|
||||
Detective: observe Voss and Nils in two exchanges within same shift.
|
||||
sets_flag: timeline_accelerating
|
||||
|
||||
- event_id: final_shipment_scheduled
|
||||
label: "The Final Shipment Is Scheduled"
|
||||
description: >
|
||||
The ring schedules the last major drop — after this, they go dark.
|
||||
This is the closing window. If the detective hasn't uncovered enough
|
||||
by the time this fires, the ring disperses and the operation closes
|
||||
without exposure (escaped outcome). If they have, confrontation
|
||||
becomes unavoidable. The smuggler knows about this drop. Kael doesn't
|
||||
want to participate. Voss insists.
|
||||
triggers:
|
||||
- type: flag_set
|
||||
flag: timeline_accelerating
|
||||
- type: ticks_since_event
|
||||
after_event: maret_increases_presence
|
||||
ticks: 800
|
||||
effects:
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.final_shipment_scheduled"
|
||||
discoverable_by: any
|
||||
discovery_method: >
|
||||
Smuggler: direct notification from Voss.
|
||||
Detective: cargo manifest shows an unusual large entry for 3 days out.
|
||||
- type: npc_routine_deviation
|
||||
npc_role: ring-member-exiting
|
||||
description: >
|
||||
Kael's schedule changes: he's assigned to the cargo bay
|
||||
during the drop window. He doesn't want to be there.
|
||||
sets_flag: final_shipment_known
|
||||
|
||||
pools:
|
||||
|
||||
# POOL: Ambient ring activity — opportunistic events that add texture
|
||||
- pool_id: ambient_ring_activity
|
||||
label: "Ambient Ring Activity"
|
||||
description: >
|
||||
Low-level ring business that happens throughout the module regardless of
|
||||
player engagement. Creates the sense that the ring exists independently.
|
||||
Players who look closely will find more; players who don't still feel the world moving.
|
||||
events:
|
||||
- event_id: cargo_discrepancy_appears
|
||||
label: "Small Cargo Discrepancy Appears"
|
||||
description: >
|
||||
A minor manifest irregularity appears in The Terminal records.
|
||||
Small enough to be deniable. Systematic enough to be a pattern.
|
||||
The detective's analytical lattice may flag it. The smuggler can
|
||||
correct it if they notice it — covering tracks is part of their role.
|
||||
triggers:
|
||||
- type: ticks_since_activation
|
||||
ticks: 150 # Fires early and repeats
|
||||
effects:
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.cargo_discrepancy_pattern"
|
||||
discoverable_by: any
|
||||
discovery_method: >
|
||||
Detective: analytical lattice flags during Terminal walkthrough.
|
||||
Smuggler: check manifest terminals (or get flagged by the discrepancy
|
||||
in their own work).
|
||||
once: false # Repeats — pattern builds over time
|
||||
|
||||
- event_id: sera_avoids_torek
|
||||
label: "Sera Avoids Torek at The Bar"
|
||||
description: >
|
||||
Sera Venn reroutes her usual path through The Last Shift to avoid
|
||||
standing near Torek Lintar (the Commission enforcement officer).
|
||||
Anyone watching Sera's normal pattern would notice.
|
||||
This is the detective's first clue that Sera's behavior is odd.
|
||||
triggers:
|
||||
- type: ticks_since_activation
|
||||
ticks: 500
|
||||
effects:
|
||||
- type: npc_routine_deviation
|
||||
npc_role: evidence-holder
|
||||
description: >
|
||||
Sera takes a longer route to her usual seat, passing through
|
||||
the back of the bar to avoid Torek's sightline.
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.sera_avoidance_behavior"
|
||||
discoverable_by: detective
|
||||
discovery_method: >
|
||||
Observe Sera's path through the bar on two separate occasions.
|
||||
Requires forward vision cone and awareness of her baseline route.
|
||||
once: false
|
||||
|
||||
- event_id: nils_makes_supply_run
|
||||
label: "Nils Makes an Unscheduled Supply Run"
|
||||
description: >
|
||||
The ring-operative (Nils) enters the maintenance corridors with a
|
||||
small container logged as "calibration tools". The container isn't
|
||||
logged for return. Someone paying attention to cargo flow would notice.
|
||||
triggers:
|
||||
- type: ticks_since_activation
|
||||
ticks: 700
|
||||
effects:
|
||||
- type: npc_routine_deviation
|
||||
npc_role: ring-operative
|
||||
description: "Nils takes a container to maintenance corridor C-7."
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.nils_unlogged_cargo"
|
||||
discoverable_by: any
|
||||
discovery_method: >
|
||||
Watch Nils's cargo handling pattern over two shifts.
|
||||
Or examine maintenance corridor C-7 access log.
|
||||
once: false
|
||||
|
||||
# ── OUTCOMES ─────────────────────────────────────────────────────────────────
|
||||
# Five resolution states. Checked each tick after the first sequence step fires.
|
||||
# Order matters — the storyteller applies the first matching outcome.
|
||||
# is_terminal: true ends the module.
|
||||
|
||||
outcomes:
|
||||
|
||||
# 1. RING EXPOSED
|
||||
# Detective successfully uncovers the operation.
|
||||
# Commission becomes involved. Arrests/flight follow.
|
||||
- outcome_id: ring_exposed
|
||||
label: "Ring Exposed"
|
||||
is_terminal: true
|
||||
description: >
|
||||
The detective accumulates enough evidence to trigger a formal Commission
|
||||
inquiry. The ring collapses: arrests, flight, or both. Voss is detained.
|
||||
Kael's situation is now public. The smuggler (if played) faces consequences.
|
||||
Naia learns what Kael was doing — and why he was trying to leave.
|
||||
No clean endings. The right outcome for the detective who goes all the way.
|
||||
conditions:
|
||||
facts_known:
|
||||
- "ring.cargo_discrepancy_pattern"
|
||||
- "ring.kael_unauthorized_corridor_access"
|
||||
- "ring.voss_kael_private_meeting"
|
||||
flags_set:
|
||||
- secret_meeting_occurred # set by kael_secret_meeting event
|
||||
- commission_flag_exists # Commission was watching before exposure
|
||||
effects:
|
||||
- type: npc_disposition
|
||||
npc_role: ring-leader
|
||||
shift: hostile
|
||||
description: "Voss is detained or flees. Commission inquiry opens."
|
||||
- type: npc_disposition
|
||||
npc_role: ring-member-exiting
|
||||
shift: hostile
|
||||
description: >
|
||||
Kael is arrested or disappears. His exit attempt is now moot.
|
||||
His relationship with Naia is exposed.
|
||||
- type: faction_reaction
|
||||
faction: lattice-commission
|
||||
reaction: grateful
|
||||
description: "Commission credits the detective's investigation."
|
||||
- type: npc_exit
|
||||
npc_role: ring-leader
|
||||
description: "Voss leaves the district — detained, fled, or both."
|
||||
|
||||
# 2. KAEL ESCAPES THE RING
|
||||
# Unique path. Requires the player to engage with Kael directly
|
||||
# and choose to help him rather than expose the ring wholesale.
|
||||
- outcome_id: kael_escapes
|
||||
label: "Kael Escapes the Ring"
|
||||
is_terminal: true
|
||||
description: >
|
||||
Through the player's choices — helping Kael cover his exit, or warning him,
|
||||
or simply choosing not to act on what they know — Kael successfully leaves
|
||||
the ring before the final shipment. He and Naia leave the district quietly.
|
||||
The ring continues without him, smaller and more cautious.
|
||||
This outcome requires discovering Kael's secret AND choosing restraint.
|
||||
The smuggler can engineer this by covering for Kael with Voss.
|
||||
The detective can achieve this by confronting Kael privately rather than
|
||||
filing a report. The most morally complicated path.
|
||||
conditions:
|
||||
facts_known:
|
||||
- "ring.kael_unauthorized_corridor_access"
|
||||
flags_set:
|
||||
- kael_behavior_changed # set by kael_goes_cold — his exit arc begins here
|
||||
- secret_meeting_occurred # set by kael_secret_meeting — the pivot moment
|
||||
- voss_pressure_applied # set by ring_leader_confronts_kael — pressure applied
|
||||
# ring_exposed is checked first in the outcomes list and is terminal,
|
||||
# so kael_escapes only evaluates if ring_exposed hasn't fired.
|
||||
# No flags_not_set needed here — outcome ordering handles priority.
|
||||
effects:
|
||||
- type: npc_disposition
|
||||
npc_role: ring-member-exiting
|
||||
shift: friendly
|
||||
description: "Kael remembers whoever helped him. He's gone, but grateful."
|
||||
- type: npc_exit
|
||||
npc_role: ring-member-exiting
|
||||
description: "Kael and Naia leave Sova Transit District."
|
||||
- type: faction_reaction
|
||||
faction: the-ring
|
||||
reaction: suspicious
|
||||
description: "The ring is destabilized by Kael's exit. Voss is alert to further leaks."
|
||||
|
||||
# 3. RING COMPLETES OPERATION
|
||||
# The ring finishes the final shipment and goes dark before discovery.
|
||||
# Default path if the detective doesn't move fast enough.
|
||||
- outcome_id: ring_completes
|
||||
label: "Ring Completes the Operation"
|
||||
is_terminal: true
|
||||
description: >
|
||||
The final shipment clears. The ring disperses. Voss transfers. Nils goes quiet.
|
||||
Kael stays — he's now out by default, the ring having dissolved around him.
|
||||
The evidence trail goes cold. The detective closes the case as inconclusive.
|
||||
The smuggler completes their last run and waits to see if there's another.
|
||||
Unsatisfying only if you expected a tidy resolution. The world moved on.
|
||||
conditions:
|
||||
flags_set:
|
||||
- final_shipment_known # set by final_shipment_scheduled event
|
||||
- timeline_accelerating # set by maret_increases_presence — Maret forced their hand
|
||||
facts_not_known:
|
||||
- "ring.cargo_discrepancy_pattern" # detective never found the basic pattern — no investigation
|
||||
ticks_since_activation: 3600 # Module ran for ~60 minutes without full exposure
|
||||
# kael_message_sent was previously gated here but auto-fires at tick ~1550,
|
||||
# making this outcome permanently unreachable. Replaced with player-action fact gate.
|
||||
effects:
|
||||
- type: faction_reaction
|
||||
faction: the-ring
|
||||
reaction: neutral
|
||||
description: "The ring successfully completed this operation. They'll be back."
|
||||
- type: npc_exit
|
||||
npc_role: ring-leader
|
||||
description: "Voss transfers to another station for 'career development'."
|
||||
- type: location_access_change
|
||||
location: maintenance-corridors
|
||||
change: open
|
||||
description: "The restricted supply closet is now empty. Access log shows it cleared."
|
||||
|
||||
# 4. RING SPLINTERS
|
||||
# Partial discovery. The ring fractures but doesn't fully collapse.
|
||||
# An incomplete ending that leaves threads for future investigation.
|
||||
- outcome_id: ring_splinters
|
||||
label: "Ring Splinters"
|
||||
is_terminal: false # Not terminal — splinter state can evolve
|
||||
description: >
|
||||
Enough evidence surfaces that the ring knows it's been partially seen.
|
||||
Voss shuts down active operations. Nils disappears. Kael stays — now the
|
||||
one person in the district who knows what happened and has no one to tell.
|
||||
The formal investigation stalls for lack of a clear chain of evidence.
|
||||
The detective has facts but not the complete picture. The smuggler
|
||||
faces an awkward return to normalcy. Both know the ring isn't gone — just quiet.
|
||||
conditions:
|
||||
facts_known:
|
||||
- "ring.cargo_discrepancy_pattern" # detective found some evidence — ring responds
|
||||
events_fired:
|
||||
- kael_goes_cold # event ID — Kael's behavioral shift fired
|
||||
flags_set:
|
||||
- kael_missed_drop # set by drop_happens_without_kael — ring destabilized
|
||||
ticks_since_activation: 2400
|
||||
# Mutually exclusive with ring_completes via facts_known/facts_not_known on
|
||||
# ring.cargo_discrepancy_pattern. No auto-flag gate needed.
|
||||
effects:
|
||||
- type: npc_disposition
|
||||
npc_role: ring-leader
|
||||
shift: suspicious
|
||||
description: "Voss goes quiet. He's watching to see who knows what."
|
||||
- type: npc_exit
|
||||
npc_role: ring-operative
|
||||
description: "Nils stops appearing at The Terminal. Transferred, officially."
|
||||
- type: faction_reaction
|
||||
faction: the-ring
|
||||
reaction: suspicious
|
||||
description: "The ring is alerted to exposure risk. Future operations will be more careful."
|
||||
|
||||
# 5. INVESTIGATION STALLS (post-splinter exit)
|
||||
# The ring splinters but the detective never breaks through to the pivot evidence.
|
||||
# Explicit terminal exit for the non-terminal ring_splinters state.
|
||||
- outcome_id: ring_stalemate
|
||||
label: "Investigation Stalls"
|
||||
is_terminal: true
|
||||
description: >
|
||||
The ring went dark after the splinter. The detective has the cargo discrepancy
|
||||
on record — enough to flag, not enough to pursue. The case stays open but cold.
|
||||
No arrests. No answers. Kael stays in the district, the only person who knows
|
||||
the full shape of what happened, with no one left to tell it to.
|
||||
The ring will reconstitute elsewhere. It always does.
|
||||
conditions:
|
||||
facts_known:
|
||||
- "ring.cargo_discrepancy_pattern" # ring_splinters already fired (same gate)
|
||||
facts_not_known:
|
||||
- "ring.kael_unauthorized_corridor_access" # detective never reached the pivot evidence
|
||||
flags_set:
|
||||
- kael_missed_drop
|
||||
- final_shipment_known # ring finished while investigation stalled
|
||||
ticks_since_activation: 4500 # 2100 ticks after ring_splinters window — investigation ran cold
|
||||
effects:
|
||||
- type: faction_reaction
|
||||
faction: lattice-commission
|
||||
reaction: neutral
|
||||
description: "The discrepancy flag stays in Maret's file. No follow-up action."
|
||||
- type: npc_exit
|
||||
npc_role: ring-leader
|
||||
description: "Voss quietly transfers. No announcement, no incident report."
|
||||
|
||||
# 6. MODULE EXPIRY (quiet exit)
|
||||
# Player never engaged at all. Module times out without drama.
|
||||
# NOTE (Gestalt, Sprint 18): Condition uses facts_not_known, not flags_not_set.
|
||||
# kael_behavior_changed fires automatically at tick 300 (time-triggered), making
|
||||
# flags_not_set: [kael_behavior_changed] permanently false after tick 300.
|
||||
# Gate expiry on player-action-required facts instead.
|
||||
- outcome_id: module_abandoned
|
||||
label: "Module Abandoned"
|
||||
is_terminal: true
|
||||
is_expiry: true
|
||||
description: >
|
||||
The player never engaged with the ring's signals. The final shipment
|
||||
completed without incident. The ring disperses on its own schedule.
|
||||
Kael stays. The world is unchanged. This is not failure — it's the game
|
||||
acknowledging that not every conspiracy needs a protagonist.
|
||||
The 70% mundane majority (D-029) plays out: life continued.
|
||||
conditions:
|
||||
facts_not_known:
|
||||
- "ring.cargo_discrepancy_pattern" # Only known via player examination of terminal
|
||||
- "ring.kael_unauthorized_corridor_access" # Only known via player observing Kael in B-7
|
||||
ticks_since_activation: 5400 # ~90 minutes with zero player investigation
|
||||
effects:
|
||||
- type: faction_reaction
|
||||
faction: the-ring
|
||||
reaction: neutral
|
||||
description: "The ring closed operations without incident. No record of compromise."
|
||||
@@ -1,718 +0,0 @@
|
||||
# Drama Module Schema — Tier 1 Content (D-023)
|
||||
# YAML expression of JSON Schema 2020-12
|
||||
# Validated against this schema: server/content/modules/tier1/*.yaml
|
||||
#
|
||||
# Ownership:
|
||||
# Dramatic structure (this file): Paula
|
||||
# YAML validation tooling / serde structs: Gestalt / Tyre
|
||||
# Authoring ergonomics review: Mellanie
|
||||
#
|
||||
# See: docs/design/tier1-module-authoring.md for field-by-field guide.
|
||||
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema"
|
||||
$id: "drama_module.schema.yaml"
|
||||
title: "Tier 1 Drama Module"
|
||||
description: >
|
||||
A hand-authored drama module drawn from the pool at game start.
|
||||
The storyteller activates one or more modules per playthrough based on
|
||||
entry conditions, then fires events and detects outcomes. Tier 1 modules
|
||||
are the conspiracy layer of D-023 — authored, optional, relocatable.
|
||||
type: object
|
||||
required:
|
||||
- module_id
|
||||
- display_name
|
||||
- version
|
||||
- tier
|
||||
- pool
|
||||
- entry_conditions
|
||||
- npc_requirements
|
||||
- events
|
||||
- outcomes
|
||||
additionalProperties: false
|
||||
|
||||
properties:
|
||||
|
||||
# ── IDENTITY ────────────────────────────────────────────────────────────────
|
||||
|
||||
module_id:
|
||||
type: string
|
||||
pattern: "^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$"
|
||||
description: >
|
||||
Stable unique slug. Format: {name}_v{major}_{minor}.
|
||||
Never reuse IDs. Increment version on breaking structural changes.
|
||||
Example: "smuggling_ring_v0_1"
|
||||
|
||||
display_name:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: "Human-readable title shown in dev/debug tooling."
|
||||
|
||||
version:
|
||||
type: string
|
||||
pattern: "^[0-9]+\\.[0-9]+$"
|
||||
description: "Authoring version. Semantic: major.minor."
|
||||
|
||||
tier:
|
||||
type: integer
|
||||
const: 1
|
||||
description: "Always 1 for Tier 1 drama modules."
|
||||
|
||||
description:
|
||||
type: string
|
||||
description: "One-paragraph authoring summary. Not shown in-game."
|
||||
|
||||
# ── POOL METADATA ─────────────────────────────────────────────────────────
|
||||
# Controls how the storyteller includes this module in the per-playthrough pool.
|
||||
|
||||
pool:
|
||||
type: object
|
||||
required:
|
||||
- weight
|
||||
additionalProperties: false
|
||||
description: "How the storyteller samples this module from the pool."
|
||||
properties:
|
||||
weight:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 10
|
||||
description: >
|
||||
Relative selection probability (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
|
||||
Binary file not shown.
+49
-15
@@ -20,7 +20,11 @@ use crate::knowledge::{resolve_culture, CultureResolver};
|
||||
///
|
||||
/// Built once during `BookmarkPlugin::build`, read-only at runtime.
|
||||
/// `BTreeMap` for deterministic iteration (D-010 principle 4, D-041).
|
||||
#[derive(Resource, Debug, Default)]
|
||||
///
|
||||
/// `Clone` is required by `BookmarkPlugin::build` which moves the registry into
|
||||
/// the Bevy `App` via `insert_resource` while retaining the value from `self`
|
||||
/// (#862 injection pattern).
|
||||
#[derive(Resource, Debug, Default, Clone)]
|
||||
pub struct BookmarkRegistry {
|
||||
entries: BTreeMap<String, BookmarkDefinition>,
|
||||
}
|
||||
@@ -88,18 +92,15 @@ impl BookmarkRegistry {
|
||||
}
|
||||
|
||||
/// The confirmed bookmark selection for the current session.
|
||||
/// Populated when `ConfirmBookmark` is processed. `None` during the
|
||||
/// character-creation phase (before confirm) and always `None` in a
|
||||
/// fresh session.
|
||||
///
|
||||
/// **v0.2 scope: transient only.** Not serialized — save/load of
|
||||
/// `SelectedBookmark` is deferred to Sprint 37 (follow-up ticket
|
||||
/// filed alongside #614). Add `Serialize`/`Deserialize` derives and
|
||||
/// wire into `SaveState` when that ticket is claimed.
|
||||
/// Populated when `ConfirmBookmark` is processed. `None` fields during the
|
||||
/// character-creation phase (before confirm) and in a fresh session.
|
||||
///
|
||||
/// Serialized into `SaveStateV1.selected_bookmark` (#863) so that a loaded
|
||||
/// game remembers which bookmark and starting location were chosen.
|
||||
///
|
||||
/// Downstream systems (apartment generator, skill seeder) read from this resource.
|
||||
// TODO(sprint-37): serialize — see #863
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
#[derive(Resource, Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SelectedBookmark {
|
||||
pub bookmark_id: Option<String>,
|
||||
pub starting_location_id: Option<String>,
|
||||
@@ -109,14 +110,47 @@ pub struct SelectedBookmark {
|
||||
///
|
||||
/// Registers `BookmarkRegistry`, `SelectedBookmark`, and the startup system that
|
||||
/// stages the initial catalog in `SnapshotBuffer` for tick-0 delivery.
|
||||
pub struct BookmarkPlugin;
|
||||
///
|
||||
/// **Default construction** uses the canonical tycoon registry:
|
||||
/// ```ignore
|
||||
/// app.add_plugins(BookmarkPlugin::default());
|
||||
/// ```
|
||||
///
|
||||
/// **Injection** substitutes a custom registry (for tests and future TOML loading):
|
||||
/// ```ignore
|
||||
/// let mut registry = BookmarkRegistry::default();
|
||||
/// // populate ...
|
||||
/// app.add_plugins(BookmarkPlugin::new(registry));
|
||||
/// ```
|
||||
pub struct BookmarkPlugin {
|
||||
registry: BookmarkRegistry,
|
||||
}
|
||||
|
||||
impl BookmarkPlugin {
|
||||
/// Create a plugin with an injected registry.
|
||||
///
|
||||
/// Useful in tests (inject a minimal registry with fixed entries) and
|
||||
/// for future TOML loading (caller constructs the registry from disk,
|
||||
/// then hands it to the plugin).
|
||||
pub fn new(registry: BookmarkRegistry) -> Self {
|
||||
Self { registry }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BookmarkPlugin {
|
||||
/// Default plugin pre-populates the canonical tycoon registry via
|
||||
/// `register_default_bookmarks`. Equivalent to the v0.2 behaviour before
|
||||
/// injection was introduced.
|
||||
fn default() -> Self {
|
||||
let mut registry = BookmarkRegistry::default();
|
||||
register_default_bookmarks(&mut registry);
|
||||
Self { registry }
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for BookmarkPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let mut registry = BookmarkRegistry::default();
|
||||
register_default_bookmarks(&mut registry);
|
||||
|
||||
app.insert_resource(registry)
|
||||
app.insert_resource(self.registry.clone())
|
||||
.init_resource::<SelectedBookmark>()
|
||||
.add_systems(Startup, prime_initial_catalog);
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -220,7 +220,6 @@ mod tests {
|
||||
|
||||
fn make_snapshot() -> ObserverSnapshot {
|
||||
ObserverSnapshot {
|
||||
version: PROTOCOL_VERSION,
|
||||
tick: 42,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
@@ -427,7 +426,6 @@ mod tests {
|
||||
#[test]
|
||||
fn empty_snapshot_no_panic() {
|
||||
let snap = ObserverSnapshot {
|
||||
version: PROTOCOL_VERSION,
|
||||
tick: 0,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
|
||||
+27
-105
@@ -10,34 +10,24 @@ pub use crate::knowledge::types::{
|
||||
};
|
||||
pub use crate::simulation::time::{DayPhase, TickRate};
|
||||
|
||||
/// Wire protocol version for ObserverSnapshot.
|
||||
///
|
||||
/// Versioning strategy: flat struct + serde defaults for field evolution.
|
||||
/// Client and server are co-versioned (subprocess IPC per D-020), so protocol
|
||||
/// negotiation is unnecessary. Client should reject snapshots with version !=
|
||||
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
|
||||
/// period, then the default is removed once both sides are updated.
|
||||
pub const PROTOCOL_VERSION: u8 = 23;
|
||||
|
||||
/// Handshake message sent as the very first framed message after connection (#555).
|
||||
/// Client reads this before entering the normal tick loop and validates
|
||||
/// `protocol_version` against its own `PROTOCOL_VERSION` constant.
|
||||
/// Client reads this before entering the normal tick loop, then sends StartupMessage.
|
||||
/// Wire format: MessagePack, same 4-byte length-prefixed framing as ObserverSnapshot.
|
||||
///
|
||||
/// No version field — D-192 dropped the lockstep version check. Client and server
|
||||
/// are always co-shipped (D-005); genuine schema drift surfaces as a downstream
|
||||
/// MessagePack missing-field error rather than an eager handshake rejection.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct HandshakeMessage {
|
||||
/// Must match client's PROTOCOL_VERSION or the client should disconnect.
|
||||
pub protocol_version: u8,
|
||||
}
|
||||
pub struct HandshakeMessage {}
|
||||
|
||||
/// Startup message sent by the client after receiving HandshakeMessage (#175).
|
||||
/// Contains the world seed for deterministic simulation (D-010, D-029).
|
||||
///
|
||||
/// Protocol flow:
|
||||
/// Protocol flow (D-192: no version field, no validation step):
|
||||
/// 1. Server sends HandshakeMessage (server → client)
|
||||
/// 2. Client validates protocol_version
|
||||
/// 3. Client sends StartupMessage (client → server)
|
||||
/// 4. Server reads world_seed, initializes SimRng
|
||||
/// 5. Normal tick loop begins
|
||||
/// 2. Client sends StartupMessage (client → server)
|
||||
/// 3. Server reads world_seed, initializes SimRng
|
||||
/// 4. Normal tick loop begins
|
||||
///
|
||||
/// Wire format: MessagePack, same 4-byte length-prefixed framing.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -46,12 +36,6 @@ pub struct StartupMessage {
|
||||
/// Generated by SessionManager.new_game() on the client.
|
||||
/// Same seed → same EntanglementConfig → same NPC population (D-029).
|
||||
pub world_seed: u64,
|
||||
/// Character archetype selected by the player (#587).
|
||||
/// Gates monologue pool selection, verb labels, and examine text.
|
||||
/// Defaults to Detective for backward compatibility (old clients
|
||||
/// that omit this field).
|
||||
#[serde(default)]
|
||||
pub character_archetype: CharacterArchetype,
|
||||
}
|
||||
|
||||
/// The ONLY data structure crossing the client-server boundary (D-020)
|
||||
@@ -79,18 +63,25 @@ pub struct StartupMessage {
|
||||
/// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick),
|
||||
/// sim_errors (#85, structured error reporting to client).
|
||||
/// v18 adds: debug_response (#580, debug console server — command/response wire).
|
||||
/// v19 adds: character_archetype on StartupMessage (#587), current_ticker (#591).
|
||||
/// v19 adds: current_ticker (#591). (character_archetype on StartupMessage was
|
||||
/// added in #587 and removed in Sprint 37 — see "Sprint 37 wire-format shifts" below.)
|
||||
/// v20 adds: settings_response (#627, SQLite settings IPC).
|
||||
/// v21 adds: economy_snapshot (#822, D-181 7-signal snapshot per queried system),
|
||||
/// EconStateQuery PlayerAction variant (#822).
|
||||
/// v22 adds: bookmark_catalog (#614, D-115/D-117 CK3-style bookmark system),
|
||||
/// RequestBookmarkCatalog + ConfirmBookmark PlayerAction variants (#614).
|
||||
/// v23 removes: conversation_events, conversation_ended (D-078 scrapped per R-012).
|
||||
/// Sprint 37 wire-format shifts (no snapshot-version bump needed — PROTOCOL_VERSION lockstep gone):
|
||||
/// - D-192 (#874): `PROTOCOL_VERSION` field removed from `HandshakeMessage`. The
|
||||
/// handshake is now an empty marker ("server ready"); there is no negotiated
|
||||
/// version field on the wire. Genuine schema drift surfaces as MessagePack
|
||||
/// missing-field errors downstream — that is the intended signal per D-192.
|
||||
/// - #878 (cascade cleanup): `character_archetype` field removed from
|
||||
/// `StartupMessage`. This protocol break rides the D-192 drop.
|
||||
///
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
/// Protocol version for forward compatibility. See [`PROTOCOL_VERSION`].
|
||||
pub version: u8,
|
||||
/// Simulation tick when this snapshot was produced
|
||||
pub tick: u64,
|
||||
/// Game time data for client HUD display (D-031)
|
||||
@@ -495,28 +486,6 @@ pub enum ObjectType {
|
||||
Furniture,
|
||||
}
|
||||
|
||||
/// Character archetype for Phase 2 verb filtering (#422) and monologue pool
|
||||
/// selection. Determines how the character perceives and labels interactions.
|
||||
/// v0.1: Smuggler and Detective (the two playable characters).
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub enum CharacterArchetype {
|
||||
/// Smuggler character — sees Move/Stash on containers, physical manipulation verbs
|
||||
Smuggler,
|
||||
/// Detective character — sees Scan/Flag on containers, investigation verbs
|
||||
#[default]
|
||||
Detective,
|
||||
}
|
||||
|
||||
impl CharacterArchetype {
|
||||
/// String key for monologue pool filtering (#587).
|
||||
pub fn as_monologue_key(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Smuggler => "smuggler",
|
||||
Self::Detective => "detective",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic player actions, not raw key events (D-020)
|
||||
/// Timestamped for deterministic processing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -1105,73 +1074,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn handshake_message_roundtrip() {
|
||||
let msg = HandshakeMessage {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
};
|
||||
// D-192: HandshakeMessage carries no version field; roundtrip verifies
|
||||
// the empty struct serialises and deserialises cleanly.
|
||||
let msg = HandshakeMessage {};
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded, msg);
|
||||
assert_eq!(decoded.protocol_version, PROTOCOL_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_message_rejects_wrong_version() {
|
||||
let msg = HandshakeMessage {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
// Simulate client-side validation: version mismatch should be detectable
|
||||
let wrong_version = PROTOCOL_VERSION.wrapping_add(1);
|
||||
assert_ne!(decoded.protocol_version, wrong_version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_message_roundtrip() {
|
||||
let msg = StartupMessage {
|
||||
world_seed: 0xDEADBEEF,
|
||||
character_archetype: CharacterArchetype::Detective,
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded, msg);
|
||||
assert_eq!(decoded.world_seed, 0xDEADBEEF);
|
||||
assert_eq!(decoded.character_archetype, CharacterArchetype::Detective);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_message_smuggler_roundtrip() {
|
||||
let msg = StartupMessage {
|
||||
world_seed: 42,
|
||||
character_archetype: CharacterArchetype::Smuggler,
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded, msg);
|
||||
assert_eq!(decoded.character_archetype, CharacterArchetype::Smuggler);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_message_missing_archetype_defaults_to_detective() {
|
||||
// Simulate an old client that sends only world_seed (no character_archetype).
|
||||
// serde(default) on StartupMessage.character_archetype should default to Detective.
|
||||
#[derive(Serialize)]
|
||||
struct OldStartupMessage {
|
||||
world_seed: u64,
|
||||
}
|
||||
let old = OldStartupMessage { world_seed: 99 };
|
||||
let bytes = rmp_serde::to_vec_named(&old).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded.world_seed, 99);
|
||||
assert_eq!(decoded.character_archetype, CharacterArchetype::Detective);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_message_zero_seed() {
|
||||
let msg = StartupMessage {
|
||||
world_seed: 0,
|
||||
character_archetype: CharacterArchetype::default(),
|
||||
};
|
||||
let msg = StartupMessage { world_seed: 0 };
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded.world_seed, 0);
|
||||
@@ -1181,7 +1105,6 @@ mod tests {
|
||||
fn startup_message_max_seed() {
|
||||
let msg = StartupMessage {
|
||||
world_seed: u64::MAX,
|
||||
character_archetype: CharacterArchetype::default(),
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
@@ -1191,10 +1114,9 @@ mod tests {
|
||||
#[test]
|
||||
fn handshake_is_distinct_from_snapshot() {
|
||||
// HandshakeMessage and ObserverSnapshot are different types on the wire.
|
||||
// A HandshakeMessage should NOT deserialize as an ObserverSnapshot.
|
||||
let msg = HandshakeMessage {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
};
|
||||
// A HandshakeMessage (empty map) must NOT deserialize as an ObserverSnapshot
|
||||
// because ObserverSnapshot has required fields (tick, game_time, etc.).
|
||||
let msg = HandshakeMessage {};
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let result = rmp_serde::from_slice::<ObserverSnapshot>(&bytes);
|
||||
assert!(
|
||||
|
||||
+9
-28
@@ -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(),
|
||||
|
||||
@@ -78,7 +78,6 @@ pub fn compute_observer_snapshot(
|
||||
&mut NearbyInteractionBuffer,
|
||||
&mut MonologueBuffer,
|
||||
Option<&Stance>,
|
||||
Option<&CharacterArchetype>,
|
||||
Option<&mut SprintAnomalyQueue>,
|
||||
Option<&CognitiveDelay>,
|
||||
Option<&mut DialogueResponseBuffer>,
|
||||
@@ -114,7 +113,6 @@ pub fn compute_observer_snapshot(
|
||||
mut interaction_buffer,
|
||||
mut monologue_buffer,
|
||||
stance_opt,
|
||||
archetype_opt,
|
||||
mut anomaly_queue_opt,
|
||||
cognitive_delay_opt,
|
||||
mut dialogue_response_opt,
|
||||
@@ -135,8 +133,6 @@ pub fn compute_observer_snapshot(
|
||||
.map(|f| f.0)
|
||||
.unwrap_or(FacingDirection::default());
|
||||
|
||||
let archetype = archetype_opt.copied().unwrap_or_default();
|
||||
|
||||
// Collect player inventory (D-065 info boundary: only own items)
|
||||
let player_inventory = registry
|
||||
.to_stable(observer_entity)
|
||||
@@ -197,7 +193,7 @@ pub fn compute_observer_snapshot(
|
||||
|
||||
// Take interactions and apply Phase 2 verb filter (D-057, #422)
|
||||
let mut nearby_interactions = interaction_buffer.take();
|
||||
apply_phase2_verb_filter(&mut nearby_interactions, observer_kg, archetype);
|
||||
apply_phase2_verb_filter(&mut nearby_interactions, observer_kg);
|
||||
|
||||
tracing::trace!(
|
||||
"compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}",
|
||||
@@ -453,7 +449,6 @@ pub fn compute_observer_snapshot(
|
||||
};
|
||||
|
||||
buffer.snapshot = Some(ObserverSnapshot {
|
||||
version: crate::bridge::types::PROTOCOL_VERSION,
|
||||
tick: time.tick,
|
||||
game_time,
|
||||
player_facing: facing,
|
||||
@@ -654,17 +649,12 @@ fn collect_remembered_entities(
|
||||
/// 1. POI priority flips (D-060) — ExamineNpc above Talk for POI entities
|
||||
/// 2. Confront injection — adds Confront verb for NPCs when KnowsDetails+
|
||||
/// 3. Contradiction marking — sets contradicted flag when entity knowledge is Contradicted
|
||||
/// 4. Archetype label relabeling — smuggler/detective see different labels for same verb
|
||||
///
|
||||
/// Phase boundary: Phase 1 (interaction.rs) determines verb availability from
|
||||
/// ObjectType + proximity. Phase 2 (here) reads the observer's KnowledgeGraph
|
||||
/// to filter, augment, and relabel. This separation keeps D-010 principle 1
|
||||
/// (info boundary) clean — simulation doesn't know what the observer knows.
|
||||
fn apply_phase2_verb_filter(
|
||||
interactions: &mut [NearbyInteraction],
|
||||
observer_kg: &KnowledgeGraph,
|
||||
archetype: CharacterArchetype,
|
||||
) {
|
||||
fn apply_phase2_verb_filter(interactions: &mut [NearbyInteraction], observer_kg: &KnowledgeGraph) {
|
||||
for interaction in interactions.iter_mut() {
|
||||
let stable_id = StableId(interaction.entity_id);
|
||||
let knowledge = observer_kg.entity_knowledge(&stable_id);
|
||||
@@ -713,18 +703,6 @@ fn apply_phase2_verb_filter(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Archetype label relabeling ---
|
||||
// Phase 2 swaps verb labels based on character archetype.
|
||||
// The VerbKind stays the same (same handler), only the display label changes.
|
||||
// This implements D-057: "Character differentiation via Phase 2 observer
|
||||
// filter, not separate verb systems."
|
||||
for verb in &mut interaction.verbs {
|
||||
if let Some(label) = archetype_verb_label(archetype, interaction.object_type, verb.kind)
|
||||
{
|
||||
verb.label = label.into();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-sort after priority changes and verb additions
|
||||
interaction
|
||||
.verbs
|
||||
@@ -732,37 +710,5 @@ fn apply_phase2_verb_filter(
|
||||
}
|
||||
}
|
||||
|
||||
/// Archetype-specific verb label overrides (#422, D-057).
|
||||
///
|
||||
/// Returns a replacement label for the given (archetype, object_type, verb_kind)
|
||||
/// combination, or None to keep the Phase 1 default label.
|
||||
///
|
||||
/// v0.1: Container verbs differ by archetype. Other object types keep defaults.
|
||||
/// Add match arms here for future archetype-specific labels.
|
||||
fn archetype_verb_label(
|
||||
archetype: CharacterArchetype,
|
||||
object_type: Option<ObjectType>,
|
||||
kind: VerbKind,
|
||||
) -> Option<&'static str> {
|
||||
match (archetype, object_type, kind) {
|
||||
// Smuggler: Container verbs — physical manipulation vocabulary
|
||||
(CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Open) => Some("Move"),
|
||||
(CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Search) => {
|
||||
Some("Stash")
|
||||
}
|
||||
|
||||
// Detective: Container verbs — investigation vocabulary
|
||||
(CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Open) => {
|
||||
Some("Scan")
|
||||
}
|
||||
(CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Search) => {
|
||||
Some("Flag")
|
||||
}
|
||||
|
||||
// All other combinations: keep Phase 1 default label
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -59,7 +59,6 @@ fn player_always_visible_in_snapshot() {
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||
assert_eq!(snapshot.version, PROTOCOL_VERSION);
|
||||
assert_eq!(snapshot.entities.len(), 1);
|
||||
assert!(matches!(snapshot.entities[0].kind, EntityKind::Player));
|
||||
assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible);
|
||||
@@ -679,10 +678,6 @@ fn snapshot_v6_fields_default_through_pipeline() {
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||
|
||||
assert_eq!(
|
||||
snapshot.version, PROTOCOL_VERSION,
|
||||
"should be current protocol version"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.player_stance,
|
||||
MovementStance::Walk,
|
||||
@@ -694,29 +689,6 @@ fn snapshot_v6_fields_default_through_pipeline() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_v6_version_is_protocol_version() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
snapshot.version,
|
||||
crate::bridge::types::PROTOCOL_VERSION,
|
||||
"snapshot version must match PROTOCOL_VERSION constant"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 2 verb filter tests (#422, D-057)
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -967,148 +939,13 @@ fn phase2_no_contradiction_for_active_knowledge() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase2_smuggler_relabels_container_verbs() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Container at close range, north of player
|
||||
let container = world
|
||||
.spawn((
|
||||
TilePosition::new(16, 15, 0),
|
||||
crate::simulation::interaction::Interactable,
|
||||
ObjectType::Container,
|
||||
))
|
||||
.id();
|
||||
registry.register(container);
|
||||
|
||||
// Smuggler player
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
CharacterArchetype::Smuggler,
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_full_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert_eq!(snapshot.nearby_interactions.len(), 1);
|
||||
let interaction = &snapshot.nearby_interactions[0];
|
||||
// Container at close range: Open→"Move", Search→"Stash", Observe stays "Observe"
|
||||
let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open);
|
||||
let search_verb = interaction
|
||||
.verbs
|
||||
.iter()
|
||||
.find(|v| v.kind == VerbKind::Search);
|
||||
let observe_verb = interaction
|
||||
.verbs
|
||||
.iter()
|
||||
.find(|v| v.kind == VerbKind::Observe);
|
||||
assert_eq!(open_verb.unwrap().label, "Move", "smuggler Open→Move");
|
||||
assert_eq!(search_verb.unwrap().label, "Stash", "smuggler Search→Stash");
|
||||
assert_eq!(observe_verb.unwrap().label, "Observe", "Observe unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase2_detective_relabels_container_verbs() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let container = world
|
||||
.spawn((
|
||||
TilePosition::new(16, 15, 0),
|
||||
crate::simulation::interaction::Interactable,
|
||||
ObjectType::Container,
|
||||
))
|
||||
.id();
|
||||
registry.register(container);
|
||||
|
||||
// Detective player (explicit)
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
CharacterArchetype::Detective,
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_full_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert_eq!(snapshot.nearby_interactions.len(), 1);
|
||||
let interaction = &snapshot.nearby_interactions[0];
|
||||
let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open);
|
||||
let search_verb = interaction
|
||||
.verbs
|
||||
.iter()
|
||||
.find(|v| v.kind == VerbKind::Search);
|
||||
assert_eq!(open_verb.unwrap().label, "Scan", "detective Open→Scan");
|
||||
assert_eq!(search_verb.unwrap().label, "Flag", "detective Search→Flag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase2_default_archetype_is_detective() {
|
||||
// When no CharacterArchetype component attached, defaults to Detective
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let container = world
|
||||
.spawn((
|
||||
TilePosition::new(16, 15, 0),
|
||||
crate::simulation::interaction::Interactable,
|
||||
ObjectType::Container,
|
||||
))
|
||||
.id();
|
||||
registry.register(container);
|
||||
|
||||
// Player WITHOUT CharacterArchetype component
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_full_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert_eq!(snapshot.nearby_interactions.len(), 1);
|
||||
let interaction = &snapshot.nearby_interactions[0];
|
||||
// Default = Detective labels
|
||||
let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open);
|
||||
assert_eq!(
|
||||
open_verb.unwrap().label,
|
||||
"Scan",
|
||||
"default archetype should use Detective labels"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase2_non_container_keeps_default_labels() {
|
||||
// Readable objects should keep their default labels regardless of archetype
|
||||
// Readable objects keep their Phase 1 verb labels unchanged through Phase 2.
|
||||
// Regression guard: archetype-verb differentiation is Phase 6 detail — not present
|
||||
// in the current server per the development cascade (CLAUDE.md). D-057 superseded.
|
||||
// If this test fails, a character-class relabelling branch was reintroduced before
|
||||
// Phase 6 scope is confirmed by the team lead.
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
@@ -1129,7 +966,6 @@ fn phase2_non_container_keeps_default_labels() {
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
CharacterArchetype::Smuggler,
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
@@ -1145,7 +981,7 @@ fn phase2_non_container_keeps_default_labels() {
|
||||
assert_eq!(
|
||||
read_verb.unwrap().label,
|
||||
"Read",
|
||||
"Readable labels unchanged for smuggler"
|
||||
"Readable labels unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2764,3 +2600,124 @@ fn tell_state_none_when_npc_has_no_derived_tell_component() {
|
||||
"NPC without DerivedTellState component should have tell_state = None"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Regression: Phase 2 container verb labels are uniform (D-057 / #878)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn phase2_container_verb_labels_uniform_regardless_of_player_state() {
|
||||
// Regression guard (D-057 superseded, #878 cascade purge):
|
||||
// apply_phase2_verb_filter no longer has an archetype branch that relabels
|
||||
// container verbs. Labels must be the Phase-1 defaults — "Open", "Search",
|
||||
// "Observe" — regardless of the observer's KnowledgeGraph contents or
|
||||
// relationship state with other entities.
|
||||
//
|
||||
// This test FAILS if a character-class verb-label branch is reintroduced
|
||||
// without a confirmed Phase 6 scope decision from the team lead.
|
||||
|
||||
// --- Trial A: empty KnowledgeGraph ---
|
||||
{
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let container = world
|
||||
.spawn((
|
||||
TilePosition::new(16, 15, 0),
|
||||
crate::simulation::interaction::Interactable,
|
||||
ObjectType::Container,
|
||||
))
|
||||
.id();
|
||||
registry.register(container);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_full_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert_eq!(snapshot.nearby_interactions.len(), 1);
|
||||
let labels: Vec<&str> = snapshot.nearby_interactions[0]
|
||||
.verbs
|
||||
.iter()
|
||||
.map(|v| v.label.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
["Open", "Search", "Observe"],
|
||||
"Trial A (empty KG): container verb labels must equal Phase-1 defaults"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Trial B: KG with PersonOfInterest NPC nearby ---
|
||||
// Player has a non-trivial knowledge state; container labels must still be
|
||||
// the Phase-1 defaults — Phase 2 NPC-specific logic must not bleed into
|
||||
// ObjectType::Container interactions.
|
||||
{
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let container = world
|
||||
.spawn((
|
||||
TilePosition::new(16, 15, 0),
|
||||
crate::simulation::interaction::Interactable,
|
||||
ObjectType::Container,
|
||||
))
|
||||
.id();
|
||||
registry.register(container);
|
||||
|
||||
// NPC out of close-range so Confront is not injected; still in KG as POI.
|
||||
let npc = world
|
||||
.spawn((
|
||||
crate::npc::Npc,
|
||||
TilePosition::new(16, 13, 0),
|
||||
crate::simulation::interaction::Interactable,
|
||||
))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 13, 0), 5);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_full_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
let container_interaction = snapshot
|
||||
.nearby_interactions
|
||||
.iter()
|
||||
.find(|i| i.object_type == Some(ObjectType::Container))
|
||||
.expect("container interaction must be present");
|
||||
|
||||
let labels: Vec<&str> = container_interaction
|
||||
.verbs
|
||||
.iter()
|
||||
.map(|v| v.label.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
["Open", "Search", "Observe"],
|
||||
"Trial B (POI NPC in KG): container verb labels must equal Phase-1 defaults"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bridge::types::CharacterArchetype;
|
||||
use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
@@ -37,11 +36,12 @@ pub struct ExamineRequest {
|
||||
pub target: Entity,
|
||||
}
|
||||
|
||||
/// Character-filtered examination result for snapshot delivery.
|
||||
/// Examination result for snapshot delivery.
|
||||
///
|
||||
/// Content differs per CharacterArchetype:
|
||||
/// Smuggler — physical threat read, cargo-handling posture, opportunity windows.
|
||||
/// Detective — procedural tells, behavioral inconsistencies, stress indicators.
|
||||
/// Phase 6 note: per-archetype text variants (smuggler/detective flavor) were
|
||||
/// removed during the cascade cleanup. Text is a single unified "subject read"
|
||||
/// until archetype differentiation is reintroduced per the culture-driven
|
||||
/// voice system (D-121) in a later cascade phase.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExamineResultEvent {
|
||||
/// Character-filtered observation text for client display.
|
||||
@@ -106,12 +106,15 @@ fn has_trait(traits_opt: Option<&PersonalityTraits>, t: PersonalityTrait) -> boo
|
||||
traits_opt.map(|p| p.traits.contains(&t)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Generate character-filtered examination text from NPC component state.
|
||||
/// Generate examination text from NPC component state.
|
||||
/// All logic is pure, deterministic, and integer-based (D-010).
|
||||
///
|
||||
/// Archetype-specific text variants were removed during the cascade cleanup
|
||||
/// (D-032 / Sprint 37). Reintroduce per-culture voice when the cascade reaches
|
||||
/// the Phase 6 character/NPC layer (see D-121).
|
||||
pub fn generate_examine_text(
|
||||
mood: NpcMood,
|
||||
ratio: u8,
|
||||
archetype: CharacterArchetype,
|
||||
traits_opt: Option<&PersonalityTraits>,
|
||||
) -> String {
|
||||
let stress_label = match ratio {
|
||||
@@ -123,53 +126,29 @@ pub fn generate_examine_text(
|
||||
|
||||
let mood_label = mood_word(mood);
|
||||
|
||||
match archetype {
|
||||
CharacterArchetype::Smuggler => {
|
||||
// Physical threat read + cargo opportunity window
|
||||
let threat = if matches!(mood, NpcMood::Hostile | NpcMood::Suspicious) {
|
||||
"Threat posture. Don't push it."
|
||||
} else if has_trait(traits_opt, PersonalityTrait::Bold) {
|
||||
"Confident bearing. Will push back if cornered."
|
||||
} else if has_trait(traits_opt, PersonalityTrait::Cautious) {
|
||||
"Nervous type. Predictable under pressure."
|
||||
} else {
|
||||
"No obvious threat read."
|
||||
};
|
||||
let tell = if has_trait(traits_opt, PersonalityTrait::Deceptive) {
|
||||
"Controlled affect — practiced concealment."
|
||||
} else if matches!(mood, NpcMood::Anxious | NpcMood::Frustrated) {
|
||||
"Involuntary stress markers present."
|
||||
} else if matches!(mood, NpcMood::Suspicious) {
|
||||
"Scanning. Aware of being observed."
|
||||
} else if matches!(mood, NpcMood::Hostile) {
|
||||
"Threat posture. Aware of being observed."
|
||||
} else {
|
||||
"Baseline presentation."
|
||||
};
|
||||
|
||||
let window = if ratio > 60 {
|
||||
"Too distracted to track cargo movement."
|
||||
} else if matches!(mood, NpcMood::Focused) {
|
||||
"Paying close attention to this section."
|
||||
} else {
|
||||
"Standard patrol pattern. Window is there."
|
||||
};
|
||||
let read = if ratio > 60 {
|
||||
"Under pressure — potential liability or asset."
|
||||
} else if matches!(mood, NpcMood::Content | NpcMood::Warm) {
|
||||
"Comfortable. Less guarded than usual."
|
||||
} else if matches!(mood, NpcMood::Focused) {
|
||||
"Paying close attention."
|
||||
} else {
|
||||
"Routine behavior pattern."
|
||||
};
|
||||
|
||||
format!("Appears {mood_label}, {stress_label}. {threat} {window}")
|
||||
}
|
||||
|
||||
CharacterArchetype::Detective => {
|
||||
// Procedural tells + behavioral read
|
||||
let tell = if has_trait(traits_opt, PersonalityTrait::Deceptive) {
|
||||
"Controlled affect — practiced concealment."
|
||||
} else if matches!(mood, NpcMood::Anxious | NpcMood::Frustrated) {
|
||||
"Involuntary stress markers present."
|
||||
} else if matches!(mood, NpcMood::Suspicious) {
|
||||
"Scanning. Aware of being observed."
|
||||
} else {
|
||||
"Baseline presentation."
|
||||
};
|
||||
|
||||
let read = if ratio > 60 {
|
||||
"Under pressure — potential liability or asset."
|
||||
} else if matches!(mood, NpcMood::Content | NpcMood::Warm) {
|
||||
"Comfortable. Less guarded than usual."
|
||||
} else {
|
||||
"Routine behavior pattern."
|
||||
};
|
||||
|
||||
format!("Subject: {mood_label}, {stress_label}. {tell} {read}")
|
||||
}
|
||||
}
|
||||
format!("Subject: {mood_label}, {stress_label}. {tell} {read}")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -196,7 +175,6 @@ pub fn process_examine_interaction(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
&ExamineRequest,
|
||||
Option<&CharacterArchetype>,
|
||||
&mut ExamineResultBuffer,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
@@ -212,14 +190,12 @@ pub fn process_examine_interaction(
|
||||
>,
|
||||
examine_text_query: Query<(&TilePosition, Option<&ExamineText>)>,
|
||||
) {
|
||||
let Ok((player_entity, player_pos, examine_req, archetype_opt, mut result_buffer)) =
|
||||
player_query.single_mut()
|
||||
let Ok((player_entity, player_pos, examine_req, mut result_buffer)) = player_query.single_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target = examine_req.target;
|
||||
let archetype = archetype_opt.copied().unwrap_or_default();
|
||||
|
||||
// Try NPC examine path first
|
||||
if let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) {
|
||||
@@ -238,7 +214,7 @@ pub fn process_examine_interaction(
|
||||
|
||||
let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral);
|
||||
let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0);
|
||||
let text = generate_examine_text(mood, ratio, archetype, traits_opt);
|
||||
let text = generate_examine_text(mood, ratio, traits_opt);
|
||||
|
||||
kg_events.push(KnowledgeEvent {
|
||||
observer: player_entity,
|
||||
@@ -323,8 +299,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smuggler_hostile_npc_gives_threat_read() {
|
||||
let text = generate_examine_text(NpcMood::Hostile, 20, CharacterArchetype::Smuggler, None);
|
||||
fn hostile_npc_gives_threat_read() {
|
||||
let text = generate_examine_text(NpcMood::Hostile, 20, None);
|
||||
assert!(
|
||||
text.contains("Threat posture"),
|
||||
"expected threat read, got: {text}"
|
||||
@@ -332,32 +308,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smuggler_focused_npc_notes_attention() {
|
||||
let text = generate_examine_text(NpcMood::Focused, 30, CharacterArchetype::Smuggler, None);
|
||||
fn focused_npc_notes_attention() {
|
||||
let text = generate_examine_text(NpcMood::Focused, 30, None);
|
||||
assert!(
|
||||
text.contains("close attention"),
|
||||
text.contains("Paying close attention"),
|
||||
"expected attention note, got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smuggler_high_stress_identifies_distraction() {
|
||||
let text = generate_examine_text(NpcMood::Anxious, 80, CharacterArchetype::Smuggler, None);
|
||||
fn high_stress_identifies_pressure() {
|
||||
let text = generate_examine_text(NpcMood::Anxious, 80, None);
|
||||
assert!(
|
||||
text.contains("Too distracted"),
|
||||
"expected distraction read, got: {text}"
|
||||
text.contains("Under pressure"),
|
||||
"expected pressure read, got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detective_deceptive_npc_notes_concealment() {
|
||||
fn deceptive_npc_notes_concealment() {
|
||||
let t = traits(&[PersonalityTrait::Deceptive]);
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Neutral,
|
||||
20,
|
||||
CharacterArchetype::Detective,
|
||||
Some(&t),
|
||||
);
|
||||
let text = generate_examine_text(NpcMood::Neutral, 20, Some(&t));
|
||||
assert!(
|
||||
text.contains("Controlled affect"),
|
||||
"expected concealment note, got: {text}"
|
||||
@@ -365,8 +336,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detective_anxious_npc_notes_stress_markers() {
|
||||
let text = generate_examine_text(NpcMood::Anxious, 50, CharacterArchetype::Detective, None);
|
||||
fn anxious_npc_notes_stress_markers() {
|
||||
let text = generate_examine_text(NpcMood::Anxious, 50, None);
|
||||
assert!(
|
||||
text.contains("stress markers"),
|
||||
"expected stress markers, got: {text}"
|
||||
@@ -374,8 +345,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detective_content_npc_notes_low_guard() {
|
||||
let text = generate_examine_text(NpcMood::Content, 10, CharacterArchetype::Detective, None);
|
||||
fn content_npc_notes_low_guard() {
|
||||
let text = generate_examine_text(NpcMood::Content, 10, None);
|
||||
assert!(
|
||||
text.contains("Less guarded"),
|
||||
"expected low guard note, got: {text}"
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ impl PostConversationQueue {
|
||||
|
||||
/// Tracks monologue state for cooldown and trigger detection.
|
||||
/// Attached to the PlayerCharacter entity.
|
||||
#[derive(Component, Debug)]
|
||||
#[derive(Component, Debug, Default)]
|
||||
pub struct MonologueState {
|
||||
/// Tick when the last monologue was fired.
|
||||
pub last_fired_tick: u64,
|
||||
@@ -131,29 +131,12 @@ pub struct MonologueState {
|
||||
pub entered: bool,
|
||||
/// IDs of lines already shown (dedup within session).
|
||||
pub shown_ids: BTreeSet<String>,
|
||||
/// Character type for pool filtering. Set from CharacterArchetype (#587).
|
||||
pub character: String,
|
||||
/// Tick of the last observation event we reacted to (#119, observe_npc).
|
||||
/// Observation events arrive one tick after the snapshot that caused them,
|
||||
/// so we track which tick's events we've already processed.
|
||||
pub last_observation_tick: u64,
|
||||
}
|
||||
|
||||
impl Default for MonologueState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
last_fired_tick: 0,
|
||||
last_position: None,
|
||||
idle_ticks: 0,
|
||||
entered: false,
|
||||
shown_ids: BTreeSet::new(),
|
||||
// Default to detective; overridden by CharacterArchetype at spawn (#587)
|
||||
character: "detective".to_string(),
|
||||
last_observation_tick: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Buffer holding the monologue event to include in the next snapshot.
|
||||
/// `take()` drains the buffer (consumed once per snapshot).
|
||||
#[derive(Component, Debug, Default)]
|
||||
@@ -2142,4 +2125,103 @@ mod tests {
|
||||
"queue should be empty after monologue consumed the event"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Regression: monologue pool selection is uniform (D-032 / #878)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn monologue_pool_selection_uniform_no_archetype_key() {
|
||||
// Regression guard (D-032 cascade purge, #878):
|
||||
// trigger_event_monologue previously partitioned pool selection by
|
||||
// CharacterArchetype key (MonologueState.character). That field is gone.
|
||||
// Pool selection is now by trigger string only — "observe_npc", "hear_sound",
|
||||
// "post_conversation" — and the line IDs are drawn exclusively from the
|
||||
// corresponding hardcoded constant (OBSERVE_NPC_LINES et al.).
|
||||
//
|
||||
// This test asserts the POSITIVE behaviour: an observe_npc trigger always
|
||||
// produces a line whose ID begins with "observe_npc_", regardless of any
|
||||
// additional observer state. It FAILS if an archetype-keyed dispatch path
|
||||
// is reintroduced (which would produce IDs outside that prefix or panic on
|
||||
// a missing archetype field).
|
||||
|
||||
// Known line IDs from OBSERVE_NPC_LINES (compile-checked below).
|
||||
const VALID_OBSERVE_NPC_IDS: &[&str] =
|
||||
&["observe_npc_01", "observe_npc_02", "observe_npc_03"];
|
||||
|
||||
// --- Observer A: minimal state (no extra components) ---
|
||||
let line_a = {
|
||||
let mut world = setup_event_world();
|
||||
let player = spawn_event_player(&mut world);
|
||||
|
||||
world
|
||||
.resource_mut::<ObservationEventQueue>()
|
||||
.push(ObservationEvent {
|
||||
tick: 1,
|
||||
trigger: ObservationTrigger::NewEntity {
|
||||
entity: StableId(10),
|
||||
location: TilePosition::new(12, 12, 0),
|
||||
},
|
||||
observer: player,
|
||||
});
|
||||
|
||||
run_event_system(&mut world);
|
||||
|
||||
let buf = world.get::<MonologueBuffer>(player).unwrap();
|
||||
buf.event
|
||||
.as_ref()
|
||||
.expect("observe_npc trigger must fire a monologue")
|
||||
.id
|
||||
.clone()
|
||||
};
|
||||
|
||||
// --- Observer B: player has heard a previous sound (last_fired_tick set) ---
|
||||
// Simulates a player with non-default MonologueState — the pool key must
|
||||
// still resolve to OBSERVE_NPC_LINES, not an archetype-partitioned variant.
|
||||
let line_b = {
|
||||
let mut world = setup_event_world();
|
||||
let player = spawn_event_player(&mut world);
|
||||
|
||||
// Pre-populate state to exercise a non-fresh observer
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
{
|
||||
let mut state = world.get_mut::<MonologueState>(player).unwrap();
|
||||
state.last_fired_tick = 3;
|
||||
}
|
||||
|
||||
world
|
||||
.resource_mut::<ObservationEventQueue>()
|
||||
.push(ObservationEvent {
|
||||
tick: 5,
|
||||
trigger: ObservationTrigger::NewEntity {
|
||||
entity: StableId(20),
|
||||
location: TilePosition::new(14, 14, 0),
|
||||
},
|
||||
observer: player,
|
||||
});
|
||||
|
||||
run_event_system(&mut world);
|
||||
|
||||
let buf = world.get::<MonologueBuffer>(player).unwrap();
|
||||
buf.event
|
||||
.as_ref()
|
||||
.expect("observe_npc trigger must fire for observer B")
|
||||
.id
|
||||
.clone()
|
||||
};
|
||||
|
||||
// Both observers must produce IDs from the unified observe_npc pool.
|
||||
assert!(
|
||||
VALID_OBSERVE_NPC_IDS.contains(&line_a.as_str()),
|
||||
"Observer A line_id '{}' is not from OBSERVE_NPC_LINES — \
|
||||
archetype-keyed pool dispatch may have been reintroduced",
|
||||
line_a
|
||||
);
|
||||
assert!(
|
||||
VALID_OBSERVE_NPC_IDS.contains(&line_b.as_str()),
|
||||
"Observer B line_id '{}' is not from OBSERVE_NPC_LINES — \
|
||||
archetype-keyed pool dispatch may have been reintroduced",
|
||||
line_b
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use std::path::{Path, PathBuf};
|
||||
use bevy_ecs::prelude::*;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::bookmark::SelectedBookmark;
|
||||
use crate::bridge::types::SaveLoadResultWire;
|
||||
use crate::bridge::types::SnapshotBuffer;
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
@@ -160,6 +161,10 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError>
|
||||
last_activation_tick: world
|
||||
.get_resource::<ActivationState>()
|
||||
.and_then(|a| a.last_activation_tick),
|
||||
selected_bookmark: world
|
||||
.get_resource::<SelectedBookmark>()
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
|
||||
let bytes = state
|
||||
@@ -285,6 +290,10 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro
|
||||
last_activation_tick: state.last_activation_tick,
|
||||
});
|
||||
|
||||
// Restore bookmark selection (#863) — the player's confirmed bookmark and
|
||||
// starting location survive save/load so downstream systems stay consistent.
|
||||
world.insert_resource(state.selected_bookmark);
|
||||
|
||||
// Reset event queues and transient buffers — prevent stale events/history
|
||||
// from the pre-load world leaking into the post-load simulation.
|
||||
world.insert_resource(ContaminationEventQueue::default());
|
||||
@@ -415,8 +424,11 @@ mod tests {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn temp_path() -> PathBuf {
|
||||
// Include the process ID so nextest processes (each starts COUNTER at 0)
|
||||
// do not collide on the same filename when running concurrently.
|
||||
let pid = std::process::id();
|
||||
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("settled_reach_save_io_test_{}.msgpack", id))
|
||||
std::env::temp_dir().join(format!("settled_reach_save_io_test_{}_{}.msgpack", pid, id))
|
||||
}
|
||||
|
||||
fn minimal_world() -> World {
|
||||
@@ -623,6 +635,7 @@ mod tests {
|
||||
contamination_active: false,
|
||||
activated_count: 0,
|
||||
last_activation_tick: None,
|
||||
selected_bookmark: SelectedBookmark::default(),
|
||||
};
|
||||
let bytes = bad_state.to_bytes().expect("serialize");
|
||||
let path = temp_path();
|
||||
@@ -955,4 +968,69 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SelectedBookmark round-trip (#863)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn save_load_preserves_selected_bookmark() {
|
||||
let mut world = minimal_world();
|
||||
|
||||
// Set a bookmark selection before saving.
|
||||
world.insert_resource(SelectedBookmark {
|
||||
bookmark_id: Some("tycoon".to_string()),
|
||||
starting_location_id: Some("new-stockholm".to_string()),
|
||||
});
|
||||
|
||||
let path = temp_path();
|
||||
save_to_file(&path, &mut world).expect("save");
|
||||
|
||||
// Clear the resource to prove load restores it, not the pre-existing value.
|
||||
world.insert_resource(SelectedBookmark::default());
|
||||
assert!(
|
||||
world.resource::<SelectedBookmark>().bookmark_id.is_none(),
|
||||
"bookmark must be cleared before load"
|
||||
);
|
||||
|
||||
load_from_file(&path, &mut world).expect("load");
|
||||
|
||||
let restored = world.resource::<SelectedBookmark>();
|
||||
assert_eq!(
|
||||
restored.bookmark_id.as_deref(),
|
||||
Some("tycoon"),
|
||||
"bookmark_id must survive round-trip"
|
||||
);
|
||||
assert_eq!(
|
||||
restored.starting_location_id.as_deref(),
|
||||
Some("new-stockholm"),
|
||||
"starting_location_id must survive round-trip"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_load_selected_bookmark_defaults_when_unset() {
|
||||
// Save without a bookmark selection (default = both None).
|
||||
// Load must produce SelectedBookmark::default(), not error.
|
||||
let mut world = minimal_world();
|
||||
// SelectedBookmark not explicitly inserted — should default to no selection.
|
||||
|
||||
let path = temp_path();
|
||||
save_to_file(&path, &mut world).expect("save");
|
||||
load_from_file(&path, &mut world).expect("load");
|
||||
|
||||
let restored = world.resource::<SelectedBookmark>();
|
||||
assert!(
|
||||
restored.bookmark_id.is_none(),
|
||||
"unset bookmark_id must survive as None"
|
||||
);
|
||||
assert!(
|
||||
restored.starting_location_id.is_none(),
|
||||
"unset starting_location_id must survive as None"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ use bevy_ecs::entity::Entity;
|
||||
use bevy_ecs::world::World;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bookmark::SelectedBookmark;
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::knowledge::registry::StableEntityId;
|
||||
use crate::knowledge::types::StableId;
|
||||
@@ -122,6 +123,11 @@ pub struct SaveStateV1 {
|
||||
/// `None` if no activation yet. Persisted alongside `activated_count`.
|
||||
#[serde(default)]
|
||||
pub last_activation_tick: Option<u64>,
|
||||
/// Bookmark and starting location confirmed by the player at session start (#863, #614).
|
||||
/// Both fields are `None` in saves created before #863 or before character creation
|
||||
/// completes. Defaults to `SelectedBookmark::default()` for backward compatibility.
|
||||
#[serde(default)]
|
||||
pub selected_bookmark: SelectedBookmark,
|
||||
}
|
||||
|
||||
/// Per-NPC state snapshot for `SaveStateV1`.
|
||||
@@ -435,6 +441,7 @@ mod tests {
|
||||
contamination_active: false,
|
||||
activated_count: 0,
|
||||
last_activation_tick: None,
|
||||
selected_bookmark: crate::bookmark::SelectedBookmark::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -854,6 +861,7 @@ mod tests {
|
||||
contamination_active: false,
|
||||
activated_count: 0,
|
||||
last_activation_tick: None,
|
||||
selected_bookmark: crate::bookmark::SelectedBookmark::default(),
|
||||
};
|
||||
|
||||
let bytes = save.to_bytes().expect("serialize");
|
||||
@@ -878,6 +886,63 @@ mod tests {
|
||||
assert!(result.is_err(), "must panic without StableEntityId");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SelectedBookmark round-trip (#863)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn selected_bookmark_with_values_survives_roundtrip() {
|
||||
// Spec (#863): SelectedBookmark persisted in SaveStateV1 must survive a
|
||||
// full MessagePack serialize → deserialize cycle with field values intact.
|
||||
let mut state = minimal_save_state();
|
||||
state.selected_bookmark = crate::bookmark::SelectedBookmark {
|
||||
bookmark_id: Some("tycoon".to_string()),
|
||||
starting_location_id: Some("GJ 35".to_string()),
|
||||
};
|
||||
|
||||
let bytes = state.to_bytes().expect("serialize");
|
||||
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||
|
||||
assert_eq!(
|
||||
recovered.selected_bookmark.bookmark_id,
|
||||
Some("tycoon".to_string()),
|
||||
"bookmark_id must survive roundtrip"
|
||||
);
|
||||
assert_eq!(
|
||||
recovered.selected_bookmark.starting_location_id,
|
||||
Some("GJ 35".to_string()),
|
||||
"starting_location_id must survive roundtrip"
|
||||
);
|
||||
|
||||
// Idempotent re-serialize: bytes must be stable
|
||||
let bytes2 = recovered.to_bytes().expect("re-serialize");
|
||||
assert_eq!(
|
||||
bytes, bytes2,
|
||||
"SelectedBookmark roundtrip must be idempotent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_bookmark_default_survives_roundtrip() {
|
||||
// Spec (#863): saves from before Sprint 37 (both fields None) must load
|
||||
// cleanly via serde(default) on the SaveStateV1 field.
|
||||
let state = minimal_save_state();
|
||||
assert!(state.selected_bookmark.bookmark_id.is_none());
|
||||
assert!(state.selected_bookmark.starting_location_id.is_none());
|
||||
|
||||
let bytes = state.to_bytes().expect("serialize");
|
||||
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||
|
||||
assert!(
|
||||
recovered.selected_bookmark.bookmark_id.is_none(),
|
||||
"default bookmark_id (None) must roundtrip"
|
||||
);
|
||||
assert!(
|
||||
recovered.selected_bookmark.starting_location_id.is_none(),
|
||||
"default starting_location_id (None) must roundtrip"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Modifications stub round-trip (#567, D-111/D-112)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -95,7 +95,7 @@ pub const MAP_HEIGHT: i32 = 125;
|
||||
/// is intentional for deterministic test setups but should be revisited
|
||||
/// if Gauntlet is ever served by the production startup pipeline.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterArchetype) {
|
||||
pub fn setup_gauntlet(app: &mut App) {
|
||||
// Start with a fully blocked map, then carve rooms and corridors.
|
||||
let mut walkability = WalkabilityMap::new_blocked(MAP_WIDTH, MAP_HEIGHT, 1);
|
||||
|
||||
@@ -190,12 +190,8 @@ pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterA
|
||||
|
||||
// --- Player (StableId 0) ---
|
||||
// Spawn at Hub center: absolute (50, 58)
|
||||
let profile = MovementProfile::smuggler();
|
||||
let profile = MovementProfile::default();
|
||||
let player_pos = TilePosition::new(50, 58, 0);
|
||||
let monologue_state = MonologueState {
|
||||
character: archetype.as_monologue_key().to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let player = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
@@ -204,7 +200,7 @@ pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterA
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
monologue_state,
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
SprintAnomalyQueue::default(),
|
||||
ScanEventBuffer::default(),
|
||||
@@ -216,7 +212,6 @@ pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterA
|
||||
crate::simulation::pressure::CharacterPressure::default(),
|
||||
))
|
||||
.id();
|
||||
app.world_mut().entity_mut(player).insert(archetype);
|
||||
registry.register(player);
|
||||
|
||||
// --- Hub signs (StableId 1-4) ---
|
||||
@@ -699,10 +694,7 @@ mod tests {
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(
|
||||
&mut app,
|
||||
crate::bridge::types::CharacterArchetype::default(),
|
||||
);
|
||||
setup_gauntlet(&mut app);
|
||||
|
||||
// Run all 29 world-query invariants against the fully-initialized gauntlet world.
|
||||
invariants::run_invariants(app.world_mut());
|
||||
@@ -722,10 +714,7 @@ mod tests {
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(
|
||||
&mut app,
|
||||
crate::bridge::types::CharacterArchetype::default(),
|
||||
);
|
||||
setup_gauntlet(&mut app);
|
||||
|
||||
let wm = app.world().resource::<WalkabilityMap>();
|
||||
// Hub center at (50, 58) must be walkable
|
||||
@@ -739,10 +728,7 @@ mod tests {
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(
|
||||
&mut app,
|
||||
crate::bridge::types::CharacterArchetype::default(),
|
||||
);
|
||||
setup_gauntlet(&mut app);
|
||||
|
||||
let wm = app.world().resource::<WalkabilityMap>();
|
||||
// North wall segment at absolute (90, 54) should be blocked
|
||||
@@ -758,10 +744,7 @@ mod tests {
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(
|
||||
&mut app,
|
||||
crate::bridge::types::CharacterArchetype::default(),
|
||||
);
|
||||
setup_gauntlet(&mut app);
|
||||
|
||||
let wm = app.world().resource::<WalkabilityMap>();
|
||||
// corridor-E center should be walkable
|
||||
@@ -775,10 +758,7 @@ mod tests {
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(
|
||||
&mut app,
|
||||
crate::bridge::types::CharacterArchetype::default(),
|
||||
);
|
||||
setup_gauntlet(&mut app);
|
||||
|
||||
let registry = app.world().resource::<EntityRegistry>();
|
||||
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
//! Regression tests: character archetype flows end-to-end to MonologueState (#595, D-032).
|
||||
//!
|
||||
//! Verifies that when a session starts with a given CharacterArchetype, the
|
||||
//! player entity's MonologueState.character reflects it correctly. This is the
|
||||
//! guard against the default "detective" string leaking into smuggler sessions.
|
||||
//!
|
||||
//! Two complementary approaches:
|
||||
//! 1. Unit-level: CharacterArchetype::as_monologue_key() mapping is correct.
|
||||
//! 2. Integration (gauntlet): setup_gauntlet() correctly wires archetype → MonologueState.
|
||||
//!
|
||||
//! Spec refs:
|
||||
//! D-032: character tag is a hard pool partition, not a filter — wrong character string
|
||||
//! silently serves wrong content.
|
||||
//! D-010: no player identity baked into game loop — archetype is a configuration.
|
||||
//! #587: character_archetype added to StartupMessage; monologue key derived from it.
|
||||
//! #595: MonologueState.character initialized from CharacterArchetype at session start.
|
||||
|
||||
use settled_reach_server::bridge::types::CharacterArchetype;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer 1 — pure unit tests, no ECS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn smuggler_archetype_maps_to_monologue_key() {
|
||||
assert_eq!(
|
||||
CharacterArchetype::Smuggler.as_monologue_key(),
|
||||
"smuggler",
|
||||
"Smuggler must produce the exact pool key 'smuggler' used in monologue YAML"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detective_archetype_maps_to_monologue_key() {
|
||||
assert_eq!(
|
||||
CharacterArchetype::Detective.as_monologue_key(),
|
||||
"detective",
|
||||
"Detective must produce the exact pool key 'detective' used in monologue YAML"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_archetype_is_detective() {
|
||||
// D-010: the safe fallback is Detective (the original single-character game).
|
||||
// If serde default fires (old client, missing field), Detective must be chosen.
|
||||
assert_eq!(
|
||||
CharacterArchetype::default(),
|
||||
CharacterArchetype::Detective,
|
||||
"Default archetype must be Detective for backward compatibility (#587)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archetype_keys_are_distinct() {
|
||||
// Sanity guard: the two keys must differ. If they were the same, pool partitioning
|
||||
// (D-032) would be broken and both characters would see identical monologue lines.
|
||||
assert_ne!(
|
||||
CharacterArchetype::Smuggler.as_monologue_key(),
|
||||
CharacterArchetype::Detective.as_monologue_key(),
|
||||
"Smuggler and Detective monologue keys must be distinct (D-032 hard partition)"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer 2 — integration: setup_gauntlet wires archetype → MonologueState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
mod gauntlet_integration {
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
use settled_reach_server::{
|
||||
bridge::types::CharacterArchetype,
|
||||
simulation::{monologue::MonologueState, movement::PlayerCharacter, SimulationPlugin},
|
||||
test_world,
|
||||
};
|
||||
|
||||
/// Build a minimal Gauntlet app with the given archetype and run one tick.
|
||||
fn boot_gauntlet(archetype: CharacterArchetype) -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin { seed: 0 });
|
||||
test_world::setup_gauntlet(&mut app, archetype);
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smuggler_archetype_sets_monologue_character_to_smuggler() {
|
||||
let mut app = boot_gauntlet(CharacterArchetype::Smuggler);
|
||||
|
||||
let mut query = app
|
||||
.world_mut()
|
||||
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
|
||||
let state = query
|
||||
.single(app.world())
|
||||
.expect("player entity with MonologueState must exist after gauntlet setup");
|
||||
|
||||
assert_eq!(
|
||||
state.character, "smuggler",
|
||||
"Smuggler archetype must produce MonologueState.character = 'smuggler' (D-032, #587)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detective_archetype_sets_monologue_character_to_detective() {
|
||||
let mut app = boot_gauntlet(CharacterArchetype::Detective);
|
||||
|
||||
let mut query = app
|
||||
.world_mut()
|
||||
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
|
||||
let state = query
|
||||
.single(app.world())
|
||||
.expect("player entity with MonologueState must exist after gauntlet setup");
|
||||
|
||||
assert_eq!(
|
||||
state.character, "detective",
|
||||
"Detective archetype must produce MonologueState.character = 'detective' (D-032, #587)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smuggler_and_detective_produce_different_monologue_characters() {
|
||||
// Regression guard: if both sessions return the same character string, D-032
|
||||
// partitioning is broken. This test catches copy-paste mistakes in setup paths.
|
||||
let mut smuggler_app = boot_gauntlet(CharacterArchetype::Smuggler);
|
||||
let mut detective_app = boot_gauntlet(CharacterArchetype::Detective);
|
||||
|
||||
let smuggler_char = {
|
||||
let mut q = smuggler_app
|
||||
.world_mut()
|
||||
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
|
||||
q.single(smuggler_app.world())
|
||||
.expect("smuggler player must exist")
|
||||
.character
|
||||
.clone()
|
||||
};
|
||||
|
||||
let detective_char = {
|
||||
let mut q = detective_app
|
||||
.world_mut()
|
||||
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
|
||||
q.single(detective_app.world())
|
||||
.expect("detective player must exist")
|
||||
.character
|
||||
.clone()
|
||||
};
|
||||
|
||||
assert_ne!(
|
||||
smuggler_char, detective_char,
|
||||
"Smuggler and Detective sessions must have different MonologueState.character values \
|
||||
(D-032 hard partition: same key means both characters see each other's monologue pool)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -19,7 +19,6 @@ fn snapshot_roundtrip_over_tcp() {
|
||||
let bridge = TcpBridge::accept_on(listener).expect("failed to accept");
|
||||
|
||||
let snapshot = ObserverSnapshot {
|
||||
version: PROTOCOL_VERSION,
|
||||
tick: 42,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
|
||||
@@ -432,6 +432,7 @@ fn minimal_save() -> SaveStateV1 {
|
||||
contamination_active: false,
|
||||
activated_count: 0,
|
||||
last_activation_tick: None,
|
||||
selected_bookmark: settled_reach_server::bookmark::SelectedBookmark::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -16,10 +16,9 @@ fn write_fixture(name: &str, bytes: &[u8]) {
|
||||
eprintln!("Wrote {} ({} bytes)", path.display(), bytes.len());
|
||||
}
|
||||
|
||||
/// Helper to create a minimal v2 snapshot for fixtures
|
||||
/// Helper to create a minimal snapshot for fixtures (D-192: no version field)
|
||||
fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
ObserverSnapshot {
|
||||
version: PROTOCOL_VERSION,
|
||||
tick,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
@@ -181,7 +180,6 @@ fn generate_msgpack_fixtures() {
|
||||
|
||||
// v2 snapshot with visible_tiles and game_time populated
|
||||
let snapshot_v2_full = ObserverSnapshot {
|
||||
version: PROTOCOL_VERSION,
|
||||
tick: 500,
|
||||
game_time: GameTime {
|
||||
day: 1,
|
||||
@@ -302,7 +300,7 @@ fn generate_msgpack_fixtures() {
|
||||
|
||||
// === #271 fixtures: named fixtures for cross-language Layer 1 testing ===
|
||||
|
||||
// snapshot_minimal: version=PROTOCOL_VERSION, tick=0, one Player entity, all optionals absent
|
||||
// snapshot_minimal: tick=0, one Player entity, all optionals absent
|
||||
let snapshot_minimal = fixture_snapshot(
|
||||
0,
|
||||
vec![VisibleEntity {
|
||||
@@ -322,9 +320,8 @@ fn generate_msgpack_fixtures() {
|
||||
&rmp_serde::to_vec_named(&snapshot_minimal).unwrap(),
|
||||
);
|
||||
|
||||
// snapshot_full: version=PROTOCOL_VERSION, tick=42, monologue + dialogue + inventory + POIs + KG dump
|
||||
// snapshot_full: tick=42, monologue + dialogue + inventory + POIs + KG dump
|
||||
let snapshot_full = ObserverSnapshot {
|
||||
version: PROTOCOL_VERSION,
|
||||
tick: 42,
|
||||
game_time: GameTime {
|
||||
day: 3,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -99,13 +99,9 @@ fn ipc_round_trip_latency() {
|
||||
let handshake_bytes = read_framed(&mut reader)
|
||||
.expect("read handshake")
|
||||
.expect("server closed before sending HandshakeMessage");
|
||||
let handshake: HandshakeMessage =
|
||||
// D-192: HandshakeMessage carries no version field. Verify it deserialises cleanly.
|
||||
let _handshake: HandshakeMessage =
|
||||
rmp_serde::from_slice(&handshake_bytes).expect("deserialize HandshakeMessage");
|
||||
assert_eq!(
|
||||
handshake.protocol_version, PROTOCOL_VERSION,
|
||||
"handshake version mismatch: server={}, client={}",
|
||||
handshake.protocol_version, PROTOCOL_VERSION
|
||||
);
|
||||
|
||||
let make_input = |tick: u64| PlayerInput {
|
||||
tick,
|
||||
|
||||
+3
-15
@@ -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"
|
||||
|
||||
@@ -4,10 +4,9 @@ use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::simulation::time::{DayPhase, TickRate};
|
||||
use std::fs;
|
||||
|
||||
/// Helper to create a minimal v2 snapshot for tests
|
||||
/// Helper to create a minimal snapshot for tests (D-192: no version field)
|
||||
fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
ObserverSnapshot {
|
||||
version: PROTOCOL_VERSION,
|
||||
tick,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
@@ -65,7 +64,6 @@ fn observer_snapshot_roundtrip() {
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
|
||||
assert_eq!(decoded.version, PROTOCOL_VERSION);
|
||||
assert_eq!(decoded.tick, 42);
|
||||
assert_eq!(decoded.entities.len(), 1);
|
||||
assert_eq!(decoded.entities[0].entity_id, 1);
|
||||
@@ -171,18 +169,13 @@ fn all_fixtures_deserialize() {
|
||||
let bytes = fs::read(&path).unwrap_or_else(|_| panic!("read fixture {}", name));
|
||||
|
||||
if name.starts_with("snapshot_boundary") {
|
||||
// Boundary snapshot fixtures (#472): tick may exceed PROTOCOL_VERSION check
|
||||
// Boundary snapshot fixtures (#472)
|
||||
rmp_serde::from_slice::<ObserverSnapshot>(&bytes).unwrap_or_else(|e| {
|
||||
panic!("deserialize boundary snapshot fixture {}: {}", name, e)
|
||||
});
|
||||
} else if name.starts_with("snapshot") {
|
||||
let snap = rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
|
||||
rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize snapshot fixture {}: {}", name, e));
|
||||
assert_eq!(
|
||||
snap.version, PROTOCOL_VERSION,
|
||||
"fixture {} has wrong version",
|
||||
name
|
||||
);
|
||||
} else if name.starts_with("input_batch") {
|
||||
rmp_serde::from_slice::<Vec<PlayerInput>>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e));
|
||||
@@ -244,7 +237,6 @@ fn all_entity_kind_variants_roundtrip() {
|
||||
#[test]
|
||||
fn snapshot_v2_fields_roundtrip() {
|
||||
let snapshot = ObserverSnapshot {
|
||||
version: PROTOCOL_VERSION,
|
||||
tick: 100,
|
||||
game_time: GameTime {
|
||||
day: 3,
|
||||
@@ -311,7 +303,6 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
|
||||
assert_eq!(decoded.version, PROTOCOL_VERSION);
|
||||
assert_eq!(decoded.game_time.day, 3);
|
||||
assert_eq!(decoded.game_time.time_of_day, 720);
|
||||
assert_eq!(decoded.game_time.day_phase, DayPhase::Evening);
|
||||
@@ -354,17 +345,6 @@ fn entity_to_bits_roundtrip() {
|
||||
}
|
||||
}
|
||||
|
||||
/// PROTOCOL_VERSION constant matches snapshot version field
|
||||
#[test]
|
||||
fn protocol_version_constant_matches_snapshot() {
|
||||
let snapshot = test_snapshot(0, vec![]);
|
||||
assert_eq!(snapshot.version, PROTOCOL_VERSION);
|
||||
assert_eq!(
|
||||
PROTOCOL_VERSION, 21,
|
||||
"bump this assertion when protocol version changes"
|
||||
);
|
||||
}
|
||||
|
||||
/// All FacingDirection variants round-trip
|
||||
#[test]
|
||||
fn all_facing_direction_variants_roundtrip() {
|
||||
@@ -381,7 +361,6 @@ fn all_facing_direction_variants_roundtrip() {
|
||||
|
||||
for dir in directions {
|
||||
let snapshot = ObserverSnapshot {
|
||||
version: PROTOCOL_VERSION,
|
||||
tick: 0,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
@@ -525,7 +504,6 @@ fn v5_payload_deserializes_into_v6_struct() {
|
||||
.expect("v5 payload should deserialize into v6 struct via serde(default)");
|
||||
|
||||
// New fields should get their defaults
|
||||
assert_eq!(decoded.version, 5, "version field preserved from v5");
|
||||
assert_eq!(decoded.tick, 42);
|
||||
assert_eq!(
|
||||
decoded.player_stance,
|
||||
@@ -717,23 +695,6 @@ fn verb_kind_confront_roundtrip() {
|
||||
assert_eq!(decoded.nearby_interactions[0].verbs[0].label, "Confront");
|
||||
}
|
||||
|
||||
/// CharacterArchetype enum round-trips through MessagePack (#422).
|
||||
/// Used in Phase 2 label relabeling — must survive the wire.
|
||||
#[test]
|
||||
fn all_character_archetype_variants_roundtrip() {
|
||||
let archetypes = [CharacterArchetype::Smuggler, CharacterArchetype::Detective];
|
||||
|
||||
for archetype in archetypes {
|
||||
let bytes = rmp_serde::to_vec_named(&archetype).expect("serialize");
|
||||
let decoded: CharacterArchetype = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(
|
||||
decoded, archetype,
|
||||
"CharacterArchetype::{:?} roundtrip failed",
|
||||
archetype
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// NearbyInteraction.contradicted=true round-trips through MessagePack (#422).
|
||||
/// Guards the contradiction flag survives serialization.
|
||||
#[test]
|
||||
@@ -1247,7 +1208,6 @@ fn v8_payload_deserializes_into_v9_struct() {
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
||||
.expect("v8 payload should deserialize into v9 struct via serde(default)");
|
||||
|
||||
assert_eq!(decoded.version, 8, "version field preserved from v8");
|
||||
assert_eq!(decoded.tick, 100);
|
||||
assert!(
|
||||
decoded.blocked_entities.is_empty(),
|
||||
@@ -1321,7 +1281,6 @@ fn v9_payload_deserializes_into_v10_struct() {
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
||||
.expect("v9 payload should deserialize into v10 struct via serde(default)");
|
||||
|
||||
assert_eq!(decoded.version, 9, "version field preserved from v9");
|
||||
assert_eq!(decoded.tick, 200);
|
||||
assert_eq!(
|
||||
decoded.rng_seed, None,
|
||||
@@ -1397,7 +1356,6 @@ fn v10_payload_deserializes_into_v11_struct() {
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
||||
.expect("v10 payload should deserialize into v11 struct via serde(default)");
|
||||
|
||||
assert_eq!(decoded.version, 10, "version field preserved from v10");
|
||||
assert_eq!(decoded.tick, 300);
|
||||
assert_eq!(decoded.visible_tiles.len(), 1);
|
||||
assert_eq!(
|
||||
@@ -1458,8 +1416,6 @@ fn serde_default_fields_fill_in_when_missing_from_wire() {
|
||||
let decoded: ObserverSnapshot =
|
||||
serde_json::from_value(minimal_json).expect("minimal JSON must deserialize");
|
||||
|
||||
// Version matches what was in the wire
|
||||
assert_eq!(decoded.version, 13);
|
||||
assert_eq!(decoded.tick, 42);
|
||||
assert_eq!(decoded.entities.len(), 1);
|
||||
|
||||
@@ -1492,28 +1448,6 @@ fn serde_default_fields_fill_in_when_missing_from_wire() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A snapshot with version != PROTOCOL_VERSION can be detected by checking
|
||||
/// the version field after deserialization (#232 compatibility checking).
|
||||
#[test]
|
||||
fn snapshot_version_mismatch_is_detectable() {
|
||||
let mut snapshot = test_snapshot(0, vec![]);
|
||||
let future_version: u8 = PROTOCOL_VERSION + 1;
|
||||
snapshot.version = future_version;
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
|
||||
// The version field faithfully preserves the value — caller detects mismatch
|
||||
assert_eq!(
|
||||
decoded.version, future_version,
|
||||
"version field must survive round-trip unchanged"
|
||||
);
|
||||
assert_ne!(
|
||||
decoded.version, PROTOCOL_VERSION,
|
||||
"client should detect this as a version mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
/// tell_state=None is skipped in msgpack serialization (skip_serializing_if).
|
||||
/// A snapshot with tell_state=None produces fewer bytes than one with
|
||||
/// tell_state=Some(Nervous) — demonstrates the skip_serializing_if contract.
|
||||
@@ -1627,19 +1561,6 @@ fn all_verb_kind_variants_roundtrip_v232() {
|
||||
}
|
||||
}
|
||||
|
||||
/// PROTOCOL_VERSION u8 type fits in one byte — wire overhead is minimal (#232).
|
||||
/// This guards against accidental widening of the version type.
|
||||
#[test]
|
||||
fn protocol_version_fits_in_u8() {
|
||||
// u8 max is 255 — enough for ~242 more protocol iterations.
|
||||
// If PROTOCOL_VERSION ever reaches 200, consider migrating to u16.
|
||||
assert!(
|
||||
PROTOCOL_VERSION <= 200,
|
||||
"PROTOCOL_VERSION={} is approaching u8 saturation; consider widening the type",
|
||||
PROTOCOL_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
|
||||
/// Verifies object_type=Some(Container) survives the wire.
|
||||
#[test]
|
||||
@@ -1687,7 +1608,6 @@ fn fixture_snapshot_minimal_fields() {
|
||||
let snap: ObserverSnapshot =
|
||||
rmp_serde::from_slice(&bytes).expect("deserialize snapshot_minimal");
|
||||
|
||||
assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch");
|
||||
assert_eq!(snap.tick, 0, "tick should be 0");
|
||||
assert_eq!(snap.entities.len(), 1, "should have exactly 1 entity");
|
||||
assert_eq!(snap.entities[0].entity_id, 1);
|
||||
@@ -1707,7 +1627,6 @@ fn fixture_snapshot_full_fields() {
|
||||
let bytes = read_named_fixture("snapshot_full");
|
||||
let snap: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize snapshot_full");
|
||||
|
||||
assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch");
|
||||
assert_eq!(snap.tick, 42, "tick should be 42");
|
||||
|
||||
// Monologue
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
//! v0.1 integration playthrough test (#593, D-027).
|
||||
//!
|
||||
//! Validates the full session lifecycle from StartupMessage to storyteller activation:
|
||||
//! D-027 criterion 1: player sees opening monologue on session start
|
||||
//! D-027 criterion 4: NPC RoutineDeviation tell observable after triangle activation
|
||||
//! D-036: news ticker headline visible in The Last Shift zone
|
||||
//!
|
||||
//! Test structure:
|
||||
//! - `test_smuggler_opening_monologue`: asserts smuggler pool fires on tick 1 (runs now)
|
||||
//! - `test_detective_opening_monologue`: asserts detective pool fires on tick 1 (runs now)
|
||||
//! - `test_v0_1_integration_playthrough`: full E2E proof (#[ignore] until #589, #591 land)
|
||||
//!
|
||||
//! Uses Layer 3 pattern: real server subprocess, TCP IPC, no mocks.
|
||||
//!
|
||||
//! Prerequisites to unblock:
|
||||
//! #589: escalate_tells_on_activation system (for RoutineDeviation assertion)
|
||||
//! #591: TickerPool + current_ticker in snapshot (for ticker assertion)
|
||||
|
||||
use settled_reach_server::bridge::framing::{read_framed, write_framed};
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::npc::tell_state::TellCategory;
|
||||
use std::io::{BufRead, BufReader, BufWriter};
|
||||
use std::net::TcpStream;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Timeout for the server to emit LISTENING:{port} on stdout.
|
||||
const LISTEN_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
/// Timeout for any individual snapshot read.
|
||||
const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server lifecycle helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct TestServer {
|
||||
child: std::process::Child,
|
||||
reader: BufReader<TcpStream>,
|
||||
writer: BufWriter<TcpStream>,
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
/// Boot the server binary in test mode (gauntlet), send StartupMessage,
|
||||
/// return a connected handle ready to receive snapshots.
|
||||
fn boot_gauntlet(world_seed: u64, archetype: CharacterArchetype) -> Self {
|
||||
let server_bin = env!("CARGO_BIN_EXE_settled-reach-server");
|
||||
let mut child = Command::new(server_bin)
|
||||
.args(["--test-mode", "--port", "0"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to spawn server binary");
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout not captured");
|
||||
let mut stdout_reader = BufReader::new(stdout);
|
||||
|
||||
// Parse LISTENING:{port}
|
||||
let port = {
|
||||
let deadline = Instant::now() + LISTEN_TIMEOUT;
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match stdout_reader.read_line(&mut line) {
|
||||
Ok(0) => panic!("server stdout closed before LISTENING signal"),
|
||||
Ok(_) => {
|
||||
let trimmed = line.trim();
|
||||
if let Some(port_str) = trimmed.strip_prefix("LISTENING:") {
|
||||
break port_str.parse::<u16>().expect("invalid port");
|
||||
}
|
||||
}
|
||||
Err(e) => panic!("failed to read server stdout: {}", e),
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"timed out waiting for LISTENING signal"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
let stream = TcpStream::connect(&addr).expect("client connect");
|
||||
stream
|
||||
.set_read_timeout(Some(SNAPSHOT_TIMEOUT))
|
||||
.expect("set timeout");
|
||||
let mut reader = BufReader::new(stream.try_clone().expect("clone stream"));
|
||||
let mut writer = BufWriter::new(stream);
|
||||
|
||||
// Protocol handshake
|
||||
let hf = read_framed(&mut reader)
|
||||
.expect("read handshake")
|
||||
.expect("connection closed");
|
||||
let _: HandshakeMessage = rmp_serde::from_slice(&hf).expect("deserialize handshake");
|
||||
|
||||
// StartupMessage with chosen archetype
|
||||
let startup = StartupMessage {
|
||||
world_seed,
|
||||
character_archetype: archetype,
|
||||
};
|
||||
let startup_payload = rmp_serde::to_vec_named(&startup).expect("serialize startup");
|
||||
write_framed(&mut writer, &startup_payload).expect("send startup");
|
||||
|
||||
TestServer {
|
||||
child,
|
||||
reader,
|
||||
writer,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a tick's worth of inputs (empty = idle tick) and read back one snapshot.
|
||||
fn tick(&mut self, inputs: Vec<PlayerInput>) -> ObserverSnapshot {
|
||||
let payload = rmp_serde::to_vec_named(&inputs).expect("serialize inputs");
|
||||
write_framed(&mut self.writer, &payload).expect("send inputs");
|
||||
|
||||
let frame = read_framed(&mut self.reader)
|
||||
.expect("read snapshot frame")
|
||||
.expect("server closed connection");
|
||||
rmp_serde::from_slice(&frame).expect("deserialize snapshot")
|
||||
}
|
||||
|
||||
/// Send a debug command and get the next snapshot.
|
||||
fn send_debug(&mut self, cmd: DebugCommandKind) -> ObserverSnapshot {
|
||||
self.tick(vec![PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::DebugCommand(cmd),
|
||||
}])
|
||||
}
|
||||
|
||||
fn shutdown(mut self) {
|
||||
drop(self.reader);
|
||||
drop(self.writer);
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
match self.child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {
|
||||
if Instant::now() > deadline {
|
||||
self.child.kill().ok();
|
||||
self.child.wait().ok();
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(_) => {
|
||||
self.child.kill().ok();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: opening monologue archetype partitioning (runs now — no #[ignore])
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_smuggler_opening_monologue() {
|
||||
// Boot with Smuggler, advance 1 tick, assert opening monologue fires from smuggler pool.
|
||||
// Monologue IDs from smuggler/opening.yaml start with "pc-smuggler_".
|
||||
// This verifies: archetype → MonologueState.character → pool selection (D-032, #587, #595).
|
||||
let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler);
|
||||
let snapshot = server.tick(vec![PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
}]);
|
||||
|
||||
assert_eq!(
|
||||
snapshot.version, PROTOCOL_VERSION,
|
||||
"protocol version mismatch"
|
||||
);
|
||||
|
||||
let monologue = snapshot.current_monologue;
|
||||
assert!(
|
||||
monologue.is_some(),
|
||||
"Smuggler session must fire opening monologue on tick 1 (enter_location trigger, D-027 criterion 1). \
|
||||
Got None — either MonologueState.character is wrong or opening.yaml lines are not loaded."
|
||||
);
|
||||
|
||||
let monologue = monologue.unwrap();
|
||||
assert!(
|
||||
monologue.id.starts_with("pc-smuggler_"),
|
||||
"Smuggler opening monologue ID must start with 'pc-smuggler_' (D-032 hard partition). \
|
||||
Got id='{}'. Likely cause: MonologueState.character defaulted to 'detective' despite Smuggler archetype.",
|
||||
monologue.id
|
||||
);
|
||||
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detective_opening_monologue() {
|
||||
// Boot with Detective, advance 1 tick, assert opening monologue fires from detective pool.
|
||||
// Monologue IDs from detective/opening.yaml start with "pc-detective_".
|
||||
let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Detective);
|
||||
let snapshot = server.tick(vec![PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
}]);
|
||||
|
||||
assert_eq!(
|
||||
snapshot.version, PROTOCOL_VERSION,
|
||||
"protocol version mismatch"
|
||||
);
|
||||
|
||||
let monologue = snapshot.current_monologue;
|
||||
assert!(
|
||||
monologue.is_some(),
|
||||
"Detective session must fire opening monologue on tick 1 (enter_location trigger). \
|
||||
Got None — either MonologueState.character is wrong or opening.yaml lines are not loaded."
|
||||
);
|
||||
|
||||
let monologue = monologue.unwrap();
|
||||
assert!(
|
||||
monologue.id.starts_with("pc-detective_"),
|
||||
"Detective opening monologue ID must start with 'pc-detective_' (D-032 hard partition). \
|
||||
Got id='{}'. Likely cause: archetype defaulted incorrectly.",
|
||||
monologue.id
|
||||
);
|
||||
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smuggler_and_detective_get_different_opening_monologue_ids() {
|
||||
// Regression guard: two sessions with different archetypes must never produce
|
||||
// the same monologue ID on tick 1. If they do, D-032 partitioning is broken.
|
||||
let mut smug = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler);
|
||||
let smug_snap = smug.tick(vec![]);
|
||||
let smug_id = smug_snap
|
||||
.current_monologue
|
||||
.as_ref()
|
||||
.map(|m| m.id.clone())
|
||||
.unwrap_or_default();
|
||||
smug.shutdown();
|
||||
|
||||
let mut det = TestServer::boot_gauntlet(12345, CharacterArchetype::Detective);
|
||||
let det_snap = det.tick(vec![]);
|
||||
let det_id = det_snap
|
||||
.current_monologue
|
||||
.as_ref()
|
||||
.map(|m| m.id.clone())
|
||||
.unwrap_or_default();
|
||||
det.shutdown();
|
||||
|
||||
assert_ne!(
|
||||
smug_id, det_id,
|
||||
"Smuggler and Detective must fire different opening monologue IDs (D-032). \
|
||||
Both got '{}' — pool partitioning is broken.",
|
||||
smug_id
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full v0.1 playthrough proof (blocked until #589 + #591 land)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
#[ignore = "blocked: TeleportToLocation debug command not implemented (needs location tile_bounds from ContentStore). Criteria 1+2 covered by non-ignored tests above."]
|
||||
fn test_v0_1_integration_playthrough() {
|
||||
// Full E2E proof per D-027 v0.1 success criteria:
|
||||
// 1. Opening monologue fires in correct character pool
|
||||
// 2. After activation, anchor NPC shows RoutineDeviation tell
|
||||
// 3. News ticker visible when player is in "bar" zone
|
||||
// (Manual criterion: walk to terminal, observe Kael, see fog-and-tension)
|
||||
|
||||
let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler);
|
||||
|
||||
// === Criterion 1: Opening monologue (Smuggler) ===
|
||||
let tick1 = server.tick(vec![]);
|
||||
let monologue = tick1
|
||||
.current_monologue
|
||||
.expect("Opening monologue must fire on tick 1");
|
||||
assert!(
|
||||
monologue.id.starts_with("pc-smuggler_"),
|
||||
"Tick-1 monologue must be from smuggler pool. Got: {}",
|
||||
monologue.id
|
||||
);
|
||||
|
||||
// === Skip to contamination phase (fast-forward via debug) ===
|
||||
let _skip_snap = server.send_debug(DebugCommandKind::SkipToContamination);
|
||||
let _contaminate = server.send_debug(DebugCommandKind::ForceContaminationActivate);
|
||||
|
||||
// === Run ticks and watch for triangle activation ===
|
||||
let mut triangle_crisis_observed = false;
|
||||
for _ in 0..20 {
|
||||
let snap = server.tick(vec![]);
|
||||
if !snap.triangle_crisis_events.is_empty() {
|
||||
triangle_crisis_observed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
triangle_crisis_observed,
|
||||
"Triangle crisis event must appear within 20 ticks after contamination activation (#589)"
|
||||
);
|
||||
|
||||
// === Criterion 2 (D-027 criterion 4): RoutineDeviation tell visible ===
|
||||
// After activation, at least one NPC must show RoutineDeviation tell in the snapshot.
|
||||
let mut deviation_observed = false;
|
||||
for _ in 0..5 {
|
||||
let snap = server.tick(vec![]);
|
||||
if snap
|
||||
.entities
|
||||
.iter()
|
||||
.any(|e| e.tell_state == Some(TellCategory::RoutineDeviation))
|
||||
{
|
||||
deviation_observed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
deviation_observed,
|
||||
"After triangle activation, at least one NPC must show RoutineDeviation tell (D-027 criterion 4, #589)"
|
||||
);
|
||||
|
||||
// === Criterion 3 (D-036): News ticker visible in bar zone ===
|
||||
// Teleport to The Last Shift bar zone and check current_ticker is Some.
|
||||
let _teleport = server.send_debug(DebugCommandKind::TeleportToLocation(
|
||||
"the-last-shift".into(),
|
||||
));
|
||||
let bar_snap = server.tick(vec![]);
|
||||
assert!(
|
||||
bar_snap.current_ticker.is_some(),
|
||||
"current_ticker must be Some when player is in 'the-last-shift' zone (D-036, #591)"
|
||||
);
|
||||
|
||||
server.shutdown();
|
||||
}
|
||||
+3
-1
@@ -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)
|
||||
|
||||
Executable
+139
@@ -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
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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: []
|
||||
---
|
||||
|
||||
@@ -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: []
|
||||
---
|
||||
|
||||
|
||||
@@ -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: []
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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: []
|
||||
---
|
||||
|
||||
|
||||
@@ -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: []
|
||||
---
|
||||
|
||||
@@ -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: []
|
||||
---
|
||||
|
||||
|
||||
@@ -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: []
|
||||
---
|
||||
|
||||
|
||||
@@ -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: []
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -9,7 +9,7 @@ updated: 2026-04-05
|
||||
scope: reach-wide
|
||||
faction_type: economic
|
||||
headquarters: Matamba (GJ 884)
|
||||
tags: [medical_goods, chemicals, south_reach, tractus, assembly, distributor]
|
||||
tags: [chemical_feedstock, medical_goods, chemicals, south_reach, tractus, assembly, distributor]
|
||||
decision_refs: [D-175]
|
||||
cross_refs: []
|
||||
---
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: "Scapa Flow Industries"
|
||||
description: "Station-based industrial manufacturer at Scapa Flow (GJ 570A) — fusion fuel bunkering and structural panel fabrication for the Bastion corridor, operating as the system's primary heavy industrial concern"
|
||||
slug: scapa-flow-industries
|
||||
category: corporation
|
||||
status: canonical
|
||||
created: 2026-04-21
|
||||
updated: 2026-04-21
|
||||
scope: GJ 570A local; Bastion corridor secondary
|
||||
faction_type: economic
|
||||
headquarters: Scapa Flow (GJ 570A)
|
||||
tags: [fusion_fuel, structural_panels, manufacturing, tractus]
|
||||
decision_refs: [D-175]
|
||||
cross_refs: []
|
||||
---
|
||||
|
||||
# Scapa Flow Industries
|
||||
|
||||
**Type:** Corporation — Industrial Manufacturing and Fuel Bunkering
|
||||
**Also Known As:** Scapa Flow, SFI
|
||||
**Status:** Canonical
|
||||
**Scope:** Bastion system primary; outer corridor secondary
|
||||
**Headquarters:** Scapa Flow station (GJ 570A, Bastion system) — orbital industrial platform
|
||||
**Classification:** Industrial manufacturer; producer behavioral archetype
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Scapa Flow station was built as a fuel depot and grew into something larger. The outer corridor systems need bunkering infrastructure, and Scapa Flow's position in Bastion made it the logical hub — ships transiting the outer routes pass through here, and the infrastructure investment compounded over generations.
|
||||
|
||||
Scapa Flow Industries now runs two parallel operations from the station: fuel bunkering (buying fusion fuel from frontier suppliers and reselling at a Bastion-corridor price) and structural panel fabrication for station-scale construction projects in the outer systems. The panel operation started as a necessity — outer systems had long lead times on materials from the inner corridor — and became a competitive product in its own right.
|
||||
|
||||
**Primary operations:** Fusion fuel bunkering and resale, structural composite panel fabrication for station construction.
|
||||
|
||||
**Market position:** Dominant in Bastion system; cost-competitive in outer corridor against inner-corridor suppliers due to reduced transit costs.
|
||||
@@ -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.
|
||||
@@ -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: []
|
||||
---
|
||||
|
||||
|
||||
@@ -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: []
|
||||
---
|
||||
|
||||
@@ -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: []
|
||||
---
|
||||
|
||||
|
||||
@@ -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: []
|
||||
---
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ updated: 2026-03-16
|
||||
scope: north_reach primary; transit corridor fashion markets secondary
|
||||
faction_type: economic
|
||||
headquarters: Braemar (GJ 475)
|
||||
tags: []
|
||||
decision_refs: []
|
||||
tags: [textiles, clothing, north_reach, tractus, cooperative]
|
||||
decision_refs: [D-175]
|
||||
cross_refs: [calloway-distillery, mercado-travessia]
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: "Threshold Fuel Syndicate"
|
||||
description: "Ice harvesting and fusion fuel production at Tau Ceti — the corridor's most reliable frontier fuel supplier, operating from Threshold's outer ice bodies where water supply is consistent and competition is thin"
|
||||
slug: threshold-fuel-syndicate
|
||||
category: corporation
|
||||
status: canonical
|
||||
created: 2026-04-21
|
||||
updated: 2026-04-21
|
||||
scope: tau_ceti local; east corridor secondary
|
||||
faction_type: economic
|
||||
headquarters: Threshold (GJ 71)
|
||||
tags: [fusion_fuel, water, ice_harvesting, frontier, independent]
|
||||
decision_refs: [D-175]
|
||||
cross_refs: []
|
||||
---
|
||||
|
||||
# Threshold Fuel Syndicate
|
||||
|
||||
**Type:** Corporation — Ice Harvesting and Fusion Fuel Refinery
|
||||
**Also Known As:** Threshold Fuel, TFS
|
||||
**Status:** Canonical
|
||||
**Scope:** Tau Ceti primary; corridor fuel supply secondary
|
||||
**Headquarters:** Threshold (GJ 71, Tau Ceti system) — outer system operations
|
||||
**Classification:** Resource extraction syndicate; monopolist behavioral archetype (local)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Tau Ceti's outer ice bodies contain one of the most accessible water reserves in the east corridor. Threshold Fuel Syndicate was formed by a consortium of Threshold settlers who realized that controlling that water supply meant controlling fuel production for every ship passing through.
|
||||
|
||||
Three generations later, TFS operates a vertically integrated operation: ice extraction, water processing, and fusion fuel refinery all under one contract structure. Local competitors have tried and withdrawn; the capital cost of orbital ice-cracking infrastructure is a high barrier.
|
||||
|
||||
**Primary operations:** Comet and ice-body water extraction, electrolytic processing, fusion fuel synthesis at Threshold orbital platform.
|
||||
|
||||
**Market position:** Dominant fuel supplier for Tau Ceti system; significant spot-market presence in the east corridor where Lagrange Fuel Systems has thinner coverage.
|
||||
@@ -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: []
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user