refactor(simulation): strip archetype trace + HeritageRoot per cascade (#877, #878)

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:
2026-04-22 08:55:48 +02:00
co-authored by Claude Opus 4.6
parent c640563fc0
commit cae3d3ab85
22 changed files with 93 additions and 2444 deletions
+2 -2
View File
@@ -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)
+3 -2
View File
@@ -225,10 +225,11 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
- `GameState.insert_active` is the source of truth (defaults true in v0.1; wired from snapshot field `insert_active`). - `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 (110). Higher = more likely to be
included in a given playthrough's active module set. Default: 5.
compatible_districts:
type: array
items:
type: string
description: >
District slugs where this module can activate, or omit for "any".
Example: ["sova-transit"]
incompatible_with:
type: array
items:
type: string
pattern: "^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$"
description: >
Module IDs that cannot run concurrently with this one.
The storyteller will not activate both in the same playthrough.
max_concurrent:
type: integer
minimum: 1
default: 1
description: >
Maximum simultaneous active instances. Almost always 1.
Set to 2+ only for modules designed to stack (rare).
# ── ENTRY CONDITIONS ──────────────────────────────────────────────────────
# All listed conditions must be true for the module to become activatable.
# The storyteller checks these each tick after min_play_ticks.
entry_conditions:
type: object
required:
- activation
additionalProperties: false
description: >
World-state prerequisites. The storyteller activates the module when
ALL conditions are satisfied AND the activation trigger fires.
properties:
world_state:
type: array
items:
$ref: "#/$defs/world_state_condition"
description: "World-state conditions checked each tick."
player:
type: array
items:
$ref: "#/$defs/player_condition"
description: >
Optional player-state conditions. Module can activate without
player engagement — these gate on player-specific world state,
not on player noticing the module.
activation:
type: object
required:
- trigger
additionalProperties: false
description: "How and when activation is evaluated."
properties:
trigger:
type: string
enum:
- proximity # Player comes within range of a key NPC/location
- storyteller_push # Storyteller activates on its own schedule
- player_action # Player performs a specific action
description: "What pushes the module from 'eligible' to 'active'."
min_play_ticks:
type: integer
minimum: 0
description: >
Minimum ticks of game time before this module can activate.
Enforces D-027 success criterion #1: 30 minutes of daily-life
breathing room. At 1 tick/second, 30 minutes ≈ 1800 ticks.
proximity_location:
type: string
description: >
Required when trigger = proximity. Location slug the player
must enter or approach. Example: "maintenance-corridors"
proximity_radius_tiles:
type: integer
minimum: 1
description: >
Required when trigger = proximity. Tile radius around the
location's anchor point.
player_action_required:
type: string
description: >
Required when trigger = player_action. The action that fires
activation. Example: "examine:cargo-manifest"
# ── NPC REQUIREMENTS ──────────────────────────────────────────────────────
# NPC slots this module requires. Each slot is filled at module load time.
# Named bindings resolve to specific authored NPCs; generated bindings
# are filled from the district's generated NPC pool.
npc_requirements:
type: array
minItems: 1
items:
$ref: "#/$defs/npc_slot"
description: >
Module-internal NPC role slots. Roles are referenced by slug throughout
the rest of this document. Hand-authored NPCs use named bindings.
Generated NPCs use constraint-based bindings.
# ── EVENTS ────────────────────────────────────────────────────────────────
# Ordered sequences and unordered event pools the storyteller can fire.
# Sequences are narrative beats in a defined order.
# Pools are events the storyteller can fire in any order when conditions are met.
events:
type: object
additionalProperties: false
description: "Event sequences and pools the storyteller manages."
properties:
sequences:
type: array
items:
$ref: "#/$defs/event_sequence"
description: >
Ordered event sequences. Steps fire in order; the next step
becomes eligible only after the previous one fires.
pools:
type: array
items:
$ref: "#/$defs/event_pool"
description: >
Unordered event pools. The storyteller may fire any eligible
event in the pool when its trigger conditions are met.
# ── OUTCOMES ──────────────────────────────────────────────────────────────
# Resolution states the module can reach. The storyteller checks outcome
# conditions each tick. First matching outcome wins.
# Every module MUST include an expiry outcome.
outcomes:
type: array
minItems: 1
items:
$ref: "#/$defs/outcome"
description: >
Terminal and transitional resolution states. The storyteller checks
these each tick and applies the first matching outcome.
# ── AUTHORING NOTES ───────────────────────────────────────────────────────
notes:
type: string
description: "Authoring-only field. Design rationale, cross-references. Ignored at load time."
dual_lens:
type: object
additionalProperties: false
description: "Authoring-only. How smuggler vs detective experience this module."
properties:
smuggler: { type: string }
detective: { type: string }
# ── SHARED DEFINITIONS ────────────────────────────────────────────────────────
$defs:
# World-state condition types
world_state_condition:
type: object
required:
- type
description: "A single world-state prerequisite for module activation."
oneOf:
- # NPC with the given module role is present in the district
properties:
type: { type: string, const: "npc_present" }
role: { type: string, description: "Module-internal NPC role slug." }
required: [type, role]
additionalProperties: false
- # A specific location is accessible to the player
properties:
type: { type: string, const: "location_accessible" }
location: { type: string, description: "Location slug." }
required: [type, location]
additionalProperties: false
- # Player has NOT yet discovered a specific fact
properties:
type: { type: string, const: "fact_not_known" }
fact_id: { type: string, description: "Fact ID from global/knowledge/." }
required: [type, fact_id]
additionalProperties: false
- # No other Tier 1 module of the given ID is currently active
properties:
type: { type: string, const: "no_active_module" }
module_id: { type: string }
required: [type, module_id]
additionalProperties: false
- # A named fact IS known (module requires precondition awareness)
properties:
type: { type: string, const: "fact_known" }
fact_id: { type: string }
known_by: { type: string, enum: [smuggler, detective, any] }
required: [type, fact_id]
additionalProperties: false
# Player-state condition types
player_condition:
type: object
required:
- type
description: "A player-state prerequisite."
oneOf:
- # Player has reached minimum relationship threshold with an NPC
properties:
type: { type: string, const: "relationship_threshold" }
npc_role: { type: string, description: "Module-internal NPC role." }
min_state:
type: string
enum: [stranger, known, friendly]
description: "Minimum RelationshipState required."
required: [type, npc_role, min_state]
additionalProperties: false
- # Minimum game ticks elapsed
properties:
type: { type: string, const: "min_ticks" }
ticks: { type: integer, minimum: 0 }
required: [type, ticks]
additionalProperties: false
# NPC slot definition
npc_slot:
type: object
required:
- role
- binding
additionalProperties: false
description: >
One NPC slot in the module. Named binding = specific authored NPC.
Generated binding = constraint-matched NPC from district pool.
properties:
role:
type: string
pattern: "^[a-z][a-z0-9-]*$"
description: >
Module-internal role slug. Referenced in events, outcomes, and
triggers. Example: "ring-leader", "ring-member-exiting", "witness"
display_hint:
type: string
description: "Authoring note. What this role is narratively."
binding:
type: string
enum: [named, generated]
description: >
named = resolves to a specific authored NPC (use named_npc).
generated = any district NPC matching the axis constraints.
named_npc:
type: string
pattern: "^npc:[a-z][a-z0-9-]*$"
description: >
Required when binding = named. Short-form NPC canonical ID.
Example: "npc:kael-davan"
axes:
type: array
items:
$ref: "#/$defs/axis_constraint"
description: >
Required when binding = generated. The NPC must satisfy all
listed axis constraints to fill this slot.
must_have_pattern:
type: string
enum: [FRIEND, MIRROR, ANCHOR, GHOST, CATALYST, THRESHOLD, REMNANT, SYSTEM, NOBODY]
description: "Optional: NPC must have this pattern (D-024)."
must_have_motivation:
type: string
enum: [HANDLER, WITNESS, TURNCOAT, CIVILIAN, OPERATOR, SKEPTIC]
description: "Optional: NPC must have this motivation (D-024)."
is_optional:
type: boolean
default: false
description: >
If true, the module can activate without this slot filled.
Optional slots produce degraded but valid module runs.
# NPC axis constraint (used in generated bindings)
axis_constraint:
type: object
required:
- axis
- constraint
additionalProperties: false
properties:
axis:
type: string
enum: [want, secret, relationships, tolerance, routine, information, contentment, personality, tells, skills]
description: "Which NPC axis to constrain (D-024)."
constraint:
type: string
description: >
Constraint expression. Freeform string interpreted by the storyteller.
Convention: "has_{value}", "min_{N}", "not_{value}".
Examples: "has_major_secret", "min_contentment_-3", "not_combat_trained"
# Event sequence
event_sequence:
type: object
required:
- sequence_id
- steps
additionalProperties: false
description: "An ordered sequence of narrative events."
properties:
sequence_id:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
label:
type: string
description:
type: string
steps:
type: array
minItems: 1
items:
$ref: "#/$defs/event_step"
# Unordered event pool
event_pool:
type: object
required:
- pool_id
- events
additionalProperties: false
properties:
pool_id:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
label:
type: string
description:
type: string
events:
type: array
minItems: 1
items:
$ref: "#/$defs/event_step"
# Individual event step
event_step:
type: object
required:
- event_id
- triggers
additionalProperties: false
description: "A single storyteller-managed event with triggers and effects."
properties:
event_id:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
description: "Unique within this module. Used in outcome conditions."
label:
type: string
description:
type: string
description: "What happens narratively when this event fires."
triggers:
type: array
minItems: 1
items:
$ref: "#/$defs/event_trigger"
description: "ANY trigger being true fires this event."
effects:
type: array
items:
$ref: "#/$defs/event_effect"
description: "What changes in the world when this event fires."
once:
type: boolean
default: true
description: "If true, fires only once. If false, may repeat when conditions reset."
sets_flag:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
description: "Module-internal flag set when this event fires. Queryable in outcomes."
# Event trigger conditions
event_trigger:
type: object
required:
- type
description: "A condition that causes an event to fire."
oneOf:
- # Ticks elapsed since module activation
properties:
type: { type: string, const: "ticks_since_activation" }
ticks: { type: integer, minimum: 1 }
required: [type, ticks]
additionalProperties: false
- # Ticks elapsed since a previous event fired
properties:
type: { type: string, const: "ticks_since_event" }
after_event: { type: string }
ticks: { type: integer, minimum: 1 }
required: [type, after_event, ticks]
additionalProperties: false
- # Player enters a location or comes within range of NPC
properties:
type: { type: string, const: "player_proximity" }
target_type: { type: string, enum: [location, npc_role] }
target: { type: string }
radius_tiles: { type: integer, minimum: 1 }
required: [type, target_type, target]
additionalProperties: false
- # Player performs an interaction
properties:
type: { type: string, const: "player_action" }
action:
type: string
enum: [talk, examine, confront, follow, observe]
target_role: { type: string, description: "Module NPC role or location slug." }
required: [type, action, target_role]
additionalProperties: false
- # Player has discovered a specific fact
properties:
type: { type: string, const: "fact_known_by_player" }
fact_id: { type: string }
required: [type, fact_id]
additionalProperties: false
- # A module flag has been set
properties:
type: { type: string, const: "flag_set" }
flag: { type: string }
required: [type, flag]
additionalProperties: false
- # NPC enters a specific mood state
properties:
type: { type: string, const: "npc_mood" }
npc_role: { type: string }
mood:
type: string
enum: [anxious, frustrated, content, suspicious, warm, hostile, relieved, focused]
required: [type, npc_role, mood]
additionalProperties: false
# Event effects
event_effect:
type: object
required:
- type
description: "A world change triggered by an event."
oneOf:
- # NPC deviates from their normal routine
properties:
type: { type: string, const: "npc_routine_deviation" }
npc_role: { type: string }
description: { type: string, description: "What the deviation looks like." }
duration_ticks: { type: integer }
required: [type, npc_role, description]
additionalProperties: false
- # A fact becomes discoverable (moves to Rumoured confidence)
properties:
type: { type: string, const: "fact_becomes_discoverable" }
fact_id: { type: string }
discoverable_by:
type: string
enum: [smuggler, detective, any]
discovery_method:
type: string
description: "How the player can discover this. Authoring note."
required: [type, fact_id, discoverable_by]
additionalProperties: false
- # NPC tell behavior becomes more pronounced
properties:
type: { type: string, const: "tell_intensify" }
npc_role: { type: string }
description: { type: string }
required: [type, npc_role]
additionalProperties: false
- # A module-internal flag is set
properties:
type: { type: string, const: "flag_set" }
flag: { type: string, pattern: "^[a-z][a-z0-9_-]*$" }
required: [type, flag]
additionalProperties: false
- # Something changes about a location
properties:
type: { type: string, const: "location_state" }
location: { type: string }
description: { type: string }
required: [type, location, description]
additionalProperties: false
- # NPC's access to information changes
properties:
type: { type: string, const: "npc_knowledge_update" }
npc_role: { type: string }
fact_id: { type: string }
description: { type: string }
required: [type, npc_role, fact_id]
additionalProperties: false
# Module outcome definition
outcome:
type: object
required:
- outcome_id
- label
- is_terminal
additionalProperties: false
description: >
A resolution state the module can reach. Conditions are checked each tick.
The first matching outcome is applied. is_terminal = true ends the module.
properties:
outcome_id:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
label:
type: string
description:
type: string
description: "What this outcome means narratively."
is_terminal:
type: boolean
description: "If true, this outcome ends the module permanently."
is_expiry:
type: boolean
default: false
description: >
If true, this is the quiet-exit outcome when the player never engages.
Every module must include exactly one expiry outcome.
conditions:
type: object
additionalProperties: false
description: "ALL conditions must be true to reach this outcome."
properties:
facts_known:
type: array
items: { type: string }
description: "Player must know all these facts."
facts_not_known:
type: array
items: { type: string }
description: "Player must NOT know any of these facts."
flags_set:
type: array
items: { type: string }
description: "All these module flags must be set."
flags_not_set:
type: array
items: { type: string }
description: "None of these module flags may be set."
events_fired:
type: array
items: { type: string }
description: "All these events must have fired."
ticks_since_activation:
type: integer
description: "Module has been active for at least this many ticks."
effects:
type: array
items:
$ref: "#/$defs/outcome_effect"
description: "Effects applied when this outcome is reached."
# Outcome-level effects (broader scope than event effects)
outcome_effect:
type: object
required:
- type
oneOf:
- # NPC disposition toward player changes
properties:
type: { type: string, const: "npc_disposition" }
npc_role: { type: string }
shift:
type: string
enum: [hostile, suspicious, neutral, friendly]
description: { type: string }
required: [type, npc_role, shift]
additionalProperties: false
- # Faction reaction
properties:
type: { type: string, const: "faction_reaction" }
faction: { type: string }
reaction:
type: string
enum: [hostile, suspicious, neutral, friendly, grateful]
description: { type: string }
required: [type, faction, reaction]
additionalProperties: false
- # Location becomes restricted or opens up
properties:
type: { type: string, const: "location_access_change" }
location: { type: string }
change:
type: string
enum: [restricted, locked, open]
description: { type: string }
required: [type, location, change]
additionalProperties: false
- # A fact is now permanently known/unknown
properties:
type: { type: string, const: "fact_state" }
fact_id: { type: string }
state:
type: string
enum: [known, hidden, destroyed]
description: { type: string }
required: [type, fact_id, state]
additionalProperties: false
- # NPC leaves the district or changes role
properties:
type: { type: string, const: "npc_exit" }
npc_role: { type: string }
description: { type: string }
required: [type, npc_role]
additionalProperties: false
+15 -100
View File
@@ -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!(
+1 -52
View File
@@ -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;
+2 -170
View File
@@ -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"
); );
} }
+48 -76
View File
@@ -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}"
-3
View File
@@ -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),
} }
-4
View File
@@ -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,
} }
} }
+8 -28
View File
@@ -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>();
-154
View File
@@ -1,154 +0,0 @@
//! Regression tests: character archetype flows end-to-end to MonologueState (#595, D-032).
//!
//! Verifies that when a session starts with a given CharacterArchetype, the
//! player entity's MonologueState.character reflects it correctly. This is the
//! guard against the default "detective" string leaking into smuggler sessions.
//!
//! Two complementary approaches:
//! 1. Unit-level: CharacterArchetype::as_monologue_key() mapping is correct.
//! 2. Integration (gauntlet): setup_gauntlet() correctly wires archetype → MonologueState.
//!
//! Spec refs:
//! D-032: character tag is a hard pool partition, not a filter — wrong character string
//! silently serves wrong content.
//! D-010: no player identity baked into game loop — archetype is a configuration.
//! #587: character_archetype added to StartupMessage; monologue key derived from it.
//! #595: MonologueState.character initialized from CharacterArchetype at session start.
use settled_reach_server::bridge::types::CharacterArchetype;
// ---------------------------------------------------------------------------
// Layer 1 — pure unit tests, no ECS
// ---------------------------------------------------------------------------
#[test]
fn smuggler_archetype_maps_to_monologue_key() {
assert_eq!(
CharacterArchetype::Smuggler.as_monologue_key(),
"smuggler",
"Smuggler must produce the exact pool key 'smuggler' used in monologue YAML"
);
}
#[test]
fn detective_archetype_maps_to_monologue_key() {
assert_eq!(
CharacterArchetype::Detective.as_monologue_key(),
"detective",
"Detective must produce the exact pool key 'detective' used in monologue YAML"
);
}
#[test]
fn default_archetype_is_detective() {
// D-010: the safe fallback is Detective (the original single-character game).
// If serde default fires (old client, missing field), Detective must be chosen.
assert_eq!(
CharacterArchetype::default(),
CharacterArchetype::Detective,
"Default archetype must be Detective for backward compatibility (#587)"
);
}
#[test]
fn archetype_keys_are_distinct() {
// Sanity guard: the two keys must differ. If they were the same, pool partitioning
// (D-032) would be broken and both characters would see identical monologue lines.
assert_ne!(
CharacterArchetype::Smuggler.as_monologue_key(),
CharacterArchetype::Detective.as_monologue_key(),
"Smuggler and Detective monologue keys must be distinct (D-032 hard partition)"
);
}
// ---------------------------------------------------------------------------
// Layer 2 — integration: setup_gauntlet wires archetype → MonologueState
// ---------------------------------------------------------------------------
#[cfg(feature = "gauntlet")]
mod gauntlet_integration {
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use settled_reach_server::{
bridge::types::CharacterArchetype,
simulation::{monologue::MonologueState, movement::PlayerCharacter, SimulationPlugin},
test_world,
};
/// Build a minimal Gauntlet app with the given archetype and run one tick.
fn boot_gauntlet(archetype: CharacterArchetype) -> App {
let mut app = App::new();
app.add_plugins(SimulationPlugin { seed: 0 });
test_world::setup_gauntlet(&mut app, archetype);
app.update();
app
}
#[test]
fn smuggler_archetype_sets_monologue_character_to_smuggler() {
let mut app = boot_gauntlet(CharacterArchetype::Smuggler);
let mut query = app
.world_mut()
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
let state = query
.single(app.world())
.expect("player entity with MonologueState must exist after gauntlet setup");
assert_eq!(
state.character, "smuggler",
"Smuggler archetype must produce MonologueState.character = 'smuggler' (D-032, #587)"
);
}
#[test]
fn detective_archetype_sets_monologue_character_to_detective() {
let mut app = boot_gauntlet(CharacterArchetype::Detective);
let mut query = app
.world_mut()
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
let state = query
.single(app.world())
.expect("player entity with MonologueState must exist after gauntlet setup");
assert_eq!(
state.character, "detective",
"Detective archetype must produce MonologueState.character = 'detective' (D-032, #587)"
);
}
#[test]
fn smuggler_and_detective_produce_different_monologue_characters() {
// Regression guard: if both sessions return the same character string, D-032
// partitioning is broken. This test catches copy-paste mistakes in setup paths.
let mut smuggler_app = boot_gauntlet(CharacterArchetype::Smuggler);
let mut detective_app = boot_gauntlet(CharacterArchetype::Detective);
let smuggler_char = {
let mut q = smuggler_app
.world_mut()
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
q.single(smuggler_app.world())
.expect("smuggler player must exist")
.character
.clone()
};
let detective_char = {
let mut q = detective_app
.world_mut()
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
q.single(detective_app.world())
.expect("detective player must exist")
.character
.clone()
};
assert_ne!(
smuggler_char, detective_char,
"Smuggler and Detective sessions must have different MonologueState.character values \
(D-032 hard partition: same key means both characters see each other's monologue pool)"
);
}
}
@@ -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(),
} }
} }
-1
View File
@@ -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,
-1
View File
@@ -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);
+3 -6
View File
@@ -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,
+1 -4
View File
@@ -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
} }
+1
View File
@@ -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
View File
@@ -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"
+3 -84
View File
@@ -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
+2 -4
View File
@@ -108,12 +108,10 @@ fn no_deviation_component_does_not_produce_deviation_tell() {
/// Set up a minimal gauntlet-based app with storyteller plugin running. /// 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
} }
-328
View File
@@ -1,328 +0,0 @@
//! v0.1 integration playthrough test (#593, D-027).
//!
//! Validates the full session lifecycle from StartupMessage to storyteller activation:
//! D-027 criterion 1: player sees opening monologue on session start
//! D-027 criterion 4: NPC RoutineDeviation tell observable after triangle activation
//! D-036: news ticker headline visible in The Last Shift zone
//!
//! Test structure:
//! - `test_smuggler_opening_monologue`: asserts smuggler pool fires on tick 1 (runs now)
//! - `test_detective_opening_monologue`: asserts detective pool fires on tick 1 (runs now)
//! - `test_v0_1_integration_playthrough`: full E2E proof (#[ignore] until #589, #591 land)
//!
//! Uses Layer 3 pattern: real server subprocess, TCP IPC, no mocks.
//!
//! Prerequisites to unblock:
//! #589: escalate_tells_on_activation system (for RoutineDeviation assertion)
//! #591: TickerPool + current_ticker in snapshot (for ticker assertion)
use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::types::*;
use settled_reach_server::npc::tell_state::TellCategory;
use std::io::{BufRead, BufReader, BufWriter};
use std::net::TcpStream;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// Timeout for the server to emit LISTENING:{port} on stdout.
const LISTEN_TIMEOUT: Duration = Duration::from_secs(15);
/// Timeout for any individual snapshot read.
const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(15);
// ---------------------------------------------------------------------------
// Server lifecycle helpers
// ---------------------------------------------------------------------------
struct TestServer {
child: std::process::Child,
reader: BufReader<TcpStream>,
writer: BufWriter<TcpStream>,
}
impl TestServer {
/// Boot the server binary in test mode (gauntlet), send StartupMessage,
/// return a connected handle ready to receive snapshots.
fn boot_gauntlet(world_seed: u64, archetype: CharacterArchetype) -> Self {
let server_bin = env!("CARGO_BIN_EXE_settled-reach-server");
let mut child = Command::new(server_bin)
.args(["--test-mode", "--port", "0"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("failed to spawn server binary");
let stdout = child.stdout.take().expect("stdout not captured");
let mut stdout_reader = BufReader::new(stdout);
// Parse LISTENING:{port}
let port = {
let deadline = Instant::now() + LISTEN_TIMEOUT;
let mut line = String::new();
loop {
line.clear();
match stdout_reader.read_line(&mut line) {
Ok(0) => panic!("server stdout closed before LISTENING signal"),
Ok(_) => {
let trimmed = line.trim();
if let Some(port_str) = trimmed.strip_prefix("LISTENING:") {
break port_str.parse::<u16>().expect("invalid port");
}
}
Err(e) => panic!("failed to read server stdout: {}", e),
}
assert!(
Instant::now() < deadline,
"timed out waiting for LISTENING signal"
);
}
};
let addr = format!("127.0.0.1:{}", port);
let stream = TcpStream::connect(&addr).expect("client connect");
stream
.set_read_timeout(Some(SNAPSHOT_TIMEOUT))
.expect("set timeout");
let mut reader = BufReader::new(stream.try_clone().expect("clone stream"));
let mut writer = BufWriter::new(stream);
// Protocol handshake
let hf = read_framed(&mut reader)
.expect("read handshake")
.expect("connection closed");
let _: HandshakeMessage = rmp_serde::from_slice(&hf).expect("deserialize handshake");
// StartupMessage with chosen archetype
let startup = StartupMessage {
world_seed,
character_archetype: archetype,
};
let startup_payload = rmp_serde::to_vec_named(&startup).expect("serialize startup");
write_framed(&mut writer, &startup_payload).expect("send startup");
TestServer {
child,
reader,
writer,
}
}
/// Send a tick's worth of inputs (empty = idle tick) and read back one snapshot.
fn tick(&mut self, inputs: Vec<PlayerInput>) -> ObserverSnapshot {
let payload = rmp_serde::to_vec_named(&inputs).expect("serialize inputs");
write_framed(&mut self.writer, &payload).expect("send inputs");
let frame = read_framed(&mut self.reader)
.expect("read snapshot frame")
.expect("server closed connection");
rmp_serde::from_slice(&frame).expect("deserialize snapshot")
}
/// Send a debug command and get the next snapshot.
fn send_debug(&mut self, cmd: DebugCommandKind) -> ObserverSnapshot {
self.tick(vec![PlayerInput {
tick: 0,
action: PlayerAction::DebugCommand(cmd),
}])
}
fn shutdown(mut self) {
drop(self.reader);
drop(self.writer);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
match self.child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if Instant::now() > deadline {
self.child.kill().ok();
self.child.wait().ok();
break;
}
std::thread::sleep(Duration::from_millis(50));
}
Err(_) => {
self.child.kill().ok();
break;
}
}
}
}
}
// ---------------------------------------------------------------------------
// Tests: opening monologue archetype partitioning (runs now — no #[ignore])
// ---------------------------------------------------------------------------
#[test]
fn test_smuggler_opening_monologue() {
// Boot with Smuggler, advance 1 tick, assert opening monologue fires from smuggler pool.
// Monologue IDs from smuggler/opening.yaml start with "pc-smuggler_".
// This verifies: archetype → MonologueState.character → pool selection (D-032, #587, #595).
let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler);
let snapshot = server.tick(vec![PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
}]);
assert_eq!(
snapshot.version, PROTOCOL_VERSION,
"protocol version mismatch"
);
let monologue = snapshot.current_monologue;
assert!(
monologue.is_some(),
"Smuggler session must fire opening monologue on tick 1 (enter_location trigger, D-027 criterion 1). \
Got None either MonologueState.character is wrong or opening.yaml lines are not loaded."
);
let monologue = monologue.unwrap();
assert!(
monologue.id.starts_with("pc-smuggler_"),
"Smuggler opening monologue ID must start with 'pc-smuggler_' (D-032 hard partition). \
Got id='{}'. Likely cause: MonologueState.character defaulted to 'detective' despite Smuggler archetype.",
monologue.id
);
server.shutdown();
}
#[test]
fn test_detective_opening_monologue() {
// Boot with Detective, advance 1 tick, assert opening monologue fires from detective pool.
// Monologue IDs from detective/opening.yaml start with "pc-detective_".
let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Detective);
let snapshot = server.tick(vec![PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
}]);
assert_eq!(
snapshot.version, PROTOCOL_VERSION,
"protocol version mismatch"
);
let monologue = snapshot.current_monologue;
assert!(
monologue.is_some(),
"Detective session must fire opening monologue on tick 1 (enter_location trigger). \
Got None either MonologueState.character is wrong or opening.yaml lines are not loaded."
);
let monologue = monologue.unwrap();
assert!(
monologue.id.starts_with("pc-detective_"),
"Detective opening monologue ID must start with 'pc-detective_' (D-032 hard partition). \
Got id='{}'. Likely cause: archetype defaulted incorrectly.",
monologue.id
);
server.shutdown();
}
#[test]
fn test_smuggler_and_detective_get_different_opening_monologue_ids() {
// Regression guard: two sessions with different archetypes must never produce
// the same monologue ID on tick 1. If they do, D-032 partitioning is broken.
let mut smug = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler);
let smug_snap = smug.tick(vec![]);
let smug_id = smug_snap
.current_monologue
.as_ref()
.map(|m| m.id.clone())
.unwrap_or_default();
smug.shutdown();
let mut det = TestServer::boot_gauntlet(12345, CharacterArchetype::Detective);
let det_snap = det.tick(vec![]);
let det_id = det_snap
.current_monologue
.as_ref()
.map(|m| m.id.clone())
.unwrap_or_default();
det.shutdown();
assert_ne!(
smug_id, det_id,
"Smuggler and Detective must fire different opening monologue IDs (D-032). \
Both got '{}' pool partitioning is broken.",
smug_id
);
}
// ---------------------------------------------------------------------------
// Full v0.1 playthrough proof (blocked until #589 + #591 land)
// ---------------------------------------------------------------------------
#[test]
#[ignore = "blocked: TeleportToLocation debug command not implemented (needs location tile_bounds from ContentStore). Criteria 1+2 covered by non-ignored tests above."]
fn test_v0_1_integration_playthrough() {
// Full E2E proof per D-027 v0.1 success criteria:
// 1. Opening monologue fires in correct character pool
// 2. After activation, anchor NPC shows RoutineDeviation tell
// 3. News ticker visible when player is in "bar" zone
// (Manual criterion: walk to terminal, observe Kael, see fog-and-tension)
let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler);
// === Criterion 1: Opening monologue (Smuggler) ===
let tick1 = server.tick(vec![]);
let monologue = tick1
.current_monologue
.expect("Opening monologue must fire on tick 1");
assert!(
monologue.id.starts_with("pc-smuggler_"),
"Tick-1 monologue must be from smuggler pool. Got: {}",
monologue.id
);
// === Skip to contamination phase (fast-forward via debug) ===
let _skip_snap = server.send_debug(DebugCommandKind::SkipToContamination);
let _contaminate = server.send_debug(DebugCommandKind::ForceContaminationActivate);
// === Run ticks and watch for triangle activation ===
let mut triangle_crisis_observed = false;
for _ in 0..20 {
let snap = server.tick(vec![]);
if !snap.triangle_crisis_events.is_empty() {
triangle_crisis_observed = true;
break;
}
}
assert!(
triangle_crisis_observed,
"Triangle crisis event must appear within 20 ticks after contamination activation (#589)"
);
// === Criterion 2 (D-027 criterion 4): RoutineDeviation tell visible ===
// After activation, at least one NPC must show RoutineDeviation tell in the snapshot.
let mut deviation_observed = false;
for _ in 0..5 {
let snap = server.tick(vec![]);
if snap
.entities
.iter()
.any(|e| e.tell_state == Some(TellCategory::RoutineDeviation))
{
deviation_observed = true;
break;
}
}
assert!(
deviation_observed,
"After triangle activation, at least one NPC must show RoutineDeviation tell (D-027 criterion 4, #589)"
);
// === Criterion 3 (D-036): News ticker visible in bar zone ===
// Teleport to The Last Shift bar zone and check current_ticker is Some.
let _teleport = server.send_debug(DebugCommandKind::TeleportToLocation(
"the-last-shift".into(),
));
let bar_snap = server.tick(vec![]);
assert!(
bar_snap.current_ticker.is_some(),
"current_ticker must be Some when player is in 'the-last-shift' zone (D-036, #591)"
);
server.shutdown();
}