Sprint 37 dead-code sweep closing out two stale supersession chains: #877 (D-167, 2026-03-24): Removes HeritageRoot type alias and ZonePaletteModifier::Heritage variant from server/src/simulation/ generator.rs. The 7 abstract heritage roots were retired in favour of the corridor cultural system; these two stubs were the only remaining references. #878 (D-032 + cascade rule): Strips the entire CharacterArchetype (Smuggler/Detective) trace from the server. Per lead direction 2026-04-21 and the development cascade (CLAUDE.md), character/NPC/ verb-differentiation/monologue code is Phase 6 detail that should not exist in code yet. The running archetype trace was pre-cascade filler, not production — production is only the client's character- creation UI and insert screens (client follow-up in #882). Deleted: - CharacterArchetype enum + StartupMessage.character_archetype field - archetype_verb_label() + archetype branch of apply_phase2_verb_filter (D-057 character-verb differentiation — marked superseded) - MonologueState.character partitioning - Gauntlet archetype plumbing (setup_gauntlet no longer takes an archetype) - server/content/schemas/drama_module.schema.yaml (zero Rust consumers) - server/content/modules/tier1/smuggling_ring_v0_1.yaml - server/tests/archetype_monologue.rs (regression guard for the removed system) - server/tests/v01_integration_playthrough.rs (archetype-dependent) Decision updates: - decisions/content.md D-032 supersession rewritten to cite the cascade (v0.2 drop invalidated the prior D-117 framing). - decisions/content.md D-035 tag taxonomy: `character` enum footnote updated; field noted as unused, do not reintroduce without a confirmed Phase 6 design. - decisions/perception.md D-057: archetype-verb differentiation marked superseded. Also bundles the types.rs version-field removal from #874 since the file was already touched here. Full trace audit in docs/architecture/sprint-37-878-audit.md. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -46,7 +46,7 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
|
|||||||
|
|
||||||
### D-032: Separate monologue pools per character [SUPERSEDED]
|
### D-032: Separate monologue pools per character [SUPERSEDED]
|
||||||
- **Date:** 2026-02-11
|
- **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). 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-21, Sprint 37): enum + content scaffolding 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`.
|
||||||
- **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`).
|
- **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.
|
- **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.
|
- **Cross-reference:** Dialogue lines remain character-agnostic — the access tier system (D-028 Layer 1) handles per-character filtering without separate pools.
|
||||||
@@ -80,7 +80,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
|
- `mood` (list\<enum\>): 8 moods for v0.1 — D-028 Layer 4 weighted selection
|
||||||
- `tags` (list\<string\>): freeform escape hatch for author intent
|
- `tags` (list\<string\>): freeform escape hatch for author intent
|
||||||
- **Monologue-specific additions:**
|
- **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)
|
- `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
|
- `prerequisite` (map or null): knowledge state gate
|
||||||
- **Authoring-only tags (not consumed by engine):** `dual_lens` (map, per-character notes), `notes` (string)
|
- **Authoring-only tags (not consumed by engine):** `dual_lens` (map, per-character notes), `notes` (string)
|
||||||
|
|||||||
@@ -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`).
|
- `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.
|
- 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]
|
||||||
- **Date:** 2026-02-13
|
- **Date:** 2026-02-13
|
||||||
|
- **Supersession note (2026-04-21, Sprint 37, #878):** The character-archetype verb variation portion of this decision is superseded by 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; verbs now render with their base labels regardless of character.
|
||||||
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **References:** Disco Elysium (world-embedded indicators), Darkwood (minimal cursor), Rimworld (right-click context list).
|
||||||
|
|||||||
@@ -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
|
|
||||||
+15
-100
@@ -10,24 +10,15 @@ pub use crate::knowledge::types::{
|
|||||||
};
|
};
|
||||||
pub use crate::simulation::time::{DayPhase, TickRate};
|
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).
|
/// Handshake message sent as the very first framed message after connection (#555).
|
||||||
/// Client reads this before entering the normal tick loop and validates
|
/// Client reads this before entering the normal tick loop, then sends StartupMessage.
|
||||||
/// `protocol_version` against its own `PROTOCOL_VERSION` constant.
|
|
||||||
/// Wire format: MessagePack, same 4-byte length-prefixed framing as ObserverSnapshot.
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct HandshakeMessage {
|
pub struct HandshakeMessage {}
|
||||||
/// Must match client's PROTOCOL_VERSION or the client should disconnect.
|
|
||||||
pub protocol_version: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Startup message sent by the client after receiving HandshakeMessage (#175).
|
/// Startup message sent by the client after receiving HandshakeMessage (#175).
|
||||||
/// Contains the world seed for deterministic simulation (D-010, D-029).
|
/// Contains the world seed for deterministic simulation (D-010, D-029).
|
||||||
@@ -46,12 +37,6 @@ pub struct StartupMessage {
|
|||||||
/// Generated by SessionManager.new_game() on the client.
|
/// Generated by SessionManager.new_game() on the client.
|
||||||
/// Same seed → same EntanglementConfig → same NPC population (D-029).
|
/// Same seed → same EntanglementConfig → same NPC population (D-029).
|
||||||
pub world_seed: u64,
|
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)
|
/// The ONLY data structure crossing the client-server boundary (D-020)
|
||||||
@@ -79,7 +64,8 @@ pub struct StartupMessage {
|
|||||||
/// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick),
|
/// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick),
|
||||||
/// sim_errors (#85, structured error reporting to client).
|
/// sim_errors (#85, structured error reporting to client).
|
||||||
/// v18 adds: debug_response (#580, debug console server — command/response wire).
|
/// 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 per D-032 purge / cascade cleanup.)
|
||||||
/// v20 adds: settings_response (#627, SQLite settings IPC).
|
/// v20 adds: settings_response (#627, SQLite settings IPC).
|
||||||
/// v21 adds: economy_snapshot (#822, D-181 7-signal snapshot per queried system),
|
/// v21 adds: economy_snapshot (#822, D-181 7-signal snapshot per queried system),
|
||||||
/// EconStateQuery PlayerAction variant (#822).
|
/// EconStateQuery PlayerAction variant (#822).
|
||||||
@@ -89,8 +75,6 @@ pub struct StartupMessage {
|
|||||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ObserverSnapshot {
|
pub struct ObserverSnapshot {
|
||||||
/// Protocol version for forward compatibility. See [`PROTOCOL_VERSION`].
|
|
||||||
pub version: u8,
|
|
||||||
/// Simulation tick when this snapshot was produced
|
/// Simulation tick when this snapshot was produced
|
||||||
pub tick: u64,
|
pub tick: u64,
|
||||||
/// Game time data for client HUD display (D-031)
|
/// Game time data for client HUD display (D-031)
|
||||||
@@ -495,28 +479,6 @@ pub enum ObjectType {
|
|||||||
Furniture,
|
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)
|
/// Semantic player actions, not raw key events (D-020)
|
||||||
/// Timestamped for deterministic processing
|
/// Timestamped for deterministic processing
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -1105,73 +1067,28 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handshake_message_roundtrip() {
|
fn handshake_message_roundtrip() {
|
||||||
let msg = HandshakeMessage {
|
// D-192: HandshakeMessage carries no version field; roundtrip verifies
|
||||||
protocol_version: PROTOCOL_VERSION,
|
// the empty struct serialises and deserialises cleanly.
|
||||||
};
|
let msg = HandshakeMessage {};
|
||||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||||
let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||||
assert_eq!(decoded, msg);
|
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]
|
#[test]
|
||||||
fn startup_message_roundtrip() {
|
fn startup_message_roundtrip() {
|
||||||
let msg = StartupMessage {
|
let msg = StartupMessage {
|
||||||
world_seed: 0xDEADBEEF,
|
world_seed: 0xDEADBEEF,
|
||||||
character_archetype: CharacterArchetype::Detective,
|
|
||||||
};
|
};
|
||||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||||
assert_eq!(decoded, msg);
|
assert_eq!(decoded, msg);
|
||||||
assert_eq!(decoded.world_seed, 0xDEADBEEF);
|
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]
|
#[test]
|
||||||
fn startup_message_zero_seed() {
|
fn startup_message_zero_seed() {
|
||||||
let msg = StartupMessage {
|
let msg = StartupMessage { world_seed: 0 };
|
||||||
world_seed: 0,
|
|
||||||
character_archetype: CharacterArchetype::default(),
|
|
||||||
};
|
|
||||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||||
assert_eq!(decoded.world_seed, 0);
|
assert_eq!(decoded.world_seed, 0);
|
||||||
@@ -1181,7 +1098,6 @@ mod tests {
|
|||||||
fn startup_message_max_seed() {
|
fn startup_message_max_seed() {
|
||||||
let msg = StartupMessage {
|
let msg = StartupMessage {
|
||||||
world_seed: u64::MAX,
|
world_seed: u64::MAX,
|
||||||
character_archetype: CharacterArchetype::default(),
|
|
||||||
};
|
};
|
||||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||||
@@ -1191,10 +1107,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn handshake_is_distinct_from_snapshot() {
|
fn handshake_is_distinct_from_snapshot() {
|
||||||
// HandshakeMessage and ObserverSnapshot are different types on the wire.
|
// HandshakeMessage and ObserverSnapshot are different types on the wire.
|
||||||
// A HandshakeMessage should NOT deserialize as an ObserverSnapshot.
|
// A HandshakeMessage (empty map) must NOT deserialize as an ObserverSnapshot
|
||||||
let msg = HandshakeMessage {
|
// because ObserverSnapshot has required fields (tick, game_time, etc.).
|
||||||
protocol_version: PROTOCOL_VERSION,
|
let msg = HandshakeMessage {};
|
||||||
};
|
|
||||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||||
let result = rmp_serde::from_slice::<ObserverSnapshot>(&bytes);
|
let result = rmp_serde::from_slice::<ObserverSnapshot>(&bytes);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ pub fn compute_observer_snapshot(
|
|||||||
&mut NearbyInteractionBuffer,
|
&mut NearbyInteractionBuffer,
|
||||||
&mut MonologueBuffer,
|
&mut MonologueBuffer,
|
||||||
Option<&Stance>,
|
Option<&Stance>,
|
||||||
Option<&CharacterArchetype>,
|
|
||||||
Option<&mut SprintAnomalyQueue>,
|
Option<&mut SprintAnomalyQueue>,
|
||||||
Option<&CognitiveDelay>,
|
Option<&CognitiveDelay>,
|
||||||
Option<&mut DialogueResponseBuffer>,
|
Option<&mut DialogueResponseBuffer>,
|
||||||
@@ -114,7 +113,6 @@ pub fn compute_observer_snapshot(
|
|||||||
mut interaction_buffer,
|
mut interaction_buffer,
|
||||||
mut monologue_buffer,
|
mut monologue_buffer,
|
||||||
stance_opt,
|
stance_opt,
|
||||||
archetype_opt,
|
|
||||||
mut anomaly_queue_opt,
|
mut anomaly_queue_opt,
|
||||||
cognitive_delay_opt,
|
cognitive_delay_opt,
|
||||||
mut dialogue_response_opt,
|
mut dialogue_response_opt,
|
||||||
@@ -135,8 +133,6 @@ pub fn compute_observer_snapshot(
|
|||||||
.map(|f| f.0)
|
.map(|f| f.0)
|
||||||
.unwrap_or(FacingDirection::default());
|
.unwrap_or(FacingDirection::default());
|
||||||
|
|
||||||
let archetype = archetype_opt.copied().unwrap_or_default();
|
|
||||||
|
|
||||||
// Collect player inventory (D-065 info boundary: only own items)
|
// Collect player inventory (D-065 info boundary: only own items)
|
||||||
let player_inventory = registry
|
let player_inventory = registry
|
||||||
.to_stable(observer_entity)
|
.to_stable(observer_entity)
|
||||||
@@ -197,7 +193,7 @@ pub fn compute_observer_snapshot(
|
|||||||
|
|
||||||
// Take interactions and apply Phase 2 verb filter (D-057, #422)
|
// Take interactions and apply Phase 2 verb filter (D-057, #422)
|
||||||
let mut nearby_interactions = interaction_buffer.take();
|
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!(
|
tracing::trace!(
|
||||||
"compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}",
|
"compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}",
|
||||||
@@ -453,7 +449,6 @@ pub fn compute_observer_snapshot(
|
|||||||
};
|
};
|
||||||
|
|
||||||
buffer.snapshot = Some(ObserverSnapshot {
|
buffer.snapshot = Some(ObserverSnapshot {
|
||||||
version: crate::bridge::types::PROTOCOL_VERSION,
|
|
||||||
tick: time.tick,
|
tick: time.tick,
|
||||||
game_time,
|
game_time,
|
||||||
player_facing: facing,
|
player_facing: facing,
|
||||||
@@ -654,7 +649,6 @@ fn collect_remembered_entities(
|
|||||||
/// 1. POI priority flips (D-060) — ExamineNpc above Talk for POI entities
|
/// 1. POI priority flips (D-060) — ExamineNpc above Talk for POI entities
|
||||||
/// 2. Confront injection — adds Confront verb for NPCs when KnowsDetails+
|
/// 2. Confront injection — adds Confront verb for NPCs when KnowsDetails+
|
||||||
/// 3. Contradiction marking — sets contradicted flag when entity knowledge is Contradicted
|
/// 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
|
/// Phase boundary: Phase 1 (interaction.rs) determines verb availability from
|
||||||
/// ObjectType + proximity. Phase 2 (here) reads the observer's KnowledgeGraph
|
/// ObjectType + proximity. Phase 2 (here) reads the observer's KnowledgeGraph
|
||||||
@@ -663,7 +657,6 @@ fn collect_remembered_entities(
|
|||||||
fn apply_phase2_verb_filter(
|
fn apply_phase2_verb_filter(
|
||||||
interactions: &mut [NearbyInteraction],
|
interactions: &mut [NearbyInteraction],
|
||||||
observer_kg: &KnowledgeGraph,
|
observer_kg: &KnowledgeGraph,
|
||||||
archetype: CharacterArchetype,
|
|
||||||
) {
|
) {
|
||||||
for interaction in interactions.iter_mut() {
|
for interaction in interactions.iter_mut() {
|
||||||
let stable_id = StableId(interaction.entity_id);
|
let stable_id = StableId(interaction.entity_id);
|
||||||
@@ -713,18 +706,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
|
// Re-sort after priority changes and verb additions
|
||||||
interaction
|
interaction
|
||||||
.verbs
|
.verbs
|
||||||
@@ -732,37 +713,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)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ fn player_always_visible_in_snapshot() {
|
|||||||
|
|
||||||
let buffer = world.resource::<SnapshotBuffer>();
|
let buffer = world.resource::<SnapshotBuffer>();
|
||||||
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||||
assert_eq!(snapshot.version, PROTOCOL_VERSION);
|
|
||||||
assert_eq!(snapshot.entities.len(), 1);
|
assert_eq!(snapshot.entities.len(), 1);
|
||||||
assert!(matches!(snapshot.entities[0].kind, EntityKind::Player));
|
assert!(matches!(snapshot.entities[0].kind, EntityKind::Player));
|
||||||
assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible);
|
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 buffer = world.resource::<SnapshotBuffer>();
|
||||||
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
snapshot.version, PROTOCOL_VERSION,
|
|
||||||
"should be current protocol version"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
snapshot.player_stance,
|
snapshot.player_stance,
|
||||||
MovementStance::Walk,
|
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)
|
// Phase 2 verb filter tests (#422, D-057)
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -967,148 +939,9 @@ 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]
|
#[test]
|
||||||
fn phase2_non_container_keeps_default_labels() {
|
fn phase2_non_container_keeps_default_labels() {
|
||||||
// Readable objects should keep their default labels regardless of archetype
|
// Readable objects keep their default labels (post-archetype cleanup, D-032 SUPERSEDED).
|
||||||
let mut world = setup_world(32, 32);
|
let mut world = setup_world(32, 32);
|
||||||
let mut registry = EntityRegistry::new(0);
|
let mut registry = EntityRegistry::new(0);
|
||||||
|
|
||||||
@@ -1129,7 +962,6 @@ fn phase2_non_container_keeps_default_labels() {
|
|||||||
KnowledgeGraph::new(),
|
KnowledgeGraph::new(),
|
||||||
NearbyInteractionBuffer::default(),
|
NearbyInteractionBuffer::default(),
|
||||||
MonologueBuffer::default(),
|
MonologueBuffer::default(),
|
||||||
CharacterArchetype::Smuggler,
|
|
||||||
))
|
))
|
||||||
.id();
|
.id();
|
||||||
registry.register(player);
|
registry.register(player);
|
||||||
@@ -1145,7 +977,7 @@ fn phase2_non_container_keeps_default_labels() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
read_verb.unwrap().label,
|
read_verb.unwrap().label,
|
||||||
"Read",
|
"Read",
|
||||||
"Readable labels unchanged for smuggler"
|
"Readable labels unchanged"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
use bevy_ecs::prelude::*;
|
use bevy_ecs::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::bridge::types::CharacterArchetype;
|
|
||||||
use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
|
use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
|
||||||
use crate::knowledge::EntityRegistry;
|
use crate::knowledge::EntityRegistry;
|
||||||
use crate::npc::mood::{MoodState, NpcMood};
|
use crate::npc::mood::{MoodState, NpcMood};
|
||||||
@@ -37,11 +36,12 @@ pub struct ExamineRequest {
|
|||||||
pub target: Entity,
|
pub target: Entity,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Character-filtered examination result for snapshot delivery.
|
/// Examination result for snapshot delivery.
|
||||||
///
|
///
|
||||||
/// Content differs per CharacterArchetype:
|
/// Phase 6 note: per-archetype text variants (smuggler/detective flavor) were
|
||||||
/// Smuggler — physical threat read, cargo-handling posture, opportunity windows.
|
/// removed during the cascade cleanup. Text is a single unified "subject read"
|
||||||
/// Detective — procedural tells, behavioral inconsistencies, stress indicators.
|
/// until archetype differentiation is reintroduced per the culture-driven
|
||||||
|
/// voice system (D-121) in a later cascade phase.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ExamineResultEvent {
|
pub struct ExamineResultEvent {
|
||||||
/// Character-filtered observation text for client display.
|
/// 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)
|
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).
|
/// 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(
|
pub fn generate_examine_text(
|
||||||
mood: NpcMood,
|
mood: NpcMood,
|
||||||
ratio: u8,
|
ratio: u8,
|
||||||
archetype: CharacterArchetype,
|
|
||||||
traits_opt: Option<&PersonalityTraits>,
|
traits_opt: Option<&PersonalityTraits>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let stress_label = match ratio {
|
let stress_label = match ratio {
|
||||||
@@ -123,53 +126,29 @@ pub fn generate_examine_text(
|
|||||||
|
|
||||||
let mood_label = mood_word(mood);
|
let mood_label = mood_word(mood);
|
||||||
|
|
||||||
match archetype {
|
let tell = if has_trait(traits_opt, PersonalityTrait::Deceptive) {
|
||||||
CharacterArchetype::Smuggler => {
|
"Controlled affect — practiced concealment."
|
||||||
// Physical threat read + cargo opportunity window
|
} else if matches!(mood, NpcMood::Anxious | NpcMood::Frustrated) {
|
||||||
let threat = if matches!(mood, NpcMood::Hostile | NpcMood::Suspicious) {
|
"Involuntary stress markers present."
|
||||||
"Threat posture. Don't push it."
|
} else if matches!(mood, NpcMood::Suspicious) {
|
||||||
} else if has_trait(traits_opt, PersonalityTrait::Bold) {
|
"Scanning. Aware of being observed."
|
||||||
"Confident bearing. Will push back if cornered."
|
} else if matches!(mood, NpcMood::Hostile) {
|
||||||
} else if has_trait(traits_opt, PersonalityTrait::Cautious) {
|
"Threat posture. Aware of being observed."
|
||||||
"Nervous type. Predictable under pressure."
|
} else {
|
||||||
} else {
|
"Baseline presentation."
|
||||||
"No obvious threat read."
|
};
|
||||||
};
|
|
||||||
|
|
||||||
let window = if ratio > 60 {
|
let read = if ratio > 60 {
|
||||||
"Too distracted to track cargo movement."
|
"Under pressure — potential liability or asset."
|
||||||
} else if matches!(mood, NpcMood::Focused) {
|
} else if matches!(mood, NpcMood::Content | NpcMood::Warm) {
|
||||||
"Paying close attention to this section."
|
"Comfortable. Less guarded than usual."
|
||||||
} else {
|
} else if matches!(mood, NpcMood::Focused) {
|
||||||
"Standard patrol pattern. Window is there."
|
"Paying close attention."
|
||||||
};
|
} else {
|
||||||
|
"Routine behavior pattern."
|
||||||
|
};
|
||||||
|
|
||||||
format!("Appears {mood_label}, {stress_label}. {threat} {window}")
|
format!("Subject: {mood_label}, {stress_label}. {tell} {read}")
|
||||||
}
|
|
||||||
|
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -196,7 +175,6 @@ pub fn process_examine_interaction(
|
|||||||
Entity,
|
Entity,
|
||||||
&TilePosition,
|
&TilePosition,
|
||||||
&ExamineRequest,
|
&ExamineRequest,
|
||||||
Option<&CharacterArchetype>,
|
|
||||||
&mut ExamineResultBuffer,
|
&mut ExamineResultBuffer,
|
||||||
),
|
),
|
||||||
With<PlayerCharacter>,
|
With<PlayerCharacter>,
|
||||||
@@ -212,14 +190,13 @@ pub fn process_examine_interaction(
|
|||||||
>,
|
>,
|
||||||
examine_text_query: Query<(&TilePosition, Option<&ExamineText>)>,
|
examine_text_query: Query<(&TilePosition, Option<&ExamineText>)>,
|
||||||
) {
|
) {
|
||||||
let Ok((player_entity, player_pos, examine_req, archetype_opt, mut result_buffer)) =
|
let Ok((player_entity, player_pos, examine_req, mut result_buffer)) =
|
||||||
player_query.single_mut()
|
player_query.single_mut()
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let target = examine_req.target;
|
let target = examine_req.target;
|
||||||
let archetype = archetype_opt.copied().unwrap_or_default();
|
|
||||||
|
|
||||||
// Try NPC examine path first
|
// Try NPC examine path first
|
||||||
if let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) {
|
if let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) {
|
||||||
@@ -238,7 +215,7 @@ pub fn process_examine_interaction(
|
|||||||
|
|
||||||
let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral);
|
let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral);
|
||||||
let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0);
|
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 {
|
kg_events.push(KnowledgeEvent {
|
||||||
observer: player_entity,
|
observer: player_entity,
|
||||||
@@ -323,8 +300,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn smuggler_hostile_npc_gives_threat_read() {
|
fn hostile_npc_gives_threat_read() {
|
||||||
let text = generate_examine_text(NpcMood::Hostile, 20, CharacterArchetype::Smuggler, None);
|
let text = generate_examine_text(NpcMood::Hostile, 20, None);
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("Threat posture"),
|
text.contains("Threat posture"),
|
||||||
"expected threat read, got: {text}"
|
"expected threat read, got: {text}"
|
||||||
@@ -332,32 +309,27 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn smuggler_focused_npc_notes_attention() {
|
fn focused_npc_notes_attention() {
|
||||||
let text = generate_examine_text(NpcMood::Focused, 30, CharacterArchetype::Smuggler, None);
|
let text = generate_examine_text(NpcMood::Focused, 30, None);
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("close attention"),
|
text.contains("Paying close attention"),
|
||||||
"expected attention note, got: {text}"
|
"expected attention note, got: {text}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn smuggler_high_stress_identifies_distraction() {
|
fn high_stress_identifies_pressure() {
|
||||||
let text = generate_examine_text(NpcMood::Anxious, 80, CharacterArchetype::Smuggler, None);
|
let text = generate_examine_text(NpcMood::Anxious, 80, None);
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("Too distracted"),
|
text.contains("Under pressure"),
|
||||||
"expected distraction read, got: {text}"
|
"expected pressure read, got: {text}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detective_deceptive_npc_notes_concealment() {
|
fn deceptive_npc_notes_concealment() {
|
||||||
let t = traits(&[PersonalityTrait::Deceptive]);
|
let t = traits(&[PersonalityTrait::Deceptive]);
|
||||||
let text = generate_examine_text(
|
let text = generate_examine_text(NpcMood::Neutral, 20, Some(&t));
|
||||||
NpcMood::Neutral,
|
|
||||||
20,
|
|
||||||
CharacterArchetype::Detective,
|
|
||||||
Some(&t),
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("Controlled affect"),
|
text.contains("Controlled affect"),
|
||||||
"expected concealment note, got: {text}"
|
"expected concealment note, got: {text}"
|
||||||
@@ -365,8 +337,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detective_anxious_npc_notes_stress_markers() {
|
fn anxious_npc_notes_stress_markers() {
|
||||||
let text = generate_examine_text(NpcMood::Anxious, 50, CharacterArchetype::Detective, None);
|
let text = generate_examine_text(NpcMood::Anxious, 50, None);
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("stress markers"),
|
text.contains("stress markers"),
|
||||||
"expected stress markers, got: {text}"
|
"expected stress markers, got: {text}"
|
||||||
@@ -374,8 +346,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detective_content_npc_notes_low_guard() {
|
fn content_npc_notes_low_guard() {
|
||||||
let text = generate_examine_text(NpcMood::Content, 10, CharacterArchetype::Detective, None);
|
let text = generate_examine_text(NpcMood::Content, 10, None);
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("Less guarded"),
|
text.contains("Less guarded"),
|
||||||
"expected low guard note, got: {text}"
|
"expected low guard note, got: {text}"
|
||||||
|
|||||||
@@ -72,8 +72,6 @@ pub type EconomicModifier = String;
|
|||||||
pub type FactionModifier = String;
|
pub type FactionModifier = String;
|
||||||
/// Condition modifier on a zone palette (worn, pristine, damaged). Stub.
|
/// Condition modifier on a zone palette (worn, pristine, damaged). Stub.
|
||||||
pub type ConditionModifier = String;
|
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.
|
/// Season modifier (affects palette and ambient conditions). Stub.
|
||||||
pub type Season = String;
|
pub type Season = String;
|
||||||
/// Role slot within a social site template. Stub.
|
/// Role slot within a social site template. Stub.
|
||||||
@@ -366,7 +364,6 @@ pub enum PaletteModifier {
|
|||||||
Era(Era),
|
Era(Era),
|
||||||
FactionPresence(FactionModifier),
|
FactionPresence(FactionModifier),
|
||||||
Condition(ConditionModifier),
|
Condition(ConditionModifier),
|
||||||
Heritage(HeritageRoot),
|
|
||||||
Season(Season),
|
Season(Season),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,8 +131,6 @@ pub struct MonologueState {
|
|||||||
pub entered: bool,
|
pub entered: bool,
|
||||||
/// IDs of lines already shown (dedup within session).
|
/// IDs of lines already shown (dedup within session).
|
||||||
pub shown_ids: BTreeSet<String>,
|
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).
|
/// Tick of the last observation event we reacted to (#119, observe_npc).
|
||||||
/// Observation events arrive one tick after the snapshot that caused them,
|
/// Observation events arrive one tick after the snapshot that caused them,
|
||||||
/// so we track which tick's events we've already processed.
|
/// so we track which tick's events we've already processed.
|
||||||
@@ -147,8 +145,6 @@ impl Default for MonologueState {
|
|||||||
idle_ticks: 0,
|
idle_ticks: 0,
|
||||||
entered: false,
|
entered: false,
|
||||||
shown_ids: BTreeSet::new(),
|
shown_ids: BTreeSet::new(),
|
||||||
// Default to detective; overridden by CharacterArchetype at spawn (#587)
|
|
||||||
character: "detective".to_string(),
|
|
||||||
last_observation_tick: 0,
|
last_observation_tick: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ pub const MAP_HEIGHT: i32 = 125;
|
|||||||
/// is intentional for deterministic test setups but should be revisited
|
/// is intentional for deterministic test setups but should be revisited
|
||||||
/// if Gauntlet is ever served by the production startup pipeline.
|
/// if Gauntlet is ever served by the production startup pipeline.
|
||||||
#[cfg(feature = "gauntlet")]
|
#[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.
|
// Start with a fully blocked map, then carve rooms and corridors.
|
||||||
let mut walkability = WalkabilityMap::new_blocked(MAP_WIDTH, MAP_HEIGHT, 1);
|
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) ---
|
// --- Player (StableId 0) ---
|
||||||
// Spawn at Hub center: absolute (50, 58)
|
// Spawn at Hub center: absolute (50, 58)
|
||||||
let profile = MovementProfile::smuggler();
|
let profile = MovementProfile::default();
|
||||||
let player_pos = TilePosition::new(50, 58, 0);
|
let player_pos = TilePosition::new(50, 58, 0);
|
||||||
let monologue_state = MonologueState {
|
|
||||||
character: archetype.as_monologue_key().to_string(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let player = app
|
let player = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.spawn((
|
.spawn((
|
||||||
@@ -204,7 +200,7 @@ pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterA
|
|||||||
Facing::default(),
|
Facing::default(),
|
||||||
KnowledgeGraph::new(),
|
KnowledgeGraph::new(),
|
||||||
NearbyInteractionBuffer::default(),
|
NearbyInteractionBuffer::default(),
|
||||||
monologue_state,
|
MonologueState::default(),
|
||||||
MonologueBuffer::default(),
|
MonologueBuffer::default(),
|
||||||
SprintAnomalyQueue::default(),
|
SprintAnomalyQueue::default(),
|
||||||
ScanEventBuffer::default(),
|
ScanEventBuffer::default(),
|
||||||
@@ -216,7 +212,6 @@ pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterA
|
|||||||
crate::simulation::pressure::CharacterPressure::default(),
|
crate::simulation::pressure::CharacterPressure::default(),
|
||||||
))
|
))
|
||||||
.id();
|
.id();
|
||||||
app.world_mut().entity_mut(player).insert(archetype);
|
|
||||||
registry.register(player);
|
registry.register(player);
|
||||||
|
|
||||||
// --- Hub signs (StableId 1-4) ---
|
// --- Hub signs (StableId 1-4) ---
|
||||||
@@ -699,10 +694,7 @@ mod tests {
|
|||||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||||
app.add_plugins(crate::npc::NpcPlugin);
|
app.add_plugins(crate::npc::NpcPlugin);
|
||||||
|
|
||||||
setup_gauntlet(
|
setup_gauntlet(&mut app);
|
||||||
&mut app,
|
|
||||||
crate::bridge::types::CharacterArchetype::default(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Run all 29 world-query invariants against the fully-initialized gauntlet world.
|
// Run all 29 world-query invariants against the fully-initialized gauntlet world.
|
||||||
invariants::run_invariants(app.world_mut());
|
invariants::run_invariants(app.world_mut());
|
||||||
@@ -722,10 +714,7 @@ mod tests {
|
|||||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||||
app.add_plugins(crate::npc::NpcPlugin);
|
app.add_plugins(crate::npc::NpcPlugin);
|
||||||
|
|
||||||
setup_gauntlet(
|
setup_gauntlet(&mut app);
|
||||||
&mut app,
|
|
||||||
crate::bridge::types::CharacterArchetype::default(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let wm = app.world().resource::<WalkabilityMap>();
|
let wm = app.world().resource::<WalkabilityMap>();
|
||||||
// Hub center at (50, 58) must be walkable
|
// Hub center at (50, 58) must be walkable
|
||||||
@@ -739,10 +728,7 @@ mod tests {
|
|||||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||||
app.add_plugins(crate::npc::NpcPlugin);
|
app.add_plugins(crate::npc::NpcPlugin);
|
||||||
|
|
||||||
setup_gauntlet(
|
setup_gauntlet(&mut app);
|
||||||
&mut app,
|
|
||||||
crate::bridge::types::CharacterArchetype::default(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let wm = app.world().resource::<WalkabilityMap>();
|
let wm = app.world().resource::<WalkabilityMap>();
|
||||||
// North wall segment at absolute (90, 54) should be blocked
|
// 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::knowledge::KnowledgePlugin);
|
||||||
app.add_plugins(crate::npc::NpcPlugin);
|
app.add_plugins(crate::npc::NpcPlugin);
|
||||||
|
|
||||||
setup_gauntlet(
|
setup_gauntlet(&mut app);
|
||||||
&mut app,
|
|
||||||
crate::bridge::types::CharacterArchetype::default(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let wm = app.world().resource::<WalkabilityMap>();
|
let wm = app.world().resource::<WalkabilityMap>();
|
||||||
// corridor-E center should be walkable
|
// corridor-E center should be walkable
|
||||||
@@ -775,10 +758,7 @@ mod tests {
|
|||||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||||
app.add_plugins(crate::npc::NpcPlugin);
|
app.add_plugins(crate::npc::NpcPlugin);
|
||||||
|
|
||||||
setup_gauntlet(
|
setup_gauntlet(&mut app);
|
||||||
&mut app,
|
|
||||||
crate::bridge::types::CharacterArchetype::default(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let registry = app.world().resource::<EntityRegistry>();
|
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)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -432,6 +432,7 @@ fn minimal_save() -> SaveStateV1 {
|
|||||||
contamination_active: false,
|
contamination_active: false,
|
||||||
activated_count: 0,
|
activated_count: 0,
|
||||||
last_activation_tick: None,
|
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};
|
use settled_reach_server::simulation::time::{DayPhase, TickRate};
|
||||||
|
|
||||||
let snapshot = ObserverSnapshot {
|
let snapshot = ObserverSnapshot {
|
||||||
version: PROTOCOL_VERSION,
|
|
||||||
tick: 10,
|
tick: 10,
|
||||||
game_time: GameTime {
|
game_time: GameTime {
|
||||||
day: 0,
|
day: 0,
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ fn player_moves_north_through_full_pipeline() {
|
|||||||
rmp_serde::from_slice(&response).expect("deserialize snapshot");
|
rmp_serde::from_slice(&response).expect("deserialize snapshot");
|
||||||
|
|
||||||
// Snapshot captures state at end of tick 0 (before advance_tick increments to 1)
|
// 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.tick, 0);
|
||||||
assert_eq!(snapshot.entities.len(), 1);
|
assert_eq!(snapshot.entities.len(), 1);
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,9 @@ fn write_fixture(name: &str, bytes: &[u8]) {
|
|||||||
eprintln!("Wrote {} ({} bytes)", path.display(), bytes.len());
|
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 {
|
fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||||
ObserverSnapshot {
|
ObserverSnapshot {
|
||||||
version: PROTOCOL_VERSION,
|
|
||||||
tick,
|
tick,
|
||||||
game_time: GameTime {
|
game_time: GameTime {
|
||||||
day: 0,
|
day: 0,
|
||||||
@@ -181,7 +180,6 @@ fn generate_msgpack_fixtures() {
|
|||||||
|
|
||||||
// v2 snapshot with visible_tiles and game_time populated
|
// v2 snapshot with visible_tiles and game_time populated
|
||||||
let snapshot_v2_full = ObserverSnapshot {
|
let snapshot_v2_full = ObserverSnapshot {
|
||||||
version: PROTOCOL_VERSION,
|
|
||||||
tick: 500,
|
tick: 500,
|
||||||
game_time: GameTime {
|
game_time: GameTime {
|
||||||
day: 1,
|
day: 1,
|
||||||
@@ -302,7 +300,7 @@ fn generate_msgpack_fixtures() {
|
|||||||
|
|
||||||
// === #271 fixtures: named fixtures for cross-language Layer 1 testing ===
|
// === #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(
|
let snapshot_minimal = fixture_snapshot(
|
||||||
0,
|
0,
|
||||||
vec![VisibleEntity {
|
vec![VisibleEntity {
|
||||||
@@ -322,9 +320,8 @@ fn generate_msgpack_fixtures() {
|
|||||||
&rmp_serde::to_vec_named(&snapshot_minimal).unwrap(),
|
&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 {
|
let snapshot_full = ObserverSnapshot {
|
||||||
version: PROTOCOL_VERSION,
|
|
||||||
tick: 42,
|
tick: 42,
|
||||||
game_time: GameTime {
|
game_time: GameTime {
|
||||||
day: 3,
|
day: 3,
|
||||||
|
|||||||
@@ -45,10 +45,7 @@ fn build_gauntlet(seed: u64) -> App {
|
|||||||
app.add_plugins(NpcPlugin);
|
app.add_plugins(NpcPlugin);
|
||||||
app.insert_resource(SimRng::new(seed));
|
app.insert_resource(SimRng::new(seed));
|
||||||
|
|
||||||
test_world::setup_gauntlet(
|
test_world::setup_gauntlet(&mut app);
|
||||||
&mut app,
|
|
||||||
settled_reach_server::bridge::types::CharacterArchetype::default(),
|
|
||||||
);
|
|
||||||
|
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ fn save_state_npc_kg_isolation() {
|
|||||||
contamination_active: false,
|
contamination_active: false,
|
||||||
activated_count: 0,
|
activated_count: 0,
|
||||||
last_activation_tick: None,
|
last_activation_tick: None,
|
||||||
|
selected_bookmark: settled_reach_server::bookmark::SelectedBookmark::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Roundtrip: serialize → deserialize.
|
// Roundtrip: serialize → deserialize.
|
||||||
|
|||||||
+3
-15
@@ -76,19 +76,12 @@ fn server_subprocess_sends_snapshot_on_connect() {
|
|||||||
let handshake_frame = read_framed(&mut reader)
|
let handshake_frame = read_framed(&mut reader)
|
||||||
.expect("read handshake frame")
|
.expect("read handshake frame")
|
||||||
.expect("server closed connection before sending handshake");
|
.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");
|
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)
|
// 5. Send StartupMessage with world_seed (#175)
|
||||||
let startup = StartupMessage {
|
let startup = StartupMessage { world_seed: 42 };
|
||||||
world_seed: 42,
|
|
||||||
character_archetype: settled_reach_server::bridge::types::CharacterArchetype::default(),
|
|
||||||
};
|
|
||||||
let startup_payload = rmp_serde::to_vec_named(&startup).expect("serialize StartupMessage");
|
let startup_payload = rmp_serde::to_vec_named(&startup).expect("serialize StartupMessage");
|
||||||
write_framed(&mut writer, &startup_payload).expect("send StartupMessage to server");
|
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");
|
rmp_serde::from_slice(&response).expect("deserialize ObserverSnapshot");
|
||||||
|
|
||||||
// 8. Assert protocol correctness (D-020)
|
// 8. Assert protocol correctness (D-020)
|
||||||
assert_eq!(
|
|
||||||
snapshot.version, PROTOCOL_VERSION,
|
|
||||||
"protocol version mismatch: got {}, expected {}",
|
|
||||||
snapshot.version, PROTOCOL_VERSION
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
snapshot.entities.len() > 0,
|
snapshot.entities.len() > 0,
|
||||||
"snapshot should contain at least one entity (the player), got 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 settled_reach_server::simulation::time::{DayPhase, TickRate};
|
||||||
use std::fs;
|
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 {
|
fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||||
ObserverSnapshot {
|
ObserverSnapshot {
|
||||||
version: PROTOCOL_VERSION,
|
|
||||||
tick,
|
tick,
|
||||||
game_time: GameTime {
|
game_time: GameTime {
|
||||||
day: 0,
|
day: 0,
|
||||||
@@ -65,7 +64,6 @@ fn observer_snapshot_roundtrip() {
|
|||||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||||
|
|
||||||
assert_eq!(decoded.version, PROTOCOL_VERSION);
|
|
||||||
assert_eq!(decoded.tick, 42);
|
assert_eq!(decoded.tick, 42);
|
||||||
assert_eq!(decoded.entities.len(), 1);
|
assert_eq!(decoded.entities.len(), 1);
|
||||||
assert_eq!(decoded.entities[0].entity_id, 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));
|
let bytes = fs::read(&path).unwrap_or_else(|_| panic!("read fixture {}", name));
|
||||||
|
|
||||||
if name.starts_with("snapshot_boundary") {
|
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| {
|
rmp_serde::from_slice::<ObserverSnapshot>(&bytes).unwrap_or_else(|e| {
|
||||||
panic!("deserialize boundary snapshot fixture {}: {}", name, e)
|
panic!("deserialize boundary snapshot fixture {}: {}", name, e)
|
||||||
});
|
});
|
||||||
} else if name.starts_with("snapshot") {
|
} 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));
|
.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") {
|
} else if name.starts_with("input_batch") {
|
||||||
rmp_serde::from_slice::<Vec<PlayerInput>>(&bytes)
|
rmp_serde::from_slice::<Vec<PlayerInput>>(&bytes)
|
||||||
.unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e));
|
.unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e));
|
||||||
@@ -244,7 +237,6 @@ fn all_entity_kind_variants_roundtrip() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn snapshot_v2_fields_roundtrip() {
|
fn snapshot_v2_fields_roundtrip() {
|
||||||
let snapshot = ObserverSnapshot {
|
let snapshot = ObserverSnapshot {
|
||||||
version: PROTOCOL_VERSION,
|
|
||||||
tick: 100,
|
tick: 100,
|
||||||
game_time: GameTime {
|
game_time: GameTime {
|
||||||
day: 3,
|
day: 3,
|
||||||
@@ -311,7 +303,6 @@ fn snapshot_v2_fields_roundtrip() {
|
|||||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
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.day, 3);
|
||||||
assert_eq!(decoded.game_time.time_of_day, 720);
|
assert_eq!(decoded.game_time.time_of_day, 720);
|
||||||
assert_eq!(decoded.game_time.day_phase, DayPhase::Evening);
|
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
|
/// All FacingDirection variants round-trip
|
||||||
#[test]
|
#[test]
|
||||||
fn all_facing_direction_variants_roundtrip() {
|
fn all_facing_direction_variants_roundtrip() {
|
||||||
@@ -381,7 +361,6 @@ fn all_facing_direction_variants_roundtrip() {
|
|||||||
|
|
||||||
for dir in directions {
|
for dir in directions {
|
||||||
let snapshot = ObserverSnapshot {
|
let snapshot = ObserverSnapshot {
|
||||||
version: PROTOCOL_VERSION,
|
|
||||||
tick: 0,
|
tick: 0,
|
||||||
game_time: GameTime {
|
game_time: GameTime {
|
||||||
day: 0,
|
day: 0,
|
||||||
@@ -525,7 +504,6 @@ fn v5_payload_deserializes_into_v6_struct() {
|
|||||||
.expect("v5 payload should deserialize into v6 struct via serde(default)");
|
.expect("v5 payload should deserialize into v6 struct via serde(default)");
|
||||||
|
|
||||||
// New fields should get their defaults
|
// New fields should get their defaults
|
||||||
assert_eq!(decoded.version, 5, "version field preserved from v5");
|
|
||||||
assert_eq!(decoded.tick, 42);
|
assert_eq!(decoded.tick, 42);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decoded.player_stance,
|
decoded.player_stance,
|
||||||
@@ -717,23 +695,6 @@ fn verb_kind_confront_roundtrip() {
|
|||||||
assert_eq!(decoded.nearby_interactions[0].verbs[0].label, "Confront");
|
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).
|
/// NearbyInteraction.contradicted=true round-trips through MessagePack (#422).
|
||||||
/// Guards the contradiction flag survives serialization.
|
/// Guards the contradiction flag survives serialization.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1247,7 +1208,6 @@ fn v8_payload_deserializes_into_v9_struct() {
|
|||||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
||||||
.expect("v8 payload should deserialize into v9 struct via serde(default)");
|
.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_eq!(decoded.tick, 100);
|
||||||
assert!(
|
assert!(
|
||||||
decoded.blocked_entities.is_empty(),
|
decoded.blocked_entities.is_empty(),
|
||||||
@@ -1321,7 +1281,6 @@ fn v9_payload_deserializes_into_v10_struct() {
|
|||||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
||||||
.expect("v9 payload should deserialize into v10 struct via serde(default)");
|
.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.tick, 200);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decoded.rng_seed, None,
|
decoded.rng_seed, None,
|
||||||
@@ -1397,7 +1356,6 @@ fn v10_payload_deserializes_into_v11_struct() {
|
|||||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
||||||
.expect("v10 payload should deserialize into v11 struct via serde(default)");
|
.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.tick, 300);
|
||||||
assert_eq!(decoded.visible_tiles.len(), 1);
|
assert_eq!(decoded.visible_tiles.len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1458,8 +1416,6 @@ fn serde_default_fields_fill_in_when_missing_from_wire() {
|
|||||||
let decoded: ObserverSnapshot =
|
let decoded: ObserverSnapshot =
|
||||||
serde_json::from_value(minimal_json).expect("minimal JSON must deserialize");
|
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.tick, 42);
|
||||||
assert_eq!(decoded.entities.len(), 1);
|
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).
|
/// tell_state=None is skipped in msgpack serialization (skip_serializing_if).
|
||||||
/// A snapshot with tell_state=None produces fewer bytes than one with
|
/// A snapshot with tell_state=None produces fewer bytes than one with
|
||||||
/// tell_state=Some(Nervous) — demonstrates the skip_serializing_if contract.
|
/// 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).
|
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
|
||||||
/// Verifies object_type=Some(Container) survives the wire.
|
/// Verifies object_type=Some(Container) survives the wire.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1687,7 +1608,6 @@ fn fixture_snapshot_minimal_fields() {
|
|||||||
let snap: ObserverSnapshot =
|
let snap: ObserverSnapshot =
|
||||||
rmp_serde::from_slice(&bytes).expect("deserialize snapshot_minimal");
|
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.tick, 0, "tick should be 0");
|
||||||
assert_eq!(snap.entities.len(), 1, "should have exactly 1 entity");
|
assert_eq!(snap.entities.len(), 1, "should have exactly 1 entity");
|
||||||
assert_eq!(snap.entities[0].entity_id, 1);
|
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 bytes = read_named_fixture("snapshot_full");
|
||||||
let snap: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize 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");
|
assert_eq!(snap.tick, 42, "tick should be 42");
|
||||||
|
|
||||||
// Monologue
|
// 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.
|
/// Set up a minimal gauntlet-based app with storyteller plugin running.
|
||||||
#[cfg(feature = "gauntlet")]
|
#[cfg(feature = "gauntlet")]
|
||||||
fn build_storyteller_app() -> App {
|
fn build_storyteller_app() -> App {
|
||||||
use settled_reach_server::{
|
use settled_reach_server::{simulation::SimulationPlugin, test_world};
|
||||||
bridge::types::CharacterArchetype, simulation::SimulationPlugin, test_world,
|
|
||||||
};
|
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(SimulationPlugin { seed: 0 });
|
app.add_plugins(SimulationPlugin { seed: 0 });
|
||||||
test_world::setup_gauntlet(&mut app, CharacterArchetype::default());
|
test_world::setup_gauntlet(&mut app);
|
||||||
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();
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user