From 16a79c928c0e8c860631231225ca45759c212dd0 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 02:27:39 +0100 Subject: [PATCH] feat(content): Tier 1 drama module schema and smuggling ring stub (#158) JSON Schema for drama modules covering entry conditions, NPC requirements, event sequences, and outcomes. Includes v0.1 vertical slice stub module and authoring guide with review feedback from Mellanie (pattern/motivation reference, terminal outcome semantics, fact ID conventions, trigger type bridge). Co-Authored-By: Claude Opus 4.6 --- .../modules/tier1/smuggling_ring_v0_1.yaml | 655 ++++++++++++++++ content/schemas/drama_module.schema.yaml | 718 ++++++++++++++++++ docs/design/tier1-module-authoring.md | 450 +++++++++++ 3 files changed, 1823 insertions(+) create mode 100644 content/modules/tier1/smuggling_ring_v0_1.yaml create mode 100644 content/schemas/drama_module.schema.yaml create mode 100644 docs/design/tier1-module-authoring.md diff --git a/content/modules/tier1/smuggling_ring_v0_1.yaml b/content/modules/tier1/smuggling_ring_v0_1.yaml new file mode 100644 index 000000000..64526e8cb --- /dev/null +++ b/content/modules/tier1/smuggling_ring_v0_1.yaml @@ -0,0 +1,655 @@ +# 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" + 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: > + Observer 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" + events_fired: + - secret_meeting_occurred + 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: + - secret_meeting_occurred + - voss_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: + events_fired: + - final_shipment_known + flags_not_set: + - kael_message_sent # If this is NOT set, Kael's cooperation went unnoticed + ticks_since_activation: 3600 # Module ran for ~60 minutes without full exposure + 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" + events_fired: + - kael_goes_cold + flags_not_set: + - kael_message_sent + ticks_since_activation: 2400 + 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. MODULE EXPIRY (quiet exit) + # Player never engaged. 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/content/schemas/drama_module.schema.yaml b/content/schemas/drama_module.schema.yaml new file mode 100644 index 000000000..02fb039d5 --- /dev/null +++ b/content/schemas/drama_module.schema.yaml @@ -0,0 +1,718 @@ +# Drama Module Schema — Tier 1 Content (D-023) +# YAML expression of JSON Schema 2020-12 +# Validated against this schema: 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/docs/design/tier1-module-authoring.md b/docs/design/tier1-module-authoring.md new file mode 100644 index 000000000..3606139f3 --- /dev/null +++ b/docs/design/tier1-module-authoring.md @@ -0,0 +1,450 @@ +# Tier 1 Drama Module — Authoring Guide + +**Schema:** `content/schemas/drama_module.schema.yaml` +**Module pool:** `content/modules/tier1/*.yaml` +**Decisions:** D-023 (three-tier model), D-027 (vertical slice), D-029 (30/50/20 population), D-034 (FRIEND pattern) +**Vertical slice reference:** `content/modules/tier1/smuggling_ring_v0_1.yaml` + +--- + +## What Is a Tier 1 Drama Module? + +Tier 1 is the authored conspiracy layer of D-023. Drama modules are the things that can go wrong — or go very right, or simply happen — beneath the surface of daily life in Sova Transit. They are: + +- **Hand-authored.** Every event sequence, every NPC role, every outcome was written by a person. +- **Pool-based.** Multiple modules exist. The storyteller draws from the pool at game start and activates a subset based on the district and the storyteller's pacing decisions. +- **Optional from the player's perspective.** The player can play 60 minutes without engaging the ring. The ring happens anyway. D-027 criterion #4: the observe→notice→follow→discover sequence must emerge from *systems*, not *scripts*. +- **Dual-lens.** Every module must be experienced differently by the smuggler and detective characters. Same world, different keyholes. + +What they are **not:** +- Not quests with markers or objectives. +- Not scripted cutscenes. +- Not balanced challenge encounters. + +The storyteller uses the module as a *schedule* — a series of world events it will fire, and conditions it monitors to determine how the world resolves. The player is a witness and agent in a world that moves with or without them. + +--- + +## File Structure + +``` +content/ + schemas/ + drama_module.schema.yaml ← Schema reference (this file validates against it) + modules/ + tier1/ + smuggling_ring_v0_1.yaml ← The v0.1 vertical slice module + future_module_v0_1.yaml ← Future modules go here +``` + +One `.yaml` file per drama module. The storyteller's content loader scans `content/modules/tier1/` at startup and adds all valid modules to the pool. + +--- + +## Field Reference + +### Identity Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `module_id` | Yes | Stable slug: `{name}_v{major}_{minor}`. Never reuse. Increment on breaking structural change. | +| `display_name` | Yes | Human-readable title for dev tooling. Not shown in-game. | +| `version` | Yes | Authoring version: `{major}.{minor}`. | +| `tier` | Yes | Always `1`. | +| `description` | No | One-paragraph design summary. Authoring-only. | +| `notes` | No | Design rationale, cross-references. Ignored at load time. | +| `dual_lens` | No | How smuggler vs detective experience this module. Authoring-only. **Write this first** — it disciplines the design. | + +--- + +### Pool Metadata + +Controls how the storyteller samples this module. + +| Field | Required | Description | +|-------|----------|-------------| +| `pool.weight` | Yes | Selection probability 1–10. Higher = more likely per playthrough. Default 5. | +| `pool.compatible_districts` | No | District slugs. Omit for "any". | +| `pool.incompatible_with` | No | Module IDs that can't run concurrently. | +| `pool.max_concurrent` | No | Almost always 1. | + +**Design note on weight:** Use weight to tune narrative variety, not difficulty. A weight-1 module is a rare playthrough surprise. A weight-8 module like the smuggling ring is "this is usually what's happening in Sova Transit." + +--- + +### Entry Conditions + +Defines when the module becomes eligible for activation. ALL world-state conditions must be true. The activation trigger determines *how* it fires. + +#### World-State Condition Types + +| Type | Required Fields | Use When | +|------|----------------|----------| +| `npc_present` | `role` | The module requires a specific NPC to be in the district. | +| `location_accessible` | `location` | The module requires a location the player can physically reach. | +| `fact_not_known` | `fact_id` | Module shouldn't activate if a precondition has already been discovered. | +| `no_active_module` | `module_id` | Prevents two incompatible modules running at once. | +| `fact_known` | `fact_id`, `known_by` | Module requires prior knowledge to make sense. | + +#### Player Conditions (Optional) + +Player conditions are *optional* — modules can and should activate without player engagement as a prerequisite. Use player conditions sparingly, only when the module literally cannot function without a minimum relationship state. + +#### Activation Triggers + +| Trigger | When to Use | +|---------|-------------| +| `storyteller_push` | Default. Storyteller activates on its own pacing. Most Tier 1 modules. | +| `proximity` | Module activates when player wanders near a key location. Useful for "stumble-upon" conspiracies. | +| `player_action` | Reserved for modules that require player initiation. Use rarely. | + +**The `min_play_ticks` field is load-bearing for D-027 criterion #1.** At approximately 1 tick/second, 30 minutes of play ≈ 1800 ticks. Set `min_play_ticks` to at least 1800. The vertical slice uses 2100 to give extra breathing room. + +--- + +### NPC Requirements + +Each module specifies its NPC slots. Roles are internal slugs used throughout the rest of the document. + +| Field | Required | Description | +|-------|----------|-------------| +| `role` | Yes | Module-internal slug. Kebab-case. Used in event triggers and outcome conditions. | +| `display_hint` | No | Authoring note: who this role is narratively. | +| `binding` | Yes | `named` (specific authored NPC) or `generated` (any matching NPC). | +| `named_npc` | Conditional | Required when `binding: named`. Short-form canonical ID: `npc:{slug}`. | +| `axes` | Conditional | Required when `binding: generated`. Axis constraints the NPC must satisfy. | +| `must_have_pattern` | No | Optional NPC pattern (D-024 System A). | +| `must_have_motivation` | No | Optional NPC motivation (D-024 System B). | +| `is_optional` | No | Default false. If true, module runs without this slot filled (degraded experience). | + +#### Named vs. Generated Bindings + +**Named bindings** reference specific hand-authored NPCs from the district. All v0.1 roles are named. This is the right choice for: +- THE FRIEND NPCs (D-034) — they have authored arcs, not generic behavior +- NPCs with unique relationships in the 5-triangle web +- Roles where voice, history, and moral weight matter + +**Generated bindings** are for future modules set in different districts or using procedurally generated NPCs. They use axis constraints: + +```yaml +axes: + - axis: secret + constraint: has_major_secret + - axis: contentment + constraint: min_contentment_-3 # Discontented, susceptible to opportunity +``` + +Constraint conventions: `has_{value}`, `min_{N}`, `not_{value}`. The server's NPC filter system interprets these. + +#### What "Roles" Are Not + +NPC roles in a drama module are **not** the same as NPC patterns (FRIEND, MIRROR, etc.) or motivations (HANDLER, WITNESS, etc.). Module roles are: +- Functional slots within the module's narrative (ring-leader, witness, evidence-holder) +- Module-local: "ring-leader" in the smuggling ring module ≠ "ring-leader" in any other module +- Used to reference the same NPC across events and outcomes without hardcoding the NPC slug + +#### NPC Pattern and Motivation Reference + +Patterns (System A, `must_have_pattern`) encode the NPC's thematic function in the player's experience: + +| Pattern | What It Means | +|---------|---------------| +| `FRIEND` | Emotionally complex anchor; the contradiction arc lives here (D-034) | +| `MIRROR` | Reflects the player character's own path back at them | +| `ANCHOR` | Reliable presence; stability the player can always return to | +| `GHOST` | Presence felt more than seen; past hangs over current events | +| `CATALYST` | Actions cause cascading effects on other NPCs | +| `THRESHOLD` | Gatekeeper; controls access to deeper information or relationships | +| `REMNANT` | Survivor of a prior event; carries knowledge others want buried | +| `SYSTEM` | Embodies an institution or faction rather than personal stakes | +| `NOBODY` | Genuinely flat; texture and atmosphere, no arc | + +Motivations (System B, `must_have_motivation`) encode why the NPC acts within the module's conspiracy: + +| Motivation | What It Means | +|------------|---------------| +| `HANDLER` | Organizes or directs others; the operational center | +| `WITNESS` | Knows something they haven't decided to act on | +| `TURNCOAT` | Wants out, or has already switched allegiance | +| `CIVILIAN` | No conspiracy involvement; proximity creates moral weight | +| `OPERATOR` | Executes tasks; functional cog in the system | +| `SKEPTIC` | Doubts the conspiracy exists; useful foil for investigation | + +**Full definitions and canonical usage:** `decisions/content.md` D-024. + +--- + +### Events + +Events are world-state changes the storyteller fires. They are not scripted player experiences — they happen in the world, and the player may or may not observe them. + +#### Sequences vs. Pools + +| Structure | Use For | +|-----------|---------| +| **Sequence** | Ordered narrative beats. Step N+1 becomes eligible only after step N fires. Use for character arcs. | +| **Pool** | Unordered ambient activity. The storyteller fires any eligible event at any time. Use for texture and background. | + +The vertical slice uses: +- `kael_exit_arc` (sequence) — Kael's ordered character arc +- `investigation_pressure` (sequence) — Parallel pressure escalation +- `ambient_ring_activity` (pool) — Background ring business that runs throughout + +Most modules should have 1-2 sequences plus 1 pool. + +#### Event Step Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `event_id` | Yes | Unique within module. Used in outcome conditions and `ticks_since_event` triggers. | +| `label` | No | Short human-readable label for dev tooling. | +| `description` | No | What happens narratively. Write this first — events should have a clear observable presence. | +| `triggers` | Yes | ANY trigger being true fires the event. Multiple triggers = OR logic. | +| `effects` | No | What changes in the world. | +| `once` | No | Default `true`. Set `false` for repeating events (ambient discrepancies, etc.). | +| `sets_flag` | No | Module-internal flag set when event fires. Used in outcome conditions. | + +#### Trigger Types + +| Type | Fires When | Key Fields | +|------|-----------|------------| +| `ticks_since_activation` | N ticks after module activated | `ticks` | +| `ticks_since_event` | N ticks after a previous event fired | `after_event`, `ticks` | +| `player_proximity` | Player near NPC/location | `target_type`, `target`, `radius_tiles` | +| `player_action` | Player interacts with target | `action`, `target_role` | +| `fact_known_by_player` | Player has discovered a fact | `fact_id` | +| `flag_set` | A module flag has been set | `flag` | +| `npc_mood` | NPC enters a mood state | `npc_role`, `mood` | + +**Design principle: events should fire without the player.** Every event must have at least one tick-based trigger (`ticks_since_activation` or `ticks_since_event`). Proximity and action triggers are secondary paths that fire the event *earlier* if the player engages. The world moves at its own pace; the player accelerates or delays, not controls. + +#### Effect Types + +| Type | Use For | +|------|---------| +| `npc_routine_deviation` | Visible NPC behavior change. Write this descriptively — it's what the player sees. | +| `fact_becomes_discoverable` | Gates a fact into the knowledge graph at Rumoured confidence. | +| `tell_intensify` | NPC's tell behavior becomes more frequent/pronounced. | +| `flag_set` | Internal state tracking. Not visible to player. | +| `location_state` | Something visible changes in a location. | +| `npc_knowledge_update` | An NPC learns something new. | + +**On `fact_becomes_discoverable`:** This makes a fact discoverable, not known. The player still has to find it — through proximity, examination, dialogue, or observation. The `discovery_method` field is an authoring note for how: be specific enough that a Mellanie can write the dialogue or monologue that surfaces it, and a Gestalt can define the trigger condition in the fact catalog. + +**Fact ID convention:** Use `{module-slug}.{fact_name}` — e.g., `ring.kael_unauthorized_corridor_access`. The module slug prefix namespaces the fact to avoid collisions across modules. Before creating a new fact ID, check `content/global/knowledge/` to see if an equivalent fact already exists; reuse it rather than creating a duplicate. + +**Mapping `discovery_method` to D-035 trigger types:** The `discovery_method` note should describe exactly how the player triggers fact discovery. This maps directly to the D-035 monologue trigger taxonomy (full list in `decisions/content.md` D-035 and `content/global/enums/triggers.yaml`): + +| If discovery happens via… | D-035 trigger type | What to author | +|--------------------------|-------------------|----------------| +| Player enters the location where something is visible | `enter_location` | Monologue line flagging the anomaly on arrival | +| Player watches an NPC doing something unusual | `observe_npc` | Monologue line on NPC observation; dialogue option unlocks | +| Player examines an object or terminal | `observe_anomaly` | Examine verb interaction; monologue on result | +| Player witnesses two NPCs interacting | `witness_interaction` | Monologue line; trust-gated gossip unlock | +| Player finishes a conversation with the relevant NPC | `post_conversation` | Monologue beat after talking to the NPC | +| Player discovers a physical object (cargo, message) | `discover_evidence` | Examine verb; monologue on discovery | +| Player returns to a location they've been before | `return_visit` | Monologue on changed state vs. prior visit | + +Write the `discovery_method` note to specify which of these applies — ideally two methods for redundancy (e.g., `enter_location` plus `observe_anomaly`) so players aren't funneled into a single approach. + +--- + +### Outcomes + +Outcomes are resolution states. The storyteller checks all outcome conditions each tick after the module activates. The first matching outcome is applied. + +**Every module must include:** +- At least one terminal outcome that represents "the investigation succeeded" +- At least one terminal outcome that represents "the conspiracy ran its course" +- Exactly one expiry outcome (`is_expiry: true`) for quiet player non-engagement + +#### Outcome Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `outcome_id` | Yes | Unique slug. | +| `label` | Yes | Short label. | +| `is_terminal` | Yes | `true` = module ends. `false` = transitional state (module can continue evolving). | +| `is_expiry` | No | `true` = this is the quiet-exit outcome. One per module. | +| `conditions` | No | ALL conditions must be true. See below. | +| `effects` | No | World changes when outcome is reached. | + +**On `is_terminal: false`:** A non-terminal outcome fires its effects and applies its label, but the module remains active — the storyteller keeps checking for the next matching outcome. Use this for intermediate states where the world has visibly shifted but the situation hasn't resolved: the `ring_splinters` outcome in the vertical slice is non-terminal because the ring going quiet is a change of state, not a conclusion. A module with only non-terminal outcomes will run forever; always ensure there is a reachable terminal outcome (or expiry) downstream. + +#### Outcome Conditions + +| Condition | Description | +|-----------|-------------| +| `facts_known` | Player must know all listed facts. | +| `facts_not_known` | Player must NOT know any listed facts. | +| `flags_set` | All listed module flags must be set. | +| `flags_not_set` | None of listed flags may be set. | +| `events_fired` | All listed events must have fired. | +| `ticks_since_activation` | Module has been running for at least N ticks. | + +#### Outcome Effects + +| Type | Description | +|------|-------------| +| `npc_disposition` | NPC's relationship state with player shifts. | +| `faction_reaction` | Faction reputation change. | +| `location_access_change` | Location becomes restricted, locked, or open. | +| `fact_state` | Fact is permanently known, hidden, or destroyed. | +| `npc_exit` | NPC leaves the district or becomes inaccessible. | + +--- + +## Design Principles for Tier 1 Modules + +### 1. The World Moves First + +Events happen on a tick schedule. The player is a witness who can accelerate, delay, or redirect — not a trigger. If your module can only function if the player takes specific actions, it's a quest, not a drama module. + +### 2. Both Characters Must Have a Story + +Every event and outcome must mean something different to the smuggler and the detective. Write the `dual_lens` authoring field first. If you can't write both lenses, the module is character-agnostic filler — not Tier 1. + +### 3. No Clean Resolutions + +D-034 and D-027 both require moral ambiguity. The smuggling ring doesn't have a "good" ending. The detective arresting Kael is not obviously better than letting him go. Every outcome must have a cost. If one outcome is obviously correct, you've failed the design. + +### 4. THE FRIEND Contradiction Is the Pivot + +If your module involves a FRIEND-pattern NPC, the observable contradiction (D-034) must be: +- **Observable from spatial positioning** — not from dialogue, not from menus +- **Ambiguous before context** — the player sees the behavior before they understand what it means +- **Irreversible once witnessed** — seeing changes the relationship, even if the player does nothing + +The secret meeting in corridor B-7 is the canonical example. After witnessing it, neither character can pretend they don't know what they saw. + +### 5. Expiry Is Not Failure + +The `module_abandoned` expiry outcome should feel like a natural ending, not a penalty. The world closes around this conspiracy without the player. That's the 70% mundane reality (D-029): most conspiracies don't get protagonists. Write the expiry description to feel melancholy but not punitive. + +### 6. Facts, Not Flags, Drive Investigation + +Facts (from `global/knowledge/`) are the player's knowledge graph. Flags are the storyteller's internal state tracking. The key design question: "Is this something the player knows, or is this something the storyteller tracks?" If the player knows it, it's a fact. If the storyteller tracks it, it's a flag. + +Facts should be discoverable through multiple methods (observation, dialogue, examination, proximity). Never require a single specific action to surface a critical fact. + +--- + +## Validation and Format Rules (Gestalt) + +These rules cover the schema's format constraints and the validation gaps that JSON Schema cannot enforce. All of these are also caught by Tier 2 build-time validation (`make validate-content`), but catching them during authoring saves a pipeline run. + +### ID and Slug Formats + +| Field | Regex | Example | +|-------|-------|---------| +| `module_id` | `^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$` | `smuggling_ring_v0_1` | +| `sequence_id`, `pool_id` | `^[a-z][a-z0-9_-]*$` | `kael_exit_arc` | +| `event_id` | `^[a-z][a-z0-9_-]*$` | `kael_goes_cold` | +| `outcome_id` | `^[a-z][a-z0-9_-]*$` | `ring_exposed` | +| `sets_flag` / flag references | `^[a-z][a-z0-9_-]*$` | `kael_behavior_changed` | +| `role` (npc slot) | `^[a-z][a-z0-9-]*$` | `ring-member-exiting` | +| `named_npc` | `^npc:[a-z][a-z0-9-]*$` | `npc:kael-davan` | +| `version` | `^[0-9]+\\.[0-9]+$` | `0.1` | + +Note the difference: `event_id`, `outcome_id`, `sequence_id`, and flags use underscores and hyphens (`[a-z0-9_-]*`). NPC `role` slugs use hyphens only (`[a-z0-9-]*`). Mixing them in wrong fields will fail schema validation. + +### Flag Naming Convention + +Flags are module-internal state. Every flag name that appears in `sets_flag` on an event **must** also appear in at least one outcome's `flags_set` or `flags_not_set` condition — or the flag serves no purpose. Convention: + +- Use `snake_case` with underscores: `kael_behavior_changed`, `voss_pressure_applied` +- Name by what happened, not what it enables: `handler_pressure_applied` not `kael_ready_to_flee` +- Flags set by events accumulate — they are never automatically cleared +- A flag set by a time-triggered event (not player-triggered) cannot be used as an expiry gate (see "Common Mistakes" below) + +### Axis Constraint Syntax (Generated NPC Bindings) + +The `constraint` field in `axes` is a freeform string. The storyteller's NPC filter interprets it. Convention (author responsibility — schema does not enforce): + +| Prefix | Example | Meaning | +|--------|---------|---------| +| `has_` | `has_major_secret` | NPC axis value includes this descriptor | +| `min_contentment_` | `min_contentment_-3` | Contentment axis value ≤ N (more discontented) | +| `not_` | `not_combat_trained` | Axis value does NOT include this descriptor | +| `is_` | `is_ring_member` | Boolean flag set on NPC profile | + +### What JSON Schema Cannot Validate (Tier 2 Catches These) + +| Issue | Where to Look | Impact | +|-------|--------------|--------| +| `fact_id` not defined in `global/knowledge/` | Effect `fact_becomes_discoverable`, outcome `facts_known` | Fact silently never becomes discoverable | +| `sets_flag` name not referenced in any outcome condition | Event `sets_flag` | Flag is set but never meaningful | +| `flags_set`/`flags_not_set` reference flag never set by any event | Outcome conditions | Condition permanently true or false | +| `ticks_since_event.after_event` references unknown event_id | Event trigger | Trigger never fires | +| `named_npc` ID doesn't exist in district NPC profiles | NPC requirements | Load-time failure | +| Multiple outcomes have `is_expiry: true` | Outcomes list | Undefined storyteller behavior | +| `faction` in outcome effects not in `global/factions/` | Outcome effects | Effect silently ignored | + +### The Expiry Condition Pitfall + +This is the most common authoring mistake for expiry outcomes. **The expiry condition must use `facts_not_known`, not `flags_not_set`.** Reason: + +Events with `ticks_since_activation` triggers fire automatically without player engagement. If an auto-firing event sets a flag, and your expiry checks `flags_not_set: [that_flag]`, the expiry condition becomes permanently false after the event fires — the module can never expire quietly. + +**Wrong:** +```yaml +# kael_goes_cold fires automatically at tick 300, sets kael_behavior_changed +# This expiry can never fire after tick 300 +- outcome_id: module_abandoned + is_expiry: true + conditions: + flags_not_set: + - kael_behavior_changed # This flag is always set by tick 300 + ticks_since_activation: 5400 +``` + +**Correct:** +```yaml +# facts_not_known gates on player investigative action, not auto-fired events +- outcome_id: module_abandoned + is_expiry: true + conditions: + facts_not_known: + - "ring.cargo_discrepancy_pattern" # Only known if player examined terminal + - "ring.kael_unauthorized_corridor_access" # Only known if player observed Kael + ticks_since_activation: 5400 +``` + +--- + +## Checklist Before Submitting a New Module + +- [ ] `module_id` uses correct format and doesn't collide with existing modules +- [ ] `dual_lens` is written and shows clearly different experiences per character +- [ ] `min_play_ticks` ≥ 1800 (30 minutes at 1 tick/second) +- [ ] Every event sequence step has at least one tick-based trigger +- [ ] Every `fact_becomes_discoverable` effect has a `discovery_method` note +- [ ] The module includes at least one named FRIEND-pattern NPC (for v0.1 modules) +- [ ] Expiry outcome is present (`is_expiry: true`) with conditions gated on `facts_not_known`, NOT `flags_not_set` +- [ ] All outcomes have been reviewed for moral ambiguity — no "obviously correct" resolution +- [ ] `npc_requirements` covers every role referenced in events and outcomes +- [ ] All fact IDs used in effects/conditions exist in `global/knowledge/` +- [ ] All `sets_flag` names appear in at least one outcome condition +- [ ] All `flags_set`/`flags_not_set` names are set by at least one event's `sets_flag` +- [ ] `make validate-content` passes + +--- + +## Cross-References + +| Topic | Location | +|-------|----------| +| Three-tier content model | `decisions/content.md` D-023 | +| NPC 10-axis model | `decisions/content.md` D-024 | +| Vertical slice scope | `decisions/scope.md` D-027 | +| Population ratios | `decisions/content.md` D-029 | +| THE FRIEND pattern | `decisions/content.md` D-034 | +| Smuggling ring module | `content/modules/tier1/smuggling_ring_v0_1.yaml` | +| Drama module schema | `content/schemas/drama_module.schema.yaml` | +| Fact catalog | `content/global/knowledge/` | +| NPC profiles (v0.1) | `content/campaigns/main/systems/krenn/` | +| Storyteller stub | `server/src/storyteller/mod.rs` | + +--- + +*Ticket #158 — Tier 1 drama module schema. Paula (dramatic structure), Gestalt (schema format), Mellanie (authoring review).*