diff --git a/decisions/content.md b/decisions/content.md index a60c55b87..9cb8b89dd 100644 --- a/decisions/content.md +++ b/decisions/content.md @@ -46,7 +46,7 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio ### D-032: Separate monologue pools per character [SUPERSEDED] - **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`). - **Rationale:** Shared monologue would dilute character voice and undermine the dual-lens experience. Each character's internal voice must be independently coherent. Same trigger, different pool — this is how mirror moments work without either pool knowing about the other. - **Cross-reference:** Dialogue lines remain character-agnostic — the access tier system (D-028 Layer 1) handles per-character filtering without separate pools. @@ -80,7 +80,7 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio - `mood` (list\): 8 moods for v0.1 — D-028 Layer 4 weighted selection - `tags` (list\): freeform escape hatch for author intent - **Monologue-specific additions:** - - `character` (enum): `smuggler`, `detective` — hard partition per D-032 **[Obsolete post-D-117: smuggler/detective eliminated. v0.2 uses culture-driven voice per D-121; this enum is unused.]** + - `character` (enum): historically `smuggler`, `detective` — removed. Per [D-032 SUPERSEDED] and the development cascade (CLAUDE.md), character-partitioned monologue is Phase 6 and has been stripped from the codebase (Sprint 37, #878). Field is unused; do not reintroduce without a confirmed Phase 6 design. - `trigger` (enum): 9 trigger types (enter_location, observe_npc, hear_sound, observe_anomaly, post_conversation, discover_evidence, witness_interaction, time_idle, return_visit) - `prerequisite` (map or null): knowledge state gate - **Authoring-only tags (not consumed by engine):** `dual_lens` (map, per-character notes), `notes` (string) diff --git a/decisions/perception.md b/decisions/perception.md index 23cbcfcc6..e67b0d9e6 100644 --- a/decisions/perception.md +++ b/decisions/perception.md @@ -225,10 +225,11 @@ How the player observes and interacts with the world: camera, fog, line-of-sight - `GameState.insert_active` is the source of truth (defaults true in v0.1; wired from snapshot field `insert_active`). - Rationale: diegetically consistent — the body reacts to proximity; the insert reacts to commands. -### D-057: Entity interaction — vertical list, insert-styled +### D-057: Entity interaction — vertical list, insert-styled [PARTIALLY SUPERSEDED] - **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. -- **Server architecture:** Two-phase verb computation. Phase 1 (simulation, no KG): compute maximum possible verb set from ObjectType component (Readable, Container, Terminal, Door, Pickup, Furniture — each with specific verb sets). Phase 2 (observer, reads KG): filter by character's knowledge (Confront requires KnowsDetails+ per [D-041](architecture.md#d-041-knowledge-graph-data-model)), apply POI priority flips, add contradiction markers. Character-archetype verb variation implemented as Phase 2 observer filter rules (same crate: smuggler sees "Move/Stash", detective sees "Scan/Flag"). +- **Server architecture:** Two-phase verb computation. Phase 1 (simulation, no KG): compute maximum possible verb set from ObjectType component (Readable, Container, Terminal, Door, Pickup, Furniture — each with specific verb sets). Phase 2 (observer, reads KG): filter by character's knowledge (Confront requires KnowsDetails+ per [D-041](architecture.md#d-041-knowledge-graph-data-model)), apply POI priority flips, add contradiction markers. ~~Character-archetype verb variation implemented as Phase 2 observer filter rules (same crate: smuggler sees "Move/Stash", detective sees "Scan/Flag").~~ *[Removed Sprint 37 — archetype verb relabeling deleted; see supersession note above.]* - **Diegetic test:** Labels render on z-layer 6. If insert is off, labels disappear. - **Rationale:** Variable-length text options (e.g., confrontation lines in character voice) break radial spatial memory. List handles 1-4 options cleanly. New-item glow signals "something changed" without UX hazard of geometry transforming under cursor. Two-phase computation enables character differentiation without separate verb systems. - **References:** Disco Elysium (world-embedded indicators), Darkwood (minimal cursor), Rimworld (right-click context list). diff --git a/server/content/modules/tier1/smuggling_ring_v0_1.yaml b/server/content/modules/tier1/smuggling_ring_v0_1.yaml deleted file mode 100644 index 9e968575c..000000000 --- a/server/content/modules/tier1/smuggling_ring_v0_1.yaml +++ /dev/null @@ -1,692 +0,0 @@ -# yaml-language-server: $schema=../../schemas/drama_module.schema.yaml -# -# Tier 1 Drama Module: The Smuggling Ring (v0.1) -# The vertical slice Tier 1 module for D-027. -# -# NARRATIVE CORE: -# A logistics worker (the smuggler PC, if played) is embedded in a small ring -# smuggling unlicensed lattice components through Sova Transit District (D-037). -# The ring is led by Voss from The Terminal. Kael Davan — a ring member and the -# smuggler's FRIEND — is quietly trying to exit to protect his partner Naia Tamm. -# Sera Venn (the detective's FRIEND) has noticed Kael's manifest discrepancies -# but hasn't reported them, protecting Naia by proxy. -# -# DUAL-LENS EXPERIENCE: -# Smuggler plays INSIDE the ring: manage drops, cover tracks, notice Kael going cold. -# Detective plays OUTSIDE: cargo anomalies → follow Kael → witness secret meeting → -# confront or protect. -# -# SUCCESS CRITERIA (D-027): -# #1: 30 minutes of daily-life play before the ring activates (min_play_ticks: 2100) -# #3: Player names Kael as an NPC they felt conflicted about -# #4: observe→notice→follow→discover emerges from systems, not scripts - -module_id: smuggling_ring_v0_1 -display_name: "The Smuggling Ring" -version: "0.1" -tier: 1 -description: > - A small ring of logistics workers smuggling unlicensed lattice components through - Sova Transit District. The ring's weakest link — Kael Davan — is trying to exit - to protect his partner. The detective investigates cargo anomalies. The smuggler - manages ring operations and navigates Kael's loyalty crisis. Neither character - knows the other's full picture until confrontation forces it. - -notes: > - This module IS the vertical slice (D-027). It exercises every system at full depth: - dual-lens NPC observation, tell progression, trust-gated dialogue, knowledge graph - confidence accumulation, confrontation weight (D-063), walk-away consequences (D-064), - and THE FRIEND contradiction arc (D-034). All outcome paths must feel earned. - No outcome is "the right answer" — Kael's situation has no clean resolution. - -dual_lens: - smuggler: > - You're inside the ring. Voss manages operations; you handle logistics cover. - Kael used to be reliable. Lately he's absent, distracted, making excuses. - The drop schedule is at risk. Do you pressure him, cover for him, or cut him? - You don't know he's trying to get out. He doesn't know you've noticed. - detective: > - Cargo manifest discrepancies in The Terminal. Small, systematic, deniable. - Your analytical lattice flags them before your conscious mind does. - Follow the thread: discrepancy → dock worker with odd schedule → Kael Davan → - maintenance corridors → someone he shouldn't be meeting. And then what? - Arrest a man trying to leave a ring he never wanted to join? - -pool: - weight: 8 - compatible_districts: - - sova-transit - max_concurrent: 1 - -# ── ENTRY CONDITIONS ───────────────────────────────────────────────────────── -# Ring activity begins after player has had time to establish routine (D-027 #1). -# The ring is already running at game start — the module activates when the -# storyteller decides the tension has built enough to surface. - -entry_conditions: - world_state: - - type: npc_present - role: ring-leader - - type: npc_present - role: ring-member-exiting - - type: location_accessible - location: the-terminal - - type: location_accessible - location: maintenance-corridors - - activation: - trigger: storyteller_push - min_play_ticks: 2100 # ~35 minutes at 1 tick/second — D-027 criterion #1 - # The storyteller pushes activation when player has established presence - # in The Terminal or The Last Shift through routine interaction. - # Proximity trigger (maintenance-corridors) is a secondary activation path - # if the player wanders there early. - -# ── NPC REQUIREMENTS ───────────────────────────────────────────────────────── -# All core roles are named (hand-authored NPCs from the vertical slice). -# No generated NPC slots in v0.1 — the smuggling ring uses the 15 authored NPCs. - -npc_requirements: - - role: ring-leader - display_hint: > - Runs the ring from The Terminal. Logistics authority = cover. - Never handles contraband directly. Pressure source for Kael. - binding: named - named_npc: "npc:voss" - must_have_motivation: HANDLER - - - role: ring-member-exiting - display_hint: > - Kael Davan. Dock worker, ring member, smuggler's FRIEND. - Trying to exit quietly to protect Naia. This is THE FRIEND contradiction. - Every event sequence runs through this role. - binding: named - named_npc: "npc:kael-davan" - must_have_pattern: FRIEND - must_have_motivation: TURNCOAT - - - role: partner-uninvolved - display_hint: > - Naia Tamm. Kael's partner. Does not know about the ring. - Her safety is Kael's motivation for exiting. Her ignorance is the moral weight. - Discovery of her connection to Kael is a late-investigation revelation. - binding: named - named_npc: "npc:naia-tamm" - must_have_motivation: CIVILIAN - - - role: evidence-holder - display_hint: > - Sera Venn. Detective's FRIEND. Commission field tech. - She has noticed Kael's manifest discrepancies but hasn't reported them — - she knows Naia, and filing means Kael's arrest and Naia's exposure. - Her silence IS the detective's investigation blocker in phase 1. - binding: named - named_npc: "npc:sera-venn" - must_have_pattern: FRIEND - must_have_motivation: WITNESS - - - role: ring-operative - display_hint: > - The ring's operational member in maintenance corridors. - Handles physical drops. Not a speaking character — observable behavior only. - Can be the anonymous contact Kael meets. - binding: named - named_npc: "npc:nils-davan" - is_optional: false - - - role: institutional-watcher - display_hint: > - Maret Korr. A Commission observer embedded at The Terminal. - Her growing attention is the external pressure that accelerates the timeline. - She doesn't know about the ring specifically — she's tracking cargo patterns. - binding: named - named_npc: "npc:maret-korr" - must_have_motivation: OPERATOR - is_optional: true # Module runs without Maret, but with degraded tension arc - -# ── EVENTS ─────────────────────────────────────────────────────────────────── -# Two sequences + one pool. -# Sequence A: Kael's exit arc (the FRIEND contradiction backbone) -# Sequence B: Investigation pressure arc (escalating discovery opportunities) -# Pool: ambient ring activity (fires opportunistically throughout the module) - -events: - - sequences: - - # SEQUENCE A: Kael's Exit Arc - # The narrative spine. Each step makes Kael's contradiction more visible. - # Observable to both characters, interpreted differently. - - - sequence_id: kael_exit_arc - label: "Kael's Exit Arc" - description: > - Kael Davan's progressive attempt to leave the ring. - Tells intensify. Routine deviations appear. The secret meeting is the - pivot point — after it fires, both characters' understanding shifts. - steps: - - - event_id: kael_goes_cold - label: "Kael Goes Cold" - description: > - Kael starts missing social patterns he'd normally keep — fewer bar visits, - shorter responses at The Terminal, leaving early. His tell system activates: - the shoulder-check behavior appears. Nothing dramatic. Just absence where - there was presence. The smuggler notices because they work together. - The detective might notice if they've been tracking Kael's baseline. - triggers: - - type: ticks_since_activation - ticks: 300 # ~5 minutes after module activates - effects: - - type: npc_routine_deviation - npc_role: ring-member-exiting - description: > - Kael skips his usual post-shift drink at The Last Shift. - Leaves the terminal 15 minutes early. No explanation. - - type: tell_intensify - npc_role: ring-member-exiting - description: > - Kael's shoulder-check behavior activates at The Terminal. - Visible to any character with forward vision cone in his direction. - sets_flag: kael_behavior_changed - - - event_id: drop_happens_without_kael - label: "Scheduled Drop — Kael Absent" - description: > - A ring drop occurs in maintenance corridor C-7. Kael was supposed - to verify the cargo. He wasn't there. Nils covered it. - The smuggler notices the irregularity in the paperwork. - The detective — if watching cargo patterns — sees a manifest entry - with no verifying signature where one is normally present. - triggers: - - type: ticks_since_event - after_event: kael_goes_cold - ticks: 450 # ~7.5 minutes after goes-cold - effects: - - type: fact_becomes_discoverable - fact_id: "ring.kael_missed_verification" - discoverable_by: any - discovery_method: > - Smuggler: check the cargo manifest in The Terminal office. - Detective: analytical lattice flags unsigned verification entry. - - type: location_state - location: maintenance-corridors - description: "An unsigned cargo verification entry exists in corridor C-7's log." - sets_flag: kael_missed_drop - - - event_id: kael_secret_meeting - label: "Kael's Secret Meeting" - description: > - Kael meets an off-district contact in maintenance corridor B-7. - This is the observable contradiction (D-034): Kael, in a restricted - area he has no logged reason to be in, talking to someone who's - not in any district NPC roster. His body language is tense. - If the player is in visual range: this is the pivot moment. - If not: the meeting happens anyway — the world doesn't wait. - triggers: - - type: ticks_since_event - after_event: drop_happens_without_kael - ticks: 600 # ~10 minutes after the dropped verification - - type: player_proximity - target_type: location - target: maintenance-corridors - radius_tiles: 12 # Player wandering near triggers the meeting early - effects: - - type: npc_routine_deviation - npc_role: ring-member-exiting - description: > - Kael enters maintenance corridor B-7. Locked door to restricted - supply closet. Emerges with the ring-operative 8 minutes later. - Neither acknowledges the encounter publicly. - - type: fact_becomes_discoverable - fact_id: "ring.kael_unauthorized_corridor_access" - discoverable_by: any - discovery_method: > - Player must be in visual range of corridor B-7. - Or examine the corridor door access log (investigative action). - - type: tell_intensify - npc_role: ring-member-exiting - description: > - After the meeting, Kael's shoulder-check frequency doubles. - Also: he avoids eye contact with the smuggler at The Terminal. - sets_flag: secret_meeting_occurred - - - event_id: kael_sends_message - label: "Kael Sends the Message" - description: > - Kael sends an encrypted Meridian message to an off-district contact. - The detective's analytical lattice can detect an anomalous outgoing - packet from the district node — not the content, just the pattern - (frequent, encrypted, sent from cargo bay terminals, not personal devices). - The smuggler won't see this unless they're specifically watching Kael. - triggers: - - type: ticks_since_event - after_event: kael_secret_meeting - ticks: 200 - effects: - - type: fact_becomes_discoverable - fact_id: "ring.kael_unusual_meridian_activity" - discoverable_by: detective - discovery_method: > - Detective's analytical lattice flags the outgoing packet pattern. - Requires player to be in or adjacent to The Terminal cargo bay. - sets_flag: kael_message_sent - - - event_id: ring_leader_confronts_kael - label: "Voss Confronts Kael" - description: > - Voss calls Kael into The Terminal supervisor's office. - Closed door. Raised voices (audible only from adjacent room/position). - Kael emerges pale. Voss emerges neutral. The smuggler can witness - the approach/departure without hearing content. The detective can - observe Kael's state immediately after if in The Terminal. - This is Voss applying pressure. Kael is now visibly under strain. - triggers: - - type: flag_set - flag: kael_message_sent - - type: ticks_since_event - after_event: kael_message_sent - ticks: 400 - effects: - - type: npc_routine_deviation - npc_role: ring-leader - description: "Voss calls Kael into the supervisor's office. Door closed." - - type: npc_routine_deviation - npc_role: ring-member-exiting - description: > - Kael emerges from the meeting looking strained. His shoulder-check - is now constant. He takes an unscheduled break outside, alone. - - type: tell_intensify - npc_role: ring-member-exiting - description: > - Kael's contentment hits lowest observed level. He now actively avoids - the ring-operative (Nils) in public. The disconnection is visible. - - type: fact_becomes_discoverable - fact_id: "ring.voss_kael_private_meeting" - discoverable_by: any - discovery_method: > - Observe the meeting room door (spatial). Or ask Kael directly - after (trust-gated dialogue unlocks "Are you alright?" option). - sets_flag: voss_pressure_applied - - # SEQUENCE B: Investigation Pressure Arc - # External pressure that escalates the timeline. - # Fires in parallel with Sequence A. - - - sequence_id: investigation_pressure - label: "Investigation Pressure Arc" - description: > - Maret Korr's institutional attention creates a closing window. - Her growing interest is the reason the module can't stay in equilibrium forever. - She doesn't know about the ring — she's a pattern-watcher. But patterns - are what the detective investigates too. Their paths converge. - steps: - - - event_id: maret_flags_anomaly - label: "Maret Flags the Cargo Anomaly" - description: > - Maret Korr files an internal Commission note flagging The Terminal's - cargo variance rate as statistically unusual. Not an investigation — - just a flag. The detective's institutional access can pull this note. - The smuggler has no way to know it exists (unless the detective tells them). - triggers: - - type: ticks_since_activation - ticks: 900 # ~15 minutes after activation - effects: - - type: fact_becomes_discoverable - fact_id: "ring.commission_cargo_flag" - discoverable_by: detective - discovery_method: > - Detective queries Commission data via institutional access - (authority access tier, Terminal records). - sets_flag: commission_flag_exists - - - event_id: maret_increases_presence - label: "Maret Increases Her Presence" - description: > - Maret starts spending more time in The Terminal. More frequent - walkthroughs during shift changes. Her attention to the cargo bay - area is noticeable to anyone watching. Ring members are unnerved. - Voss starts accelerating the timeline to close operations before - institutional attention becomes formal investigation. - triggers: - - type: ticks_since_event - after_event: maret_flags_anomaly - ticks: 600 - - type: player_action - action: examine - target_role: institutional-watcher - effects: - - type: npc_routine_deviation - npc_role: institutional-watcher - description: > - Maret adds two extra Terminal walkthroughs per shift cycle. - Spends 15 minutes studying the cargo bay manifest terminals. - - type: tell_intensify - npc_role: ring-leader - description: > - Voss becomes quieter, more deliberate. Less casual conversation. - His tell — the stillness before speaking — becomes more frequent. - - type: fact_becomes_discoverable - fact_id: "ring.voss_accelerating_timeline" - discoverable_by: any - discovery_method: > - Smuggler: Voss mentions "the schedule moving up" in a guarded - conversation (trust-gated, ring-insider access required). - Detective: observe Voss and Nils in two exchanges within same shift. - sets_flag: timeline_accelerating - - - event_id: final_shipment_scheduled - label: "The Final Shipment Is Scheduled" - description: > - The ring schedules the last major drop — after this, they go dark. - This is the closing window. If the detective hasn't uncovered enough - by the time this fires, the ring disperses and the operation closes - without exposure (escaped outcome). If they have, confrontation - becomes unavoidable. The smuggler knows about this drop. Kael doesn't - want to participate. Voss insists. - triggers: - - type: flag_set - flag: timeline_accelerating - - type: ticks_since_event - after_event: maret_increases_presence - ticks: 800 - effects: - - type: fact_becomes_discoverable - fact_id: "ring.final_shipment_scheduled" - discoverable_by: any - discovery_method: > - Smuggler: direct notification from Voss. - Detective: cargo manifest shows an unusual large entry for 3 days out. - - type: npc_routine_deviation - npc_role: ring-member-exiting - description: > - Kael's schedule changes: he's assigned to the cargo bay - during the drop window. He doesn't want to be there. - sets_flag: final_shipment_known - - pools: - - # POOL: Ambient ring activity — opportunistic events that add texture - - pool_id: ambient_ring_activity - label: "Ambient Ring Activity" - description: > - Low-level ring business that happens throughout the module regardless of - player engagement. Creates the sense that the ring exists independently. - Players who look closely will find more; players who don't still feel the world moving. - events: - - event_id: cargo_discrepancy_appears - label: "Small Cargo Discrepancy Appears" - description: > - A minor manifest irregularity appears in The Terminal records. - Small enough to be deniable. Systematic enough to be a pattern. - The detective's analytical lattice may flag it. The smuggler can - correct it if they notice it — covering tracks is part of their role. - triggers: - - type: ticks_since_activation - ticks: 150 # Fires early and repeats - effects: - - type: fact_becomes_discoverable - fact_id: "ring.cargo_discrepancy_pattern" - discoverable_by: any - discovery_method: > - Detective: analytical lattice flags during Terminal walkthrough. - Smuggler: check manifest terminals (or get flagged by the discrepancy - in their own work). - once: false # Repeats — pattern builds over time - - - event_id: sera_avoids_torek - label: "Sera Avoids Torek at The Bar" - description: > - Sera Venn reroutes her usual path through The Last Shift to avoid - standing near Torek Lintar (the Commission enforcement officer). - Anyone watching Sera's normal pattern would notice. - This is the detective's first clue that Sera's behavior is odd. - triggers: - - type: ticks_since_activation - ticks: 500 - effects: - - type: npc_routine_deviation - npc_role: evidence-holder - description: > - Sera takes a longer route to her usual seat, passing through - the back of the bar to avoid Torek's sightline. - - type: fact_becomes_discoverable - fact_id: "ring.sera_avoidance_behavior" - discoverable_by: detective - discovery_method: > - Observe Sera's path through the bar on two separate occasions. - Requires forward vision cone and awareness of her baseline route. - once: false - - - event_id: nils_makes_supply_run - label: "Nils Makes an Unscheduled Supply Run" - description: > - The ring-operative (Nils) enters the maintenance corridors with a - small container logged as "calibration tools". The container isn't - logged for return. Someone paying attention to cargo flow would notice. - triggers: - - type: ticks_since_activation - ticks: 700 - effects: - - type: npc_routine_deviation - npc_role: ring-operative - description: "Nils takes a container to maintenance corridor C-7." - - type: fact_becomes_discoverable - fact_id: "ring.nils_unlogged_cargo" - discoverable_by: any - discovery_method: > - Watch Nils's cargo handling pattern over two shifts. - Or examine maintenance corridor C-7 access log. - once: false - -# ── OUTCOMES ───────────────────────────────────────────────────────────────── -# Five resolution states. Checked each tick after the first sequence step fires. -# Order matters — the storyteller applies the first matching outcome. -# is_terminal: true ends the module. - -outcomes: - - # 1. RING EXPOSED - # Detective successfully uncovers the operation. - # Commission becomes involved. Arrests/flight follow. - - outcome_id: ring_exposed - label: "Ring Exposed" - is_terminal: true - description: > - The detective accumulates enough evidence to trigger a formal Commission - inquiry. The ring collapses: arrests, flight, or both. Voss is detained. - Kael's situation is now public. The smuggler (if played) faces consequences. - Naia learns what Kael was doing — and why he was trying to leave. - No clean endings. The right outcome for the detective who goes all the way. - conditions: - facts_known: - - "ring.cargo_discrepancy_pattern" - - "ring.kael_unauthorized_corridor_access" - - "ring.voss_kael_private_meeting" - flags_set: - - secret_meeting_occurred # set by kael_secret_meeting event - - commission_flag_exists # Commission was watching before exposure - effects: - - type: npc_disposition - npc_role: ring-leader - shift: hostile - description: "Voss is detained or flees. Commission inquiry opens." - - type: npc_disposition - npc_role: ring-member-exiting - shift: hostile - description: > - Kael is arrested or disappears. His exit attempt is now moot. - His relationship with Naia is exposed. - - type: faction_reaction - faction: lattice-commission - reaction: grateful - description: "Commission credits the detective's investigation." - - type: npc_exit - npc_role: ring-leader - description: "Voss leaves the district — detained, fled, or both." - - # 2. KAEL ESCAPES THE RING - # Unique path. Requires the player to engage with Kael directly - # and choose to help him rather than expose the ring wholesale. - - outcome_id: kael_escapes - label: "Kael Escapes the Ring" - is_terminal: true - description: > - Through the player's choices — helping Kael cover his exit, or warning him, - or simply choosing not to act on what they know — Kael successfully leaves - the ring before the final shipment. He and Naia leave the district quietly. - The ring continues without him, smaller and more cautious. - This outcome requires discovering Kael's secret AND choosing restraint. - The smuggler can engineer this by covering for Kael with Voss. - The detective can achieve this by confronting Kael privately rather than - filing a report. The most morally complicated path. - conditions: - facts_known: - - "ring.kael_unauthorized_corridor_access" - flags_set: - - kael_behavior_changed # set by kael_goes_cold — his exit arc begins here - - secret_meeting_occurred # set by kael_secret_meeting — the pivot moment - - voss_pressure_applied # set by ring_leader_confronts_kael — pressure applied - # ring_exposed is checked first in the outcomes list and is terminal, - # so kael_escapes only evaluates if ring_exposed hasn't fired. - # No flags_not_set needed here — outcome ordering handles priority. - effects: - - type: npc_disposition - npc_role: ring-member-exiting - shift: friendly - description: "Kael remembers whoever helped him. He's gone, but grateful." - - type: npc_exit - npc_role: ring-member-exiting - description: "Kael and Naia leave Sova Transit District." - - type: faction_reaction - faction: the-ring - reaction: suspicious - description: "The ring is destabilized by Kael's exit. Voss is alert to further leaks." - - # 3. RING COMPLETES OPERATION - # The ring finishes the final shipment and goes dark before discovery. - # Default path if the detective doesn't move fast enough. - - outcome_id: ring_completes - label: "Ring Completes the Operation" - is_terminal: true - description: > - The final shipment clears. The ring disperses. Voss transfers. Nils goes quiet. - Kael stays — he's now out by default, the ring having dissolved around him. - The evidence trail goes cold. The detective closes the case as inconclusive. - The smuggler completes their last run and waits to see if there's another. - Unsatisfying only if you expected a tidy resolution. The world moved on. - conditions: - flags_set: - - final_shipment_known # set by final_shipment_scheduled event - - timeline_accelerating # set by maret_increases_presence — Maret forced their hand - facts_not_known: - - "ring.cargo_discrepancy_pattern" # detective never found the basic pattern — no investigation - ticks_since_activation: 3600 # Module ran for ~60 minutes without full exposure - # kael_message_sent was previously gated here but auto-fires at tick ~1550, - # making this outcome permanently unreachable. Replaced with player-action fact gate. - effects: - - type: faction_reaction - faction: the-ring - reaction: neutral - description: "The ring successfully completed this operation. They'll be back." - - type: npc_exit - npc_role: ring-leader - description: "Voss transfers to another station for 'career development'." - - type: location_access_change - location: maintenance-corridors - change: open - description: "The restricted supply closet is now empty. Access log shows it cleared." - - # 4. RING SPLINTERS - # Partial discovery. The ring fractures but doesn't fully collapse. - # An incomplete ending that leaves threads for future investigation. - - outcome_id: ring_splinters - label: "Ring Splinters" - is_terminal: false # Not terminal — splinter state can evolve - description: > - Enough evidence surfaces that the ring knows it's been partially seen. - Voss shuts down active operations. Nils disappears. Kael stays — now the - one person in the district who knows what happened and has no one to tell. - The formal investigation stalls for lack of a clear chain of evidence. - The detective has facts but not the complete picture. The smuggler - faces an awkward return to normalcy. Both know the ring isn't gone — just quiet. - conditions: - facts_known: - - "ring.cargo_discrepancy_pattern" # detective found some evidence — ring responds - events_fired: - - kael_goes_cold # event ID — Kael's behavioral shift fired - flags_set: - - kael_missed_drop # set by drop_happens_without_kael — ring destabilized - ticks_since_activation: 2400 - # Mutually exclusive with ring_completes via facts_known/facts_not_known on - # ring.cargo_discrepancy_pattern. No auto-flag gate needed. - effects: - - type: npc_disposition - npc_role: ring-leader - shift: suspicious - description: "Voss goes quiet. He's watching to see who knows what." - - type: npc_exit - npc_role: ring-operative - description: "Nils stops appearing at The Terminal. Transferred, officially." - - type: faction_reaction - faction: the-ring - reaction: suspicious - description: "The ring is alerted to exposure risk. Future operations will be more careful." - - # 5. INVESTIGATION STALLS (post-splinter exit) - # The ring splinters but the detective never breaks through to the pivot evidence. - # Explicit terminal exit for the non-terminal ring_splinters state. - - outcome_id: ring_stalemate - label: "Investigation Stalls" - is_terminal: true - description: > - The ring went dark after the splinter. The detective has the cargo discrepancy - on record — enough to flag, not enough to pursue. The case stays open but cold. - No arrests. No answers. Kael stays in the district, the only person who knows - the full shape of what happened, with no one left to tell it to. - The ring will reconstitute elsewhere. It always does. - conditions: - facts_known: - - "ring.cargo_discrepancy_pattern" # ring_splinters already fired (same gate) - facts_not_known: - - "ring.kael_unauthorized_corridor_access" # detective never reached the pivot evidence - flags_set: - - kael_missed_drop - - final_shipment_known # ring finished while investigation stalled - ticks_since_activation: 4500 # 2100 ticks after ring_splinters window — investigation ran cold - effects: - - type: faction_reaction - faction: lattice-commission - reaction: neutral - description: "The discrepancy flag stays in Maret's file. No follow-up action." - - type: npc_exit - npc_role: ring-leader - description: "Voss quietly transfers. No announcement, no incident report." - - # 6. MODULE EXPIRY (quiet exit) - # Player never engaged at all. Module times out without drama. - # NOTE (Gestalt, Sprint 18): Condition uses facts_not_known, not flags_not_set. - # kael_behavior_changed fires automatically at tick 300 (time-triggered), making - # flags_not_set: [kael_behavior_changed] permanently false after tick 300. - # Gate expiry on player-action-required facts instead. - - outcome_id: module_abandoned - label: "Module Abandoned" - is_terminal: true - is_expiry: true - description: > - The player never engaged with the ring's signals. The final shipment - completed without incident. The ring disperses on its own schedule. - Kael stays. The world is unchanged. This is not failure — it's the game - acknowledging that not every conspiracy needs a protagonist. - The 70% mundane majority (D-029) plays out: life continued. - conditions: - facts_not_known: - - "ring.cargo_discrepancy_pattern" # Only known via player examination of terminal - - "ring.kael_unauthorized_corridor_access" # Only known via player observing Kael in B-7 - ticks_since_activation: 5400 # ~90 minutes with zero player investigation - effects: - - type: faction_reaction - faction: the-ring - reaction: neutral - description: "The ring closed operations without incident. No record of compromise." diff --git a/server/content/schemas/drama_module.schema.yaml b/server/content/schemas/drama_module.schema.yaml deleted file mode 100644 index 93c2df1e5..000000000 --- a/server/content/schemas/drama_module.schema.yaml +++ /dev/null @@ -1,718 +0,0 @@ -# Drama Module Schema — Tier 1 Content (D-023) -# YAML expression of JSON Schema 2020-12 -# Validated against this schema: server/content/modules/tier1/*.yaml -# -# Ownership: -# Dramatic structure (this file): Paula -# YAML validation tooling / serde structs: Gestalt / Tyre -# Authoring ergonomics review: Mellanie -# -# See: docs/design/tier1-module-authoring.md for field-by-field guide. - -$schema: "https://json-schema.org/draft/2020-12/schema" -$id: "drama_module.schema.yaml" -title: "Tier 1 Drama Module" -description: > - A hand-authored drama module drawn from the pool at game start. - The storyteller activates one or more modules per playthrough based on - entry conditions, then fires events and detects outcomes. Tier 1 modules - are the conspiracy layer of D-023 — authored, optional, relocatable. -type: object -required: - - module_id - - display_name - - version - - tier - - pool - - entry_conditions - - npc_requirements - - events - - outcomes -additionalProperties: false - -properties: - - # ── IDENTITY ──────────────────────────────────────────────────────────────── - - module_id: - type: string - pattern: "^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$" - description: > - Stable unique slug. Format: {name}_v{major}_{minor}. - Never reuse IDs. Increment version on breaking structural changes. - Example: "smuggling_ring_v0_1" - - display_name: - type: string - minLength: 1 - description: "Human-readable title shown in dev/debug tooling." - - version: - type: string - pattern: "^[0-9]+\\.[0-9]+$" - description: "Authoring version. Semantic: major.minor." - - tier: - type: integer - const: 1 - description: "Always 1 for Tier 1 drama modules." - - description: - type: string - description: "One-paragraph authoring summary. Not shown in-game." - - # ── POOL METADATA ───────────────────────────────────────────────────────── - # Controls how the storyteller includes this module in the per-playthrough pool. - - pool: - type: object - required: - - weight - additionalProperties: false - description: "How the storyteller samples this module from the pool." - properties: - weight: - type: integer - minimum: 1 - maximum: 10 - description: > - Relative selection probability (1–10). Higher = more likely to be - included in a given playthrough's active module set. Default: 5. - compatible_districts: - type: array - items: - type: string - description: > - District slugs where this module can activate, or omit for "any". - Example: ["sova-transit"] - incompatible_with: - type: array - items: - type: string - pattern: "^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$" - description: > - Module IDs that cannot run concurrently with this one. - The storyteller will not activate both in the same playthrough. - max_concurrent: - type: integer - minimum: 1 - default: 1 - description: > - Maximum simultaneous active instances. Almost always 1. - Set to 2+ only for modules designed to stack (rare). - - # ── ENTRY CONDITIONS ────────────────────────────────────────────────────── - # All listed conditions must be true for the module to become activatable. - # The storyteller checks these each tick after min_play_ticks. - - entry_conditions: - type: object - required: - - activation - additionalProperties: false - description: > - World-state prerequisites. The storyteller activates the module when - ALL conditions are satisfied AND the activation trigger fires. - properties: - world_state: - type: array - items: - $ref: "#/$defs/world_state_condition" - description: "World-state conditions checked each tick." - player: - type: array - items: - $ref: "#/$defs/player_condition" - description: > - Optional player-state conditions. Module can activate without - player engagement — these gate on player-specific world state, - not on player noticing the module. - activation: - type: object - required: - - trigger - additionalProperties: false - description: "How and when activation is evaluated." - properties: - trigger: - type: string - enum: - - proximity # Player comes within range of a key NPC/location - - storyteller_push # Storyteller activates on its own schedule - - player_action # Player performs a specific action - description: "What pushes the module from 'eligible' to 'active'." - min_play_ticks: - type: integer - minimum: 0 - description: > - Minimum ticks of game time before this module can activate. - Enforces D-027 success criterion #1: 30 minutes of daily-life - breathing room. At 1 tick/second, 30 minutes ≈ 1800 ticks. - proximity_location: - type: string - description: > - Required when trigger = proximity. Location slug the player - must enter or approach. Example: "maintenance-corridors" - proximity_radius_tiles: - type: integer - minimum: 1 - description: > - Required when trigger = proximity. Tile radius around the - location's anchor point. - player_action_required: - type: string - description: > - Required when trigger = player_action. The action that fires - activation. Example: "examine:cargo-manifest" - - # ── NPC REQUIREMENTS ────────────────────────────────────────────────────── - # NPC slots this module requires. Each slot is filled at module load time. - # Named bindings resolve to specific authored NPCs; generated bindings - # are filled from the district's generated NPC pool. - - npc_requirements: - type: array - minItems: 1 - items: - $ref: "#/$defs/npc_slot" - description: > - Module-internal NPC role slots. Roles are referenced by slug throughout - the rest of this document. Hand-authored NPCs use named bindings. - Generated NPCs use constraint-based bindings. - - # ── EVENTS ──────────────────────────────────────────────────────────────── - # Ordered sequences and unordered event pools the storyteller can fire. - # Sequences are narrative beats in a defined order. - # Pools are events the storyteller can fire in any order when conditions are met. - - events: - type: object - additionalProperties: false - description: "Event sequences and pools the storyteller manages." - properties: - sequences: - type: array - items: - $ref: "#/$defs/event_sequence" - description: > - Ordered event sequences. Steps fire in order; the next step - becomes eligible only after the previous one fires. - pools: - type: array - items: - $ref: "#/$defs/event_pool" - description: > - Unordered event pools. The storyteller may fire any eligible - event in the pool when its trigger conditions are met. - - # ── OUTCOMES ────────────────────────────────────────────────────────────── - # Resolution states the module can reach. The storyteller checks outcome - # conditions each tick. First matching outcome wins. - # Every module MUST include an expiry outcome. - - outcomes: - type: array - minItems: 1 - items: - $ref: "#/$defs/outcome" - description: > - Terminal and transitional resolution states. The storyteller checks - these each tick and applies the first matching outcome. - - # ── AUTHORING NOTES ─────────────────────────────────────────────────────── - - notes: - type: string - description: "Authoring-only field. Design rationale, cross-references. Ignored at load time." - - dual_lens: - type: object - additionalProperties: false - description: "Authoring-only. How smuggler vs detective experience this module." - properties: - smuggler: { type: string } - detective: { type: string } - -# ── SHARED DEFINITIONS ──────────────────────────────────────────────────────── - -$defs: - - # World-state condition types - - world_state_condition: - type: object - required: - - type - description: "A single world-state prerequisite for module activation." - oneOf: - - # NPC with the given module role is present in the district - properties: - type: { type: string, const: "npc_present" } - role: { type: string, description: "Module-internal NPC role slug." } - required: [type, role] - additionalProperties: false - - - # A specific location is accessible to the player - properties: - type: { type: string, const: "location_accessible" } - location: { type: string, description: "Location slug." } - required: [type, location] - additionalProperties: false - - - # Player has NOT yet discovered a specific fact - properties: - type: { type: string, const: "fact_not_known" } - fact_id: { type: string, description: "Fact ID from global/knowledge/." } - required: [type, fact_id] - additionalProperties: false - - - # No other Tier 1 module of the given ID is currently active - properties: - type: { type: string, const: "no_active_module" } - module_id: { type: string } - required: [type, module_id] - additionalProperties: false - - - # A named fact IS known (module requires precondition awareness) - properties: - type: { type: string, const: "fact_known" } - fact_id: { type: string } - known_by: { type: string, enum: [smuggler, detective, any] } - required: [type, fact_id] - additionalProperties: false - - # Player-state condition types - - player_condition: - type: object - required: - - type - description: "A player-state prerequisite." - oneOf: - - # Player has reached minimum relationship threshold with an NPC - properties: - type: { type: string, const: "relationship_threshold" } - npc_role: { type: string, description: "Module-internal NPC role." } - min_state: - type: string - enum: [stranger, known, friendly] - description: "Minimum RelationshipState required." - required: [type, npc_role, min_state] - additionalProperties: false - - - # Minimum game ticks elapsed - properties: - type: { type: string, const: "min_ticks" } - ticks: { type: integer, minimum: 0 } - required: [type, ticks] - additionalProperties: false - - # NPC slot definition - - npc_slot: - type: object - required: - - role - - binding - additionalProperties: false - description: > - One NPC slot in the module. Named binding = specific authored NPC. - Generated binding = constraint-matched NPC from district pool. - properties: - role: - type: string - pattern: "^[a-z][a-z0-9-]*$" - description: > - Module-internal role slug. Referenced in events, outcomes, and - triggers. Example: "ring-leader", "ring-member-exiting", "witness" - display_hint: - type: string - description: "Authoring note. What this role is narratively." - binding: - type: string - enum: [named, generated] - description: > - named = resolves to a specific authored NPC (use named_npc). - generated = any district NPC matching the axis constraints. - named_npc: - type: string - pattern: "^npc:[a-z][a-z0-9-]*$" - description: > - Required when binding = named. Short-form NPC canonical ID. - Example: "npc:kael-davan" - axes: - type: array - items: - $ref: "#/$defs/axis_constraint" - description: > - Required when binding = generated. The NPC must satisfy all - listed axis constraints to fill this slot. - must_have_pattern: - type: string - enum: [FRIEND, MIRROR, ANCHOR, GHOST, CATALYST, THRESHOLD, REMNANT, SYSTEM, NOBODY] - description: "Optional: NPC must have this pattern (D-024)." - must_have_motivation: - type: string - enum: [HANDLER, WITNESS, TURNCOAT, CIVILIAN, OPERATOR, SKEPTIC] - description: "Optional: NPC must have this motivation (D-024)." - is_optional: - type: boolean - default: false - description: > - If true, the module can activate without this slot filled. - Optional slots produce degraded but valid module runs. - - # NPC axis constraint (used in generated bindings) - - axis_constraint: - type: object - required: - - axis - - constraint - additionalProperties: false - properties: - axis: - type: string - enum: [want, secret, relationships, tolerance, routine, information, contentment, personality, tells, skills] - description: "Which NPC axis to constrain (D-024)." - constraint: - type: string - description: > - Constraint expression. Freeform string interpreted by the storyteller. - Convention: "has_{value}", "min_{N}", "not_{value}". - Examples: "has_major_secret", "min_contentment_-3", "not_combat_trained" - - # Event sequence - - event_sequence: - type: object - required: - - sequence_id - - steps - additionalProperties: false - description: "An ordered sequence of narrative events." - properties: - sequence_id: - type: string - pattern: "^[a-z][a-z0-9_-]*$" - label: - type: string - description: - type: string - steps: - type: array - minItems: 1 - items: - $ref: "#/$defs/event_step" - - # Unordered event pool - - event_pool: - type: object - required: - - pool_id - - events - additionalProperties: false - properties: - pool_id: - type: string - pattern: "^[a-z][a-z0-9_-]*$" - label: - type: string - description: - type: string - events: - type: array - minItems: 1 - items: - $ref: "#/$defs/event_step" - - # Individual event step - - event_step: - type: object - required: - - event_id - - triggers - additionalProperties: false - description: "A single storyteller-managed event with triggers and effects." - properties: - event_id: - type: string - pattern: "^[a-z][a-z0-9_-]*$" - description: "Unique within this module. Used in outcome conditions." - label: - type: string - description: - type: string - description: "What happens narratively when this event fires." - triggers: - type: array - minItems: 1 - items: - $ref: "#/$defs/event_trigger" - description: "ANY trigger being true fires this event." - effects: - type: array - items: - $ref: "#/$defs/event_effect" - description: "What changes in the world when this event fires." - once: - type: boolean - default: true - description: "If true, fires only once. If false, may repeat when conditions reset." - sets_flag: - type: string - pattern: "^[a-z][a-z0-9_-]*$" - description: "Module-internal flag set when this event fires. Queryable in outcomes." - - # Event trigger conditions - - event_trigger: - type: object - required: - - type - description: "A condition that causes an event to fire." - oneOf: - - # Ticks elapsed since module activation - properties: - type: { type: string, const: "ticks_since_activation" } - ticks: { type: integer, minimum: 1 } - required: [type, ticks] - additionalProperties: false - - - # Ticks elapsed since a previous event fired - properties: - type: { type: string, const: "ticks_since_event" } - after_event: { type: string } - ticks: { type: integer, minimum: 1 } - required: [type, after_event, ticks] - additionalProperties: false - - - # Player enters a location or comes within range of NPC - properties: - type: { type: string, const: "player_proximity" } - target_type: { type: string, enum: [location, npc_role] } - target: { type: string } - radius_tiles: { type: integer, minimum: 1 } - required: [type, target_type, target] - additionalProperties: false - - - # Player performs an interaction - properties: - type: { type: string, const: "player_action" } - action: - type: string - enum: [talk, examine, confront, follow, observe] - target_role: { type: string, description: "Module NPC role or location slug." } - required: [type, action, target_role] - additionalProperties: false - - - # Player has discovered a specific fact - properties: - type: { type: string, const: "fact_known_by_player" } - fact_id: { type: string } - required: [type, fact_id] - additionalProperties: false - - - # A module flag has been set - properties: - type: { type: string, const: "flag_set" } - flag: { type: string } - required: [type, flag] - additionalProperties: false - - - # NPC enters a specific mood state - properties: - type: { type: string, const: "npc_mood" } - npc_role: { type: string } - mood: - type: string - enum: [anxious, frustrated, content, suspicious, warm, hostile, relieved, focused] - required: [type, npc_role, mood] - additionalProperties: false - - # Event effects - - event_effect: - type: object - required: - - type - description: "A world change triggered by an event." - oneOf: - - # NPC deviates from their normal routine - properties: - type: { type: string, const: "npc_routine_deviation" } - npc_role: { type: string } - description: { type: string, description: "What the deviation looks like." } - duration_ticks: { type: integer } - required: [type, npc_role, description] - additionalProperties: false - - - # A fact becomes discoverable (moves to Rumoured confidence) - properties: - type: { type: string, const: "fact_becomes_discoverable" } - fact_id: { type: string } - discoverable_by: - type: string - enum: [smuggler, detective, any] - discovery_method: - type: string - description: "How the player can discover this. Authoring note." - required: [type, fact_id, discoverable_by] - additionalProperties: false - - - # NPC tell behavior becomes more pronounced - properties: - type: { type: string, const: "tell_intensify" } - npc_role: { type: string } - description: { type: string } - required: [type, npc_role] - additionalProperties: false - - - # A module-internal flag is set - properties: - type: { type: string, const: "flag_set" } - flag: { type: string, pattern: "^[a-z][a-z0-9_-]*$" } - required: [type, flag] - additionalProperties: false - - - # Something changes about a location - properties: - type: { type: string, const: "location_state" } - location: { type: string } - description: { type: string } - required: [type, location, description] - additionalProperties: false - - - # NPC's access to information changes - properties: - type: { type: string, const: "npc_knowledge_update" } - npc_role: { type: string } - fact_id: { type: string } - description: { type: string } - required: [type, npc_role, fact_id] - additionalProperties: false - - # Module outcome definition - - outcome: - type: object - required: - - outcome_id - - label - - is_terminal - additionalProperties: false - description: > - A resolution state the module can reach. Conditions are checked each tick. - The first matching outcome is applied. is_terminal = true ends the module. - properties: - outcome_id: - type: string - pattern: "^[a-z][a-z0-9_-]*$" - label: - type: string - description: - type: string - description: "What this outcome means narratively." - is_terminal: - type: boolean - description: "If true, this outcome ends the module permanently." - is_expiry: - type: boolean - default: false - description: > - If true, this is the quiet-exit outcome when the player never engages. - Every module must include exactly one expiry outcome. - conditions: - type: object - additionalProperties: false - description: "ALL conditions must be true to reach this outcome." - properties: - facts_known: - type: array - items: { type: string } - description: "Player must know all these facts." - facts_not_known: - type: array - items: { type: string } - description: "Player must NOT know any of these facts." - flags_set: - type: array - items: { type: string } - description: "All these module flags must be set." - flags_not_set: - type: array - items: { type: string } - description: "None of these module flags may be set." - events_fired: - type: array - items: { type: string } - description: "All these events must have fired." - ticks_since_activation: - type: integer - description: "Module has been active for at least this many ticks." - effects: - type: array - items: - $ref: "#/$defs/outcome_effect" - description: "Effects applied when this outcome is reached." - - # Outcome-level effects (broader scope than event effects) - - outcome_effect: - type: object - required: - - type - oneOf: - - # NPC disposition toward player changes - properties: - type: { type: string, const: "npc_disposition" } - npc_role: { type: string } - shift: - type: string - enum: [hostile, suspicious, neutral, friendly] - description: { type: string } - required: [type, npc_role, shift] - additionalProperties: false - - - # Faction reaction - properties: - type: { type: string, const: "faction_reaction" } - faction: { type: string } - reaction: - type: string - enum: [hostile, suspicious, neutral, friendly, grateful] - description: { type: string } - required: [type, faction, reaction] - additionalProperties: false - - - # Location becomes restricted or opens up - properties: - type: { type: string, const: "location_access_change" } - location: { type: string } - change: - type: string - enum: [restricted, locked, open] - description: { type: string } - required: [type, location, change] - additionalProperties: false - - - # A fact is now permanently known/unknown - properties: - type: { type: string, const: "fact_state" } - fact_id: { type: string } - state: - type: string - enum: [known, hidden, destroyed] - description: { type: string } - required: [type, fact_id, state] - additionalProperties: false - - - # NPC leaves the district or changes role - properties: - type: { type: string, const: "npc_exit" } - npc_role: { type: string } - description: { type: string } - required: [type, npc_role] - additionalProperties: false diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index d5d6a4b2d..7a550a90b 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -10,24 +10,15 @@ pub use crate::knowledge::types::{ }; pub use crate::simulation::time::{DayPhase, TickRate}; -/// Wire protocol version for ObserverSnapshot. -/// -/// Versioning strategy: flat struct + serde defaults for field evolution. -/// Client and server are co-versioned (subprocess IPC per D-020), so protocol -/// negotiation is unnecessary. Client should reject snapshots with version != -/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration -/// period, then the default is removed once both sides are updated. -pub const PROTOCOL_VERSION: u8 = 23; - /// Handshake message sent as the very first framed message after connection (#555). -/// Client reads this before entering the normal tick loop and validates -/// `protocol_version` against its own `PROTOCOL_VERSION` constant. +/// Client reads this before entering the normal tick loop, then sends StartupMessage. /// Wire format: MessagePack, same 4-byte length-prefixed framing as ObserverSnapshot. +/// +/// No version field — D-192 dropped the lockstep version check. Client and server +/// are always co-shipped (D-005); genuine schema drift surfaces as a downstream +/// MessagePack missing-field error rather than an eager handshake rejection. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct HandshakeMessage { - /// Must match client's PROTOCOL_VERSION or the client should disconnect. - pub protocol_version: u8, -} +pub struct HandshakeMessage {} /// Startup message sent by the client after receiving HandshakeMessage (#175). /// Contains the world seed for deterministic simulation (D-010, D-029). @@ -46,12 +37,6 @@ pub struct StartupMessage { /// Generated by SessionManager.new_game() on the client. /// Same seed → same EntanglementConfig → same NPC population (D-029). pub world_seed: u64, - /// Character archetype selected by the player (#587). - /// Gates monologue pool selection, verb labels, and examine text. - /// Defaults to Detective for backward compatibility (old clients - /// that omit this field). - #[serde(default)] - pub character_archetype: CharacterArchetype, } /// The ONLY data structure crossing the client-server boundary (D-020) @@ -79,7 +64,8 @@ pub struct StartupMessage { /// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick), /// sim_errors (#85, structured error reporting to client). /// v18 adds: debug_response (#580, debug console server — command/response wire). -/// v19 adds: character_archetype on StartupMessage (#587), current_ticker (#591). +/// v19 adds: current_ticker (#591). (character_archetype on StartupMessage was +/// added in #587 and removed in Sprint 37 per D-032 purge / cascade cleanup.) /// v20 adds: settings_response (#627, SQLite settings IPC). /// v21 adds: economy_snapshot (#822, D-181 7-signal snapshot per queried system), /// EconStateQuery PlayerAction variant (#822). @@ -89,8 +75,6 @@ pub struct StartupMessage { /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. See [`PROTOCOL_VERSION`]. - pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, /// Game time data for client HUD display (D-031) @@ -495,28 +479,6 @@ pub enum ObjectType { Furniture, } -/// Character archetype for Phase 2 verb filtering (#422) and monologue pool -/// selection. Determines how the character perceives and labels interactions. -/// v0.1: Smuggler and Detective (the two playable characters). -#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] -pub enum CharacterArchetype { - /// Smuggler character — sees Move/Stash on containers, physical manipulation verbs - Smuggler, - /// Detective character — sees Scan/Flag on containers, investigation verbs - #[default] - Detective, -} - -impl CharacterArchetype { - /// String key for monologue pool filtering (#587). - pub fn as_monologue_key(&self) -> &'static str { - match self { - Self::Smuggler => "smuggler", - Self::Detective => "detective", - } - } -} - /// Semantic player actions, not raw key events (D-020) /// Timestamped for deterministic processing #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1105,73 +1067,28 @@ mod tests { #[test] fn handshake_message_roundtrip() { - let msg = HandshakeMessage { - protocol_version: PROTOCOL_VERSION, - }; + // D-192: HandshakeMessage carries no version field; roundtrip verifies + // the empty struct serialises and deserialises cleanly. + let msg = HandshakeMessage {}; let bytes = rmp_serde::to_vec_named(&msg).expect("serialize"); let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize"); assert_eq!(decoded, msg); - assert_eq!(decoded.protocol_version, PROTOCOL_VERSION); - } - - #[test] - fn handshake_message_rejects_wrong_version() { - let msg = HandshakeMessage { - protocol_version: PROTOCOL_VERSION, - }; - let bytes = rmp_serde::to_vec_named(&msg).expect("serialize"); - let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize"); - // Simulate client-side validation: version mismatch should be detectable - let wrong_version = PROTOCOL_VERSION.wrapping_add(1); - assert_ne!(decoded.protocol_version, wrong_version); } #[test] fn startup_message_roundtrip() { let msg = StartupMessage { world_seed: 0xDEADBEEF, - character_archetype: CharacterArchetype::Detective, }; let bytes = rmp_serde::to_vec_named(&msg).expect("serialize"); let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize"); assert_eq!(decoded, msg); assert_eq!(decoded.world_seed, 0xDEADBEEF); - assert_eq!(decoded.character_archetype, CharacterArchetype::Detective); - } - - #[test] - fn startup_message_smuggler_roundtrip() { - let msg = StartupMessage { - world_seed: 42, - character_archetype: CharacterArchetype::Smuggler, - }; - let bytes = rmp_serde::to_vec_named(&msg).expect("serialize"); - let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!(decoded, msg); - assert_eq!(decoded.character_archetype, CharacterArchetype::Smuggler); - } - - #[test] - fn startup_message_missing_archetype_defaults_to_detective() { - // Simulate an old client that sends only world_seed (no character_archetype). - // serde(default) on StartupMessage.character_archetype should default to Detective. - #[derive(Serialize)] - struct OldStartupMessage { - world_seed: u64, - } - let old = OldStartupMessage { world_seed: 99 }; - let bytes = rmp_serde::to_vec_named(&old).expect("serialize"); - let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!(decoded.world_seed, 99); - assert_eq!(decoded.character_archetype, CharacterArchetype::Detective); } #[test] fn startup_message_zero_seed() { - let msg = StartupMessage { - world_seed: 0, - character_archetype: CharacterArchetype::default(), - }; + let msg = StartupMessage { world_seed: 0 }; let bytes = rmp_serde::to_vec_named(&msg).expect("serialize"); let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize"); assert_eq!(decoded.world_seed, 0); @@ -1181,7 +1098,6 @@ mod tests { fn startup_message_max_seed() { let msg = StartupMessage { world_seed: u64::MAX, - character_archetype: CharacterArchetype::default(), }; let bytes = rmp_serde::to_vec_named(&msg).expect("serialize"); let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize"); @@ -1191,10 +1107,9 @@ mod tests { #[test] fn handshake_is_distinct_from_snapshot() { // HandshakeMessage and ObserverSnapshot are different types on the wire. - // A HandshakeMessage should NOT deserialize as an ObserverSnapshot. - let msg = HandshakeMessage { - protocol_version: PROTOCOL_VERSION, - }; + // A HandshakeMessage (empty map) must NOT deserialize as an ObserverSnapshot + // because ObserverSnapshot has required fields (tick, game_time, etc.). + let msg = HandshakeMessage {}; let bytes = rmp_serde::to_vec_named(&msg).expect("serialize"); let result = rmp_serde::from_slice::(&bytes); assert!( diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 950e2dd89..48909b4dd 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -78,7 +78,6 @@ pub fn compute_observer_snapshot( &mut NearbyInteractionBuffer, &mut MonologueBuffer, Option<&Stance>, - Option<&CharacterArchetype>, Option<&mut SprintAnomalyQueue>, Option<&CognitiveDelay>, Option<&mut DialogueResponseBuffer>, @@ -114,7 +113,6 @@ pub fn compute_observer_snapshot( mut interaction_buffer, mut monologue_buffer, stance_opt, - archetype_opt, mut anomaly_queue_opt, cognitive_delay_opt, mut dialogue_response_opt, @@ -135,8 +133,6 @@ pub fn compute_observer_snapshot( .map(|f| f.0) .unwrap_or(FacingDirection::default()); - let archetype = archetype_opt.copied().unwrap_or_default(); - // Collect player inventory (D-065 info boundary: only own items) let player_inventory = registry .to_stable(observer_entity) @@ -197,7 +193,7 @@ pub fn compute_observer_snapshot( // Take interactions and apply Phase 2 verb filter (D-057, #422) let mut nearby_interactions = interaction_buffer.take(); - apply_phase2_verb_filter(&mut nearby_interactions, observer_kg, archetype); + apply_phase2_verb_filter(&mut nearby_interactions, observer_kg); tracing::trace!( "compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}", @@ -453,7 +449,6 @@ pub fn compute_observer_snapshot( }; buffer.snapshot = Some(ObserverSnapshot { - version: crate::bridge::types::PROTOCOL_VERSION, tick: time.tick, game_time, player_facing: facing, @@ -654,7 +649,6 @@ fn collect_remembered_entities( /// 1. POI priority flips (D-060) — ExamineNpc above Talk for POI entities /// 2. Confront injection — adds Confront verb for NPCs when KnowsDetails+ /// 3. Contradiction marking — sets contradicted flag when entity knowledge is Contradicted -/// 4. Archetype label relabeling — smuggler/detective see different labels for same verb /// /// Phase boundary: Phase 1 (interaction.rs) determines verb availability from /// ObjectType + proximity. Phase 2 (here) reads the observer's KnowledgeGraph @@ -663,7 +657,6 @@ fn collect_remembered_entities( fn apply_phase2_verb_filter( interactions: &mut [NearbyInteraction], observer_kg: &KnowledgeGraph, - archetype: CharacterArchetype, ) { for interaction in interactions.iter_mut() { 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 interaction .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, - kind: VerbKind, -) -> Option<&'static str> { - match (archetype, object_type, kind) { - // Smuggler: Container verbs — physical manipulation vocabulary - (CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Open) => Some("Move"), - (CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Search) => { - Some("Stash") - } - - // Detective: Container verbs — investigation vocabulary - (CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Open) => { - Some("Scan") - } - (CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Search) => { - Some("Flag") - } - - // All other combinations: keep Phase 1 default label - _ => None, - } -} - #[cfg(test)] mod tests; diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index 4ee5f93d6..73d155ad7 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -59,7 +59,6 @@ fn player_always_visible_in_snapshot() { let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); - assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!(snapshot.entities.len(), 1); assert!(matches!(snapshot.entities[0].kind, EntityKind::Player)); assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible); @@ -679,10 +678,6 @@ fn snapshot_v6_fields_default_through_pipeline() { let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); - assert_eq!( - snapshot.version, PROTOCOL_VERSION, - "should be current protocol version" - ); assert_eq!( snapshot.player_stance, MovementStance::Walk, @@ -694,29 +689,6 @@ fn snapshot_v6_fields_default_through_pipeline() { ); } -#[test] -fn snapshot_v6_version_is_protocol_version() { - let mut world = setup_world(32, 32); - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing::default(), - KnowledgeGraph::new(), - NearbyInteractionBuffer::default(), - MonologueBuffer::default(), - )); - - run_observer_pipeline(&mut world); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - assert_eq!( - snapshot.version, - crate::bridge::types::PROTOCOL_VERSION, - "snapshot version must match PROTOCOL_VERSION constant" - ); -} - // ----------------------------------------------------------------------- // Phase 2 verb filter tests (#422, D-057) // ----------------------------------------------------------------------- @@ -967,148 +939,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::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - assert_eq!(snapshot.nearby_interactions.len(), 1); - let interaction = &snapshot.nearby_interactions[0]; - // Container at close range: Open→"Move", Search→"Stash", Observe stays "Observe" - let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); - let search_verb = interaction - .verbs - .iter() - .find(|v| v.kind == VerbKind::Search); - let observe_verb = interaction - .verbs - .iter() - .find(|v| v.kind == VerbKind::Observe); - assert_eq!(open_verb.unwrap().label, "Move", "smuggler Open→Move"); - assert_eq!(search_verb.unwrap().label, "Stash", "smuggler Search→Stash"); - assert_eq!(observe_verb.unwrap().label, "Observe", "Observe unchanged"); -} - -#[test] -fn phase2_detective_relabels_container_verbs() { - let mut world = setup_world(32, 32); - let mut registry = EntityRegistry::new(0); - - let container = world - .spawn(( - TilePosition::new(16, 15, 0), - crate::simulation::interaction::Interactable, - ObjectType::Container, - )) - .id(); - registry.register(container); - - // Detective player (explicit) - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - KnowledgeGraph::new(), - NearbyInteractionBuffer::default(), - MonologueBuffer::default(), - CharacterArchetype::Detective, - )) - .id(); - registry.register(player); - world.insert_resource(registry); - - run_full_pipeline(&mut world); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - assert_eq!(snapshot.nearby_interactions.len(), 1); - let interaction = &snapshot.nearby_interactions[0]; - let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); - let search_verb = interaction - .verbs - .iter() - .find(|v| v.kind == VerbKind::Search); - assert_eq!(open_verb.unwrap().label, "Scan", "detective Open→Scan"); - assert_eq!(search_verb.unwrap().label, "Flag", "detective Search→Flag"); -} - -#[test] -fn phase2_default_archetype_is_detective() { - // When no CharacterArchetype component attached, defaults to Detective - let mut world = setup_world(32, 32); - let mut registry = EntityRegistry::new(0); - - let container = world - .spawn(( - TilePosition::new(16, 15, 0), - crate::simulation::interaction::Interactable, - ObjectType::Container, - )) - .id(); - registry.register(container); - - // Player WITHOUT CharacterArchetype component - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - KnowledgeGraph::new(), - NearbyInteractionBuffer::default(), - MonologueBuffer::default(), - )) - .id(); - registry.register(player); - world.insert_resource(registry); - - run_full_pipeline(&mut world); - - let buffer = world.resource::(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - assert_eq!(snapshot.nearby_interactions.len(), 1); - let interaction = &snapshot.nearby_interactions[0]; - // Default = Detective labels - let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); - assert_eq!( - open_verb.unwrap().label, - "Scan", - "default archetype should use Detective labels" - ); -} - #[test] fn phase2_non_container_keeps_default_labels() { - // Readable objects should keep their default labels regardless of archetype + // Readable objects keep their default labels (post-archetype cleanup, D-032 SUPERSEDED). let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); @@ -1129,7 +962,6 @@ fn phase2_non_container_keeps_default_labels() { KnowledgeGraph::new(), NearbyInteractionBuffer::default(), MonologueBuffer::default(), - CharacterArchetype::Smuggler, )) .id(); registry.register(player); @@ -1145,7 +977,7 @@ fn phase2_non_container_keeps_default_labels() { assert_eq!( read_verb.unwrap().label, "Read", - "Readable labels unchanged for smuggler" + "Readable labels unchanged" ); } diff --git a/server/src/simulation/examine.rs b/server/src/simulation/examine.rs index c13943fa3..07011ed59 100644 --- a/server/src/simulation/examine.rs +++ b/server/src/simulation/examine.rs @@ -14,7 +14,6 @@ use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; -use crate::bridge::types::CharacterArchetype; use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType}; use crate::knowledge::EntityRegistry; use crate::npc::mood::{MoodState, NpcMood}; @@ -37,11 +36,12 @@ pub struct ExamineRequest { pub target: Entity, } -/// Character-filtered examination result for snapshot delivery. +/// Examination result for snapshot delivery. /// -/// Content differs per CharacterArchetype: -/// Smuggler — physical threat read, cargo-handling posture, opportunity windows. -/// Detective — procedural tells, behavioral inconsistencies, stress indicators. +/// Phase 6 note: per-archetype text variants (smuggler/detective flavor) were +/// removed during the cascade cleanup. Text is a single unified "subject read" +/// until archetype differentiation is reintroduced per the culture-driven +/// voice system (D-121) in a later cascade phase. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExamineResultEvent { /// Character-filtered observation text for client display. @@ -106,12 +106,15 @@ fn has_trait(traits_opt: Option<&PersonalityTraits>, t: PersonalityTrait) -> boo traits_opt.map(|p| p.traits.contains(&t)).unwrap_or(false) } -/// Generate character-filtered examination text from NPC component state. +/// Generate examination text from NPC component state. /// All logic is pure, deterministic, and integer-based (D-010). +/// +/// Archetype-specific text variants were removed during the cascade cleanup +/// (D-032 / Sprint 37). Reintroduce per-culture voice when the cascade reaches +/// the Phase 6 character/NPC layer (see D-121). pub fn generate_examine_text( mood: NpcMood, ratio: u8, - archetype: CharacterArchetype, traits_opt: Option<&PersonalityTraits>, ) -> String { let stress_label = match ratio { @@ -123,53 +126,29 @@ pub fn generate_examine_text( let mood_label = mood_word(mood); - match archetype { - CharacterArchetype::Smuggler => { - // Physical threat read + cargo opportunity window - let threat = if matches!(mood, NpcMood::Hostile | NpcMood::Suspicious) { - "Threat posture. Don't push it." - } else if has_trait(traits_opt, PersonalityTrait::Bold) { - "Confident bearing. Will push back if cornered." - } else if has_trait(traits_opt, PersonalityTrait::Cautious) { - "Nervous type. Predictable under pressure." - } else { - "No obvious threat read." - }; + let tell = if has_trait(traits_opt, PersonalityTrait::Deceptive) { + "Controlled affect — practiced concealment." + } else if matches!(mood, NpcMood::Anxious | NpcMood::Frustrated) { + "Involuntary stress markers present." + } else if matches!(mood, NpcMood::Suspicious) { + "Scanning. Aware of being observed." + } else if matches!(mood, NpcMood::Hostile) { + "Threat posture. Aware of being observed." + } else { + "Baseline presentation." + }; - let window = if ratio > 60 { - "Too distracted to track cargo movement." - } else if matches!(mood, NpcMood::Focused) { - "Paying close attention to this section." - } else { - "Standard patrol pattern. Window is there." - }; + let read = if ratio > 60 { + "Under pressure — potential liability or asset." + } else if matches!(mood, NpcMood::Content | NpcMood::Warm) { + "Comfortable. Less guarded than usual." + } else if matches!(mood, NpcMood::Focused) { + "Paying close attention." + } else { + "Routine behavior pattern." + }; - format!("Appears {mood_label}, {stress_label}. {threat} {window}") - } - - CharacterArchetype::Detective => { - // Procedural tells + behavioral read - let tell = if has_trait(traits_opt, PersonalityTrait::Deceptive) { - "Controlled affect — practiced concealment." - } else if matches!(mood, NpcMood::Anxious | NpcMood::Frustrated) { - "Involuntary stress markers present." - } else if matches!(mood, NpcMood::Suspicious) { - "Scanning. Aware of being observed." - } else { - "Baseline presentation." - }; - - let read = if ratio > 60 { - "Under pressure — potential liability or asset." - } else if matches!(mood, NpcMood::Content | NpcMood::Warm) { - "Comfortable. Less guarded than usual." - } else { - "Routine behavior pattern." - }; - - format!("Subject: {mood_label}, {stress_label}. {tell} {read}") - } - } + format!("Subject: {mood_label}, {stress_label}. {tell} {read}") } // --------------------------------------------------------------------------- @@ -196,7 +175,6 @@ pub fn process_examine_interaction( Entity, &TilePosition, &ExamineRequest, - Option<&CharacterArchetype>, &mut ExamineResultBuffer, ), With, @@ -212,14 +190,13 @@ pub fn process_examine_interaction( >, examine_text_query: Query<(&TilePosition, Option<&ExamineText>)>, ) { - let Ok((player_entity, player_pos, examine_req, archetype_opt, mut result_buffer)) = + let Ok((player_entity, player_pos, examine_req, mut result_buffer)) = player_query.single_mut() else { return; }; let target = examine_req.target; - let archetype = archetype_opt.copied().unwrap_or_default(); // Try NPC examine path first if let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) { @@ -238,7 +215,7 @@ pub fn process_examine_interaction( let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral); let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0); - let text = generate_examine_text(mood, ratio, archetype, traits_opt); + let text = generate_examine_text(mood, ratio, traits_opt); kg_events.push(KnowledgeEvent { observer: player_entity, @@ -323,8 +300,8 @@ mod tests { } #[test] - fn smuggler_hostile_npc_gives_threat_read() { - let text = generate_examine_text(NpcMood::Hostile, 20, CharacterArchetype::Smuggler, None); + fn hostile_npc_gives_threat_read() { + let text = generate_examine_text(NpcMood::Hostile, 20, None); assert!( text.contains("Threat posture"), "expected threat read, got: {text}" @@ -332,32 +309,27 @@ mod tests { } #[test] - fn smuggler_focused_npc_notes_attention() { - let text = generate_examine_text(NpcMood::Focused, 30, CharacterArchetype::Smuggler, None); + fn focused_npc_notes_attention() { + let text = generate_examine_text(NpcMood::Focused, 30, None); assert!( - text.contains("close attention"), + text.contains("Paying close attention"), "expected attention note, got: {text}" ); } #[test] - fn smuggler_high_stress_identifies_distraction() { - let text = generate_examine_text(NpcMood::Anxious, 80, CharacterArchetype::Smuggler, None); + fn high_stress_identifies_pressure() { + let text = generate_examine_text(NpcMood::Anxious, 80, None); assert!( - text.contains("Too distracted"), - "expected distraction read, got: {text}" + text.contains("Under pressure"), + "expected pressure read, got: {text}" ); } #[test] - fn detective_deceptive_npc_notes_concealment() { + fn deceptive_npc_notes_concealment() { let t = traits(&[PersonalityTrait::Deceptive]); - let text = generate_examine_text( - NpcMood::Neutral, - 20, - CharacterArchetype::Detective, - Some(&t), - ); + let text = generate_examine_text(NpcMood::Neutral, 20, Some(&t)); assert!( text.contains("Controlled affect"), "expected concealment note, got: {text}" @@ -365,8 +337,8 @@ mod tests { } #[test] - fn detective_anxious_npc_notes_stress_markers() { - let text = generate_examine_text(NpcMood::Anxious, 50, CharacterArchetype::Detective, None); + fn anxious_npc_notes_stress_markers() { + let text = generate_examine_text(NpcMood::Anxious, 50, None); assert!( text.contains("stress markers"), "expected stress markers, got: {text}" @@ -374,8 +346,8 @@ mod tests { } #[test] - fn detective_content_npc_notes_low_guard() { - let text = generate_examine_text(NpcMood::Content, 10, CharacterArchetype::Detective, None); + fn content_npc_notes_low_guard() { + let text = generate_examine_text(NpcMood::Content, 10, None); assert!( text.contains("Less guarded"), "expected low guard note, got: {text}" diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index 35c81dd3b..427c19038 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -72,8 +72,6 @@ pub type EconomicModifier = String; pub type FactionModifier = String; /// Condition modifier on a zone palette (worn, pristine, damaged). Stub. pub type ConditionModifier = String; -/// Heritage root modifier (Settled Reach cultural grammar layer). Stub. -pub type HeritageRoot = String; /// Season modifier (affects palette and ambient conditions). Stub. pub type Season = String; /// Role slot within a social site template. Stub. @@ -366,7 +364,6 @@ pub enum PaletteModifier { Era(Era), FactionPresence(FactionModifier), Condition(ConditionModifier), - Heritage(HeritageRoot), Season(Season), } diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index e9680777d..b3417fcc9 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -131,8 +131,6 @@ pub struct MonologueState { pub entered: bool, /// IDs of lines already shown (dedup within session). pub shown_ids: BTreeSet, - /// Character type for pool filtering. Set from CharacterArchetype (#587). - pub character: String, /// Tick of the last observation event we reacted to (#119, observe_npc). /// Observation events arrive one tick after the snapshot that caused them, /// so we track which tick's events we've already processed. @@ -147,8 +145,6 @@ impl Default for MonologueState { idle_ticks: 0, entered: false, shown_ids: BTreeSet::new(), - // Default to detective; overridden by CharacterArchetype at spawn (#587) - character: "detective".to_string(), last_observation_tick: 0, } } diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index 196d0ac25..c19831ed1 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -95,7 +95,7 @@ pub const MAP_HEIGHT: i32 = 125; /// is intentional for deterministic test setups but should be revisited /// if Gauntlet is ever served by the production startup pipeline. #[cfg(feature = "gauntlet")] -pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterArchetype) { +pub fn setup_gauntlet(app: &mut App) { // Start with a fully blocked map, then carve rooms and corridors. let mut walkability = WalkabilityMap::new_blocked(MAP_WIDTH, MAP_HEIGHT, 1); @@ -190,12 +190,8 @@ pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterA // --- Player (StableId 0) --- // Spawn at Hub center: absolute (50, 58) - let profile = MovementProfile::smuggler(); + let profile = MovementProfile::default(); let player_pos = TilePosition::new(50, 58, 0); - let monologue_state = MonologueState { - character: archetype.as_monologue_key().to_string(), - ..Default::default() - }; let player = app .world_mut() .spawn(( @@ -204,7 +200,7 @@ pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterA Facing::default(), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), - monologue_state, + MonologueState::default(), MonologueBuffer::default(), SprintAnomalyQueue::default(), ScanEventBuffer::default(), @@ -216,7 +212,6 @@ pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterA crate::simulation::pressure::CharacterPressure::default(), )) .id(); - app.world_mut().entity_mut(player).insert(archetype); registry.register(player); // --- Hub signs (StableId 1-4) --- @@ -699,10 +694,7 @@ mod tests { app.add_plugins(crate::knowledge::KnowledgePlugin); app.add_plugins(crate::npc::NpcPlugin); - setup_gauntlet( - &mut app, - crate::bridge::types::CharacterArchetype::default(), - ); + setup_gauntlet(&mut app); // Run all 29 world-query invariants against the fully-initialized gauntlet world. invariants::run_invariants(app.world_mut()); @@ -722,10 +714,7 @@ mod tests { app.add_plugins(crate::knowledge::KnowledgePlugin); app.add_plugins(crate::npc::NpcPlugin); - setup_gauntlet( - &mut app, - crate::bridge::types::CharacterArchetype::default(), - ); + setup_gauntlet(&mut app); let wm = app.world().resource::(); // Hub center at (50, 58) must be walkable @@ -739,10 +728,7 @@ mod tests { app.add_plugins(crate::knowledge::KnowledgePlugin); app.add_plugins(crate::npc::NpcPlugin); - setup_gauntlet( - &mut app, - crate::bridge::types::CharacterArchetype::default(), - ); + setup_gauntlet(&mut app); let wm = app.world().resource::(); // North wall segment at absolute (90, 54) should be blocked @@ -758,10 +744,7 @@ mod tests { app.add_plugins(crate::knowledge::KnowledgePlugin); app.add_plugins(crate::npc::NpcPlugin); - setup_gauntlet( - &mut app, - crate::bridge::types::CharacterArchetype::default(), - ); + setup_gauntlet(&mut app); let wm = app.world().resource::(); // corridor-E center should be walkable @@ -775,10 +758,7 @@ mod tests { app.add_plugins(crate::knowledge::KnowledgePlugin); app.add_plugins(crate::npc::NpcPlugin); - setup_gauntlet( - &mut app, - crate::bridge::types::CharacterArchetype::default(), - ); + setup_gauntlet(&mut app); let registry = app.world().resource::(); diff --git a/server/tests/archetype_monologue.rs b/server/tests/archetype_monologue.rs deleted file mode 100644 index b1fb8669a..000000000 --- a/server/tests/archetype_monologue.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! Regression tests: character archetype flows end-to-end to MonologueState (#595, D-032). -//! -//! Verifies that when a session starts with a given CharacterArchetype, the -//! player entity's MonologueState.character reflects it correctly. This is the -//! guard against the default "detective" string leaking into smuggler sessions. -//! -//! Two complementary approaches: -//! 1. Unit-level: CharacterArchetype::as_monologue_key() mapping is correct. -//! 2. Integration (gauntlet): setup_gauntlet() correctly wires archetype → MonologueState. -//! -//! Spec refs: -//! D-032: character tag is a hard pool partition, not a filter — wrong character string -//! silently serves wrong content. -//! D-010: no player identity baked into game loop — archetype is a configuration. -//! #587: character_archetype added to StartupMessage; monologue key derived from it. -//! #595: MonologueState.character initialized from CharacterArchetype at session start. - -use settled_reach_server::bridge::types::CharacterArchetype; - -// --------------------------------------------------------------------------- -// Layer 1 — pure unit tests, no ECS -// --------------------------------------------------------------------------- - -#[test] -fn smuggler_archetype_maps_to_monologue_key() { - assert_eq!( - CharacterArchetype::Smuggler.as_monologue_key(), - "smuggler", - "Smuggler must produce the exact pool key 'smuggler' used in monologue YAML" - ); -} - -#[test] -fn detective_archetype_maps_to_monologue_key() { - assert_eq!( - CharacterArchetype::Detective.as_monologue_key(), - "detective", - "Detective must produce the exact pool key 'detective' used in monologue YAML" - ); -} - -#[test] -fn default_archetype_is_detective() { - // D-010: the safe fallback is Detective (the original single-character game). - // If serde default fires (old client, missing field), Detective must be chosen. - assert_eq!( - CharacterArchetype::default(), - CharacterArchetype::Detective, - "Default archetype must be Detective for backward compatibility (#587)" - ); -} - -#[test] -fn archetype_keys_are_distinct() { - // Sanity guard: the two keys must differ. If they were the same, pool partitioning - // (D-032) would be broken and both characters would see identical monologue lines. - assert_ne!( - CharacterArchetype::Smuggler.as_monologue_key(), - CharacterArchetype::Detective.as_monologue_key(), - "Smuggler and Detective monologue keys must be distinct (D-032 hard partition)" - ); -} - -// --------------------------------------------------------------------------- -// Layer 2 — integration: setup_gauntlet wires archetype → MonologueState -// --------------------------------------------------------------------------- - -#[cfg(feature = "gauntlet")] -mod gauntlet_integration { - use bevy_app::prelude::*; - use bevy_ecs::prelude::*; - use settled_reach_server::{ - bridge::types::CharacterArchetype, - simulation::{monologue::MonologueState, movement::PlayerCharacter, SimulationPlugin}, - test_world, - }; - - /// Build a minimal Gauntlet app with the given archetype and run one tick. - fn boot_gauntlet(archetype: CharacterArchetype) -> App { - let mut app = App::new(); - app.add_plugins(SimulationPlugin { seed: 0 }); - test_world::setup_gauntlet(&mut app, archetype); - app.update(); - app - } - - #[test] - fn smuggler_archetype_sets_monologue_character_to_smuggler() { - let mut app = boot_gauntlet(CharacterArchetype::Smuggler); - - let mut query = app - .world_mut() - .query_filtered::<&MonologueState, With>(); - let state = query - .single(app.world()) - .expect("player entity with MonologueState must exist after gauntlet setup"); - - assert_eq!( - state.character, "smuggler", - "Smuggler archetype must produce MonologueState.character = 'smuggler' (D-032, #587)" - ); - } - - #[test] - fn detective_archetype_sets_monologue_character_to_detective() { - let mut app = boot_gauntlet(CharacterArchetype::Detective); - - let mut query = app - .world_mut() - .query_filtered::<&MonologueState, With>(); - let state = query - .single(app.world()) - .expect("player entity with MonologueState must exist after gauntlet setup"); - - assert_eq!( - state.character, "detective", - "Detective archetype must produce MonologueState.character = 'detective' (D-032, #587)" - ); - } - - #[test] - fn smuggler_and_detective_produce_different_monologue_characters() { - // Regression guard: if both sessions return the same character string, D-032 - // partitioning is broken. This test catches copy-paste mistakes in setup paths. - let mut smuggler_app = boot_gauntlet(CharacterArchetype::Smuggler); - let mut detective_app = boot_gauntlet(CharacterArchetype::Detective); - - let smuggler_char = { - let mut q = smuggler_app - .world_mut() - .query_filtered::<&MonologueState, With>(); - q.single(smuggler_app.world()) - .expect("smuggler player must exist") - .character - .clone() - }; - - let detective_char = { - let mut q = detective_app - .world_mut() - .query_filtered::<&MonologueState, With>(); - q.single(detective_app.world()) - .expect("detective player must exist") - .character - .clone() - }; - - assert_ne!( - smuggler_char, detective_char, - "Smuggler and Detective sessions must have different MonologueState.character values \ - (D-032 hard partition: same key means both characters see each other's monologue pool)" - ); - } -} diff --git a/server/tests/environmental_interaction.rs b/server/tests/environmental_interaction.rs index 0ab87a443..637bf48bc 100644 --- a/server/tests/environmental_interaction.rs +++ b/server/tests/environmental_interaction.rs @@ -432,6 +432,7 @@ fn minimal_save() -> SaveStateV1 { contamination_active: false, activated_count: 0, last_activation_tick: None, + selected_bookmark: settled_reach_server::bookmark::SelectedBookmark::default(), } } diff --git a/server/tests/error_handling.rs b/server/tests/error_handling.rs index bf1086e40..8ebafd5d4 100644 --- a/server/tests/error_handling.rs +++ b/server/tests/error_handling.rs @@ -268,7 +268,6 @@ fn snapshot_with_sim_errors_roundtrips() { use settled_reach_server::simulation::time::{DayPhase, TickRate}; let snapshot = ObserverSnapshot { - version: PROTOCOL_VERSION, tick: 10, game_time: GameTime { day: 0, diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs index 61e903de2..2b2c08f03 100644 --- a/server/tests/game_loop.rs +++ b/server/tests/game_loop.rs @@ -68,7 +68,6 @@ fn player_moves_north_through_full_pipeline() { rmp_serde::from_slice(&response).expect("deserialize snapshot"); // Snapshot captures state at end of tick 0 (before advance_tick increments to 1) - assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!(snapshot.tick, 0); assert_eq!(snapshot.entities.len(), 1); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index f8079fff7..bbb33c75d 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -16,10 +16,9 @@ fn write_fixture(name: &str, bytes: &[u8]) { eprintln!("Wrote {} ({} bytes)", path.display(), bytes.len()); } -/// Helper to create a minimal v2 snapshot for fixtures +/// Helper to create a minimal snapshot for fixtures (D-192: no version field) fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { ObserverSnapshot { - version: PROTOCOL_VERSION, tick, game_time: GameTime { day: 0, @@ -181,7 +180,6 @@ fn generate_msgpack_fixtures() { // v2 snapshot with visible_tiles and game_time populated let snapshot_v2_full = ObserverSnapshot { - version: PROTOCOL_VERSION, tick: 500, game_time: GameTime { day: 1, @@ -302,7 +300,7 @@ fn generate_msgpack_fixtures() { // === #271 fixtures: named fixtures for cross-language Layer 1 testing === - // snapshot_minimal: version=PROTOCOL_VERSION, tick=0, one Player entity, all optionals absent + // snapshot_minimal: tick=0, one Player entity, all optionals absent let snapshot_minimal = fixture_snapshot( 0, vec![VisibleEntity { @@ -322,9 +320,8 @@ fn generate_msgpack_fixtures() { &rmp_serde::to_vec_named(&snapshot_minimal).unwrap(), ); - // snapshot_full: version=PROTOCOL_VERSION, tick=42, monologue + dialogue + inventory + POIs + KG dump + // snapshot_full: tick=42, monologue + dialogue + inventory + POIs + KG dump let snapshot_full = ObserverSnapshot { - version: PROTOCOL_VERSION, tick: 42, game_time: GameTime { day: 3, diff --git a/server/tests/gen_gauntlet_fixtures.rs b/server/tests/gen_gauntlet_fixtures.rs index aa6cb05d1..68f777380 100644 --- a/server/tests/gen_gauntlet_fixtures.rs +++ b/server/tests/gen_gauntlet_fixtures.rs @@ -45,10 +45,7 @@ fn build_gauntlet(seed: u64) -> App { app.add_plugins(NpcPlugin); app.insert_resource(SimRng::new(seed)); - test_world::setup_gauntlet( - &mut app, - settled_reach_server::bridge::types::CharacterArchetype::default(), - ); + test_world::setup_gauntlet(&mut app); app } diff --git a/server/tests/information_boundaries.rs b/server/tests/information_boundaries.rs index fb9325efc..5d52147f3 100644 --- a/server/tests/information_boundaries.rs +++ b/server/tests/information_boundaries.rs @@ -214,6 +214,7 @@ fn save_state_npc_kg_isolation() { contamination_active: false, activated_count: 0, last_activation_tick: None, + selected_bookmark: settled_reach_server::bookmark::SelectedBookmark::default(), }; // Roundtrip: serialize → deserialize. diff --git a/server/tests/layer3.rs b/server/tests/layer3.rs index dc7832caa..c528752fa 100644 --- a/server/tests/layer3.rs +++ b/server/tests/layer3.rs @@ -76,19 +76,12 @@ fn server_subprocess_sends_snapshot_on_connect() { let handshake_frame = read_framed(&mut reader) .expect("read handshake frame") .expect("server closed connection before sending handshake"); - let handshake: HandshakeMessage = + // D-192: HandshakeMessage carries no version field. Verify it deserialises cleanly. + let _handshake: HandshakeMessage = rmp_serde::from_slice(&handshake_frame).expect("deserialize HandshakeMessage"); - assert_eq!( - handshake.protocol_version, PROTOCOL_VERSION, - "handshake protocol_version mismatch: got {}, expected {}", - handshake.protocol_version, PROTOCOL_VERSION - ); // 5. Send StartupMessage with world_seed (#175) - let startup = StartupMessage { - world_seed: 42, - character_archetype: settled_reach_server::bridge::types::CharacterArchetype::default(), - }; + let startup = StartupMessage { world_seed: 42 }; let startup_payload = rmp_serde::to_vec_named(&startup).expect("serialize StartupMessage"); write_framed(&mut writer, &startup_payload).expect("send StartupMessage to server"); @@ -108,11 +101,6 @@ fn server_subprocess_sends_snapshot_on_connect() { rmp_serde::from_slice(&response).expect("deserialize ObserverSnapshot"); // 8. Assert protocol correctness (D-020) - assert_eq!( - snapshot.version, PROTOCOL_VERSION, - "protocol version mismatch: got {}, expected {}", - snapshot.version, PROTOCOL_VERSION - ); assert!( snapshot.entities.len() > 0, "snapshot should contain at least one entity (the player), got 0" diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index b783c5d2d..4bf12ac2e 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -4,10 +4,9 @@ use settled_reach_server::bridge::types::*; use settled_reach_server::simulation::time::{DayPhase, TickRate}; use std::fs; -/// Helper to create a minimal v2 snapshot for tests +/// Helper to create a minimal snapshot for tests (D-192: no version field) fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { ObserverSnapshot { - version: PROTOCOL_VERSION, tick, game_time: GameTime { day: 0, @@ -65,7 +64,6 @@ fn observer_snapshot_roundtrip() { let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!(decoded.version, PROTOCOL_VERSION); assert_eq!(decoded.tick, 42); assert_eq!(decoded.entities.len(), 1); assert_eq!(decoded.entities[0].entity_id, 1); @@ -171,18 +169,13 @@ fn all_fixtures_deserialize() { let bytes = fs::read(&path).unwrap_or_else(|_| panic!("read fixture {}", name)); if name.starts_with("snapshot_boundary") { - // Boundary snapshot fixtures (#472): tick may exceed PROTOCOL_VERSION check + // Boundary snapshot fixtures (#472) rmp_serde::from_slice::(&bytes).unwrap_or_else(|e| { panic!("deserialize boundary snapshot fixture {}: {}", name, e) }); } else if name.starts_with("snapshot") { - let snap = rmp_serde::from_slice::(&bytes) + rmp_serde::from_slice::(&bytes) .unwrap_or_else(|e| panic!("deserialize snapshot fixture {}: {}", name, e)); - assert_eq!( - snap.version, PROTOCOL_VERSION, - "fixture {} has wrong version", - name - ); } else if name.starts_with("input_batch") { rmp_serde::from_slice::>(&bytes) .unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e)); @@ -244,7 +237,6 @@ fn all_entity_kind_variants_roundtrip() { #[test] fn snapshot_v2_fields_roundtrip() { let snapshot = ObserverSnapshot { - version: PROTOCOL_VERSION, tick: 100, game_time: GameTime { day: 3, @@ -311,7 +303,6 @@ fn snapshot_v2_fields_roundtrip() { let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!(decoded.version, PROTOCOL_VERSION); assert_eq!(decoded.game_time.day, 3); assert_eq!(decoded.game_time.time_of_day, 720); assert_eq!(decoded.game_time.day_phase, DayPhase::Evening); @@ -354,17 +345,6 @@ fn entity_to_bits_roundtrip() { } } -/// PROTOCOL_VERSION constant matches snapshot version field -#[test] -fn protocol_version_constant_matches_snapshot() { - let snapshot = test_snapshot(0, vec![]); - assert_eq!(snapshot.version, PROTOCOL_VERSION); - assert_eq!( - PROTOCOL_VERSION, 21, - "bump this assertion when protocol version changes" - ); -} - /// All FacingDirection variants round-trip #[test] fn all_facing_direction_variants_roundtrip() { @@ -381,7 +361,6 @@ fn all_facing_direction_variants_roundtrip() { for dir in directions { let snapshot = ObserverSnapshot { - version: PROTOCOL_VERSION, tick: 0, game_time: GameTime { day: 0, @@ -525,7 +504,6 @@ fn v5_payload_deserializes_into_v6_struct() { .expect("v5 payload should deserialize into v6 struct via serde(default)"); // New fields should get their defaults - assert_eq!(decoded.version, 5, "version field preserved from v5"); assert_eq!(decoded.tick, 42); assert_eq!( decoded.player_stance, @@ -717,23 +695,6 @@ fn verb_kind_confront_roundtrip() { assert_eq!(decoded.nearby_interactions[0].verbs[0].label, "Confront"); } -/// CharacterArchetype enum round-trips through MessagePack (#422). -/// Used in Phase 2 label relabeling — must survive the wire. -#[test] -fn all_character_archetype_variants_roundtrip() { - let archetypes = [CharacterArchetype::Smuggler, CharacterArchetype::Detective]; - - for archetype in archetypes { - let bytes = rmp_serde::to_vec_named(&archetype).expect("serialize"); - let decoded: CharacterArchetype = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!( - decoded, archetype, - "CharacterArchetype::{:?} roundtrip failed", - archetype - ); - } -} - /// NearbyInteraction.contradicted=true round-trips through MessagePack (#422). /// Guards the contradiction flag survives serialization. #[test] @@ -1247,7 +1208,6 @@ fn v8_payload_deserializes_into_v9_struct() { let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes) .expect("v8 payload should deserialize into v9 struct via serde(default)"); - assert_eq!(decoded.version, 8, "version field preserved from v8"); assert_eq!(decoded.tick, 100); assert!( decoded.blocked_entities.is_empty(), @@ -1321,7 +1281,6 @@ fn v9_payload_deserializes_into_v10_struct() { let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes) .expect("v9 payload should deserialize into v10 struct via serde(default)"); - assert_eq!(decoded.version, 9, "version field preserved from v9"); assert_eq!(decoded.tick, 200); assert_eq!( decoded.rng_seed, None, @@ -1397,7 +1356,6 @@ fn v10_payload_deserializes_into_v11_struct() { let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes) .expect("v10 payload should deserialize into v11 struct via serde(default)"); - assert_eq!(decoded.version, 10, "version field preserved from v10"); assert_eq!(decoded.tick, 300); assert_eq!(decoded.visible_tiles.len(), 1); assert_eq!( @@ -1458,8 +1416,6 @@ fn serde_default_fields_fill_in_when_missing_from_wire() { let decoded: ObserverSnapshot = serde_json::from_value(minimal_json).expect("minimal JSON must deserialize"); - // Version matches what was in the wire - assert_eq!(decoded.version, 13); assert_eq!(decoded.tick, 42); assert_eq!(decoded.entities.len(), 1); @@ -1492,28 +1448,6 @@ fn serde_default_fields_fill_in_when_missing_from_wire() { ); } -/// A snapshot with version != PROTOCOL_VERSION can be detected by checking -/// the version field after deserialization (#232 compatibility checking). -#[test] -fn snapshot_version_mismatch_is_detectable() { - let mut snapshot = test_snapshot(0, vec![]); - let future_version: u8 = PROTOCOL_VERSION + 1; - snapshot.version = future_version; - - let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); - let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); - - // The version field faithfully preserves the value — caller detects mismatch - assert_eq!( - decoded.version, future_version, - "version field must survive round-trip unchanged" - ); - assert_ne!( - decoded.version, PROTOCOL_VERSION, - "client should detect this as a version mismatch" - ); -} - /// tell_state=None is skipped in msgpack serialization (skip_serializing_if). /// A snapshot with tell_state=None produces fewer bytes than one with /// tell_state=Some(Nervous) — demonstrates the skip_serializing_if contract. @@ -1627,19 +1561,6 @@ fn all_verb_kind_variants_roundtrip_v232() { } } -/// PROTOCOL_VERSION u8 type fits in one byte — wire overhead is minimal (#232). -/// This guards against accidental widening of the version type. -#[test] -fn protocol_version_fits_in_u8() { - // u8 max is 255 — enough for ~242 more protocol iterations. - // If PROTOCOL_VERSION ever reaches 200, consider migrating to u16. - assert!( - PROTOCOL_VERSION <= 200, - "PROTOCOL_VERSION={} is approaching u8 saturation; consider widening the type", - PROTOCOL_VERSION - ); -} - /// NearbyInteraction.object_type round-trips through MessagePack (#422). /// Verifies object_type=Some(Container) survives the wire. #[test] @@ -1687,7 +1608,6 @@ fn fixture_snapshot_minimal_fields() { let snap: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize snapshot_minimal"); - assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch"); assert_eq!(snap.tick, 0, "tick should be 0"); assert_eq!(snap.entities.len(), 1, "should have exactly 1 entity"); assert_eq!(snap.entities[0].entity_id, 1); @@ -1707,7 +1627,6 @@ fn fixture_snapshot_full_fields() { let bytes = read_named_fixture("snapshot_full"); let snap: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize snapshot_full"); - assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch"); assert_eq!(snap.tick, 42, "tick should be 42"); // Monologue diff --git a/server/tests/tell_escalation.rs b/server/tests/tell_escalation.rs index a30f75555..e83c3111e 100644 --- a/server/tests/tell_escalation.rs +++ b/server/tests/tell_escalation.rs @@ -108,12 +108,10 @@ fn no_deviation_component_does_not_produce_deviation_tell() { /// Set up a minimal gauntlet-based app with storyteller plugin running. #[cfg(feature = "gauntlet")] fn build_storyteller_app() -> App { - use settled_reach_server::{ - bridge::types::CharacterArchetype, simulation::SimulationPlugin, test_world, - }; + use settled_reach_server::{simulation::SimulationPlugin, test_world}; let mut app = App::new(); app.add_plugins(SimulationPlugin { seed: 0 }); - test_world::setup_gauntlet(&mut app, CharacterArchetype::default()); + test_world::setup_gauntlet(&mut app); app } diff --git a/server/tests/v01_integration_playthrough.rs b/server/tests/v01_integration_playthrough.rs deleted file mode 100644 index 2856a8292..000000000 --- a/server/tests/v01_integration_playthrough.rs +++ /dev/null @@ -1,328 +0,0 @@ -//! v0.1 integration playthrough test (#593, D-027). -//! -//! Validates the full session lifecycle from StartupMessage to storyteller activation: -//! D-027 criterion 1: player sees opening monologue on session start -//! D-027 criterion 4: NPC RoutineDeviation tell observable after triangle activation -//! D-036: news ticker headline visible in The Last Shift zone -//! -//! Test structure: -//! - `test_smuggler_opening_monologue`: asserts smuggler pool fires on tick 1 (runs now) -//! - `test_detective_opening_monologue`: asserts detective pool fires on tick 1 (runs now) -//! - `test_v0_1_integration_playthrough`: full E2E proof (#[ignore] until #589, #591 land) -//! -//! Uses Layer 3 pattern: real server subprocess, TCP IPC, no mocks. -//! -//! Prerequisites to unblock: -//! #589: escalate_tells_on_activation system (for RoutineDeviation assertion) -//! #591: TickerPool + current_ticker in snapshot (for ticker assertion) - -use settled_reach_server::bridge::framing::{read_framed, write_framed}; -use settled_reach_server::bridge::types::*; -use settled_reach_server::npc::tell_state::TellCategory; -use std::io::{BufRead, BufReader, BufWriter}; -use std::net::TcpStream; -use std::process::{Command, Stdio}; -use std::time::{Duration, Instant}; - -/// Timeout for the server to emit LISTENING:{port} on stdout. -const LISTEN_TIMEOUT: Duration = Duration::from_secs(15); -/// Timeout for any individual snapshot read. -const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(15); - -// --------------------------------------------------------------------------- -// Server lifecycle helpers -// --------------------------------------------------------------------------- - -struct TestServer { - child: std::process::Child, - reader: BufReader, - writer: BufWriter, -} - -impl TestServer { - /// Boot the server binary in test mode (gauntlet), send StartupMessage, - /// return a connected handle ready to receive snapshots. - fn boot_gauntlet(world_seed: u64, archetype: CharacterArchetype) -> Self { - let server_bin = env!("CARGO_BIN_EXE_settled-reach-server"); - let mut child = Command::new(server_bin) - .args(["--test-mode", "--port", "0"]) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("failed to spawn server binary"); - - let stdout = child.stdout.take().expect("stdout not captured"); - let mut stdout_reader = BufReader::new(stdout); - - // Parse LISTENING:{port} - let port = { - let deadline = Instant::now() + LISTEN_TIMEOUT; - let mut line = String::new(); - loop { - line.clear(); - match stdout_reader.read_line(&mut line) { - Ok(0) => panic!("server stdout closed before LISTENING signal"), - Ok(_) => { - let trimmed = line.trim(); - if let Some(port_str) = trimmed.strip_prefix("LISTENING:") { - break port_str.parse::().expect("invalid port"); - } - } - Err(e) => panic!("failed to read server stdout: {}", e), - } - assert!( - Instant::now() < deadline, - "timed out waiting for LISTENING signal" - ); - } - }; - - let addr = format!("127.0.0.1:{}", port); - let stream = TcpStream::connect(&addr).expect("client connect"); - stream - .set_read_timeout(Some(SNAPSHOT_TIMEOUT)) - .expect("set timeout"); - let mut reader = BufReader::new(stream.try_clone().expect("clone stream")); - let mut writer = BufWriter::new(stream); - - // Protocol handshake - let hf = read_framed(&mut reader) - .expect("read handshake") - .expect("connection closed"); - let _: HandshakeMessage = rmp_serde::from_slice(&hf).expect("deserialize handshake"); - - // StartupMessage with chosen archetype - let startup = StartupMessage { - world_seed, - character_archetype: archetype, - }; - let startup_payload = rmp_serde::to_vec_named(&startup).expect("serialize startup"); - write_framed(&mut writer, &startup_payload).expect("send startup"); - - TestServer { - child, - reader, - writer, - } - } - - /// Send a tick's worth of inputs (empty = idle tick) and read back one snapshot. - fn tick(&mut self, inputs: Vec) -> ObserverSnapshot { - let payload = rmp_serde::to_vec_named(&inputs).expect("serialize inputs"); - write_framed(&mut self.writer, &payload).expect("send inputs"); - - let frame = read_framed(&mut self.reader) - .expect("read snapshot frame") - .expect("server closed connection"); - rmp_serde::from_slice(&frame).expect("deserialize snapshot") - } - - /// Send a debug command and get the next snapshot. - fn send_debug(&mut self, cmd: DebugCommandKind) -> ObserverSnapshot { - self.tick(vec![PlayerInput { - tick: 0, - action: PlayerAction::DebugCommand(cmd), - }]) - } - - fn shutdown(mut self) { - drop(self.reader); - drop(self.writer); - let deadline = Instant::now() + Duration::from_secs(5); - loop { - match self.child.try_wait() { - Ok(Some(_)) => break, - Ok(None) => { - if Instant::now() > deadline { - self.child.kill().ok(); - self.child.wait().ok(); - break; - } - std::thread::sleep(Duration::from_millis(50)); - } - Err(_) => { - self.child.kill().ok(); - break; - } - } - } - } -} - -// --------------------------------------------------------------------------- -// Tests: opening monologue archetype partitioning (runs now — no #[ignore]) -// --------------------------------------------------------------------------- - -#[test] -fn test_smuggler_opening_monologue() { - // Boot with Smuggler, advance 1 tick, assert opening monologue fires from smuggler pool. - // Monologue IDs from smuggler/opening.yaml start with "pc-smuggler_". - // This verifies: archetype → MonologueState.character → pool selection (D-032, #587, #595). - let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler); - let snapshot = server.tick(vec![PlayerInput { - tick: 0, - action: PlayerAction::MoveNorth, - }]); - - assert_eq!( - snapshot.version, PROTOCOL_VERSION, - "protocol version mismatch" - ); - - let monologue = snapshot.current_monologue; - assert!( - monologue.is_some(), - "Smuggler session must fire opening monologue on tick 1 (enter_location trigger, D-027 criterion 1). \ - Got None — either MonologueState.character is wrong or opening.yaml lines are not loaded." - ); - - let monologue = monologue.unwrap(); - assert!( - monologue.id.starts_with("pc-smuggler_"), - "Smuggler opening monologue ID must start with 'pc-smuggler_' (D-032 hard partition). \ - Got id='{}'. Likely cause: MonologueState.character defaulted to 'detective' despite Smuggler archetype.", - monologue.id - ); - - server.shutdown(); -} - -#[test] -fn test_detective_opening_monologue() { - // Boot with Detective, advance 1 tick, assert opening monologue fires from detective pool. - // Monologue IDs from detective/opening.yaml start with "pc-detective_". - let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Detective); - let snapshot = server.tick(vec![PlayerInput { - tick: 0, - action: PlayerAction::MoveNorth, - }]); - - assert_eq!( - snapshot.version, PROTOCOL_VERSION, - "protocol version mismatch" - ); - - let monologue = snapshot.current_monologue; - assert!( - monologue.is_some(), - "Detective session must fire opening monologue on tick 1 (enter_location trigger). \ - Got None — either MonologueState.character is wrong or opening.yaml lines are not loaded." - ); - - let monologue = monologue.unwrap(); - assert!( - monologue.id.starts_with("pc-detective_"), - "Detective opening monologue ID must start with 'pc-detective_' (D-032 hard partition). \ - Got id='{}'. Likely cause: archetype defaulted incorrectly.", - monologue.id - ); - - server.shutdown(); -} - -#[test] -fn test_smuggler_and_detective_get_different_opening_monologue_ids() { - // Regression guard: two sessions with different archetypes must never produce - // the same monologue ID on tick 1. If they do, D-032 partitioning is broken. - let mut smug = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler); - let smug_snap = smug.tick(vec![]); - let smug_id = smug_snap - .current_monologue - .as_ref() - .map(|m| m.id.clone()) - .unwrap_or_default(); - smug.shutdown(); - - let mut det = TestServer::boot_gauntlet(12345, CharacterArchetype::Detective); - let det_snap = det.tick(vec![]); - let det_id = det_snap - .current_monologue - .as_ref() - .map(|m| m.id.clone()) - .unwrap_or_default(); - det.shutdown(); - - assert_ne!( - smug_id, det_id, - "Smuggler and Detective must fire different opening monologue IDs (D-032). \ - Both got '{}' — pool partitioning is broken.", - smug_id - ); -} - -// --------------------------------------------------------------------------- -// Full v0.1 playthrough proof (blocked until #589 + #591 land) -// --------------------------------------------------------------------------- - -#[test] -#[ignore = "blocked: TeleportToLocation debug command not implemented (needs location tile_bounds from ContentStore). Criteria 1+2 covered by non-ignored tests above."] -fn test_v0_1_integration_playthrough() { - // Full E2E proof per D-027 v0.1 success criteria: - // 1. Opening monologue fires in correct character pool - // 2. After activation, anchor NPC shows RoutineDeviation tell - // 3. News ticker visible when player is in "bar" zone - // (Manual criterion: walk to terminal, observe Kael, see fog-and-tension) - - let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler); - - // === Criterion 1: Opening monologue (Smuggler) === - let tick1 = server.tick(vec![]); - let monologue = tick1 - .current_monologue - .expect("Opening monologue must fire on tick 1"); - assert!( - monologue.id.starts_with("pc-smuggler_"), - "Tick-1 monologue must be from smuggler pool. Got: {}", - monologue.id - ); - - // === Skip to contamination phase (fast-forward via debug) === - let _skip_snap = server.send_debug(DebugCommandKind::SkipToContamination); - let _contaminate = server.send_debug(DebugCommandKind::ForceContaminationActivate); - - // === Run ticks and watch for triangle activation === - let mut triangle_crisis_observed = false; - for _ in 0..20 { - let snap = server.tick(vec![]); - if !snap.triangle_crisis_events.is_empty() { - triangle_crisis_observed = true; - break; - } - } - assert!( - triangle_crisis_observed, - "Triangle crisis event must appear within 20 ticks after contamination activation (#589)" - ); - - // === Criterion 2 (D-027 criterion 4): RoutineDeviation tell visible === - // After activation, at least one NPC must show RoutineDeviation tell in the snapshot. - let mut deviation_observed = false; - for _ in 0..5 { - let snap = server.tick(vec![]); - if snap - .entities - .iter() - .any(|e| e.tell_state == Some(TellCategory::RoutineDeviation)) - { - deviation_observed = true; - break; - } - } - assert!( - deviation_observed, - "After triangle activation, at least one NPC must show RoutineDeviation tell (D-027 criterion 4, #589)" - ); - - // === Criterion 3 (D-036): News ticker visible in bar zone === - // Teleport to The Last Shift bar zone and check current_ticker is Some. - let _teleport = server.send_debug(DebugCommandKind::TeleportToLocation( - "the-last-shift".into(), - )); - let bar_snap = server.tick(vec![]); - assert!( - bar_snap.current_ticker.is_some(), - "current_ticker must be Some when player is in 'the-last-shift' zone (D-036, #591)" - ); - - server.shutdown(); -}