Merge remote-tracking branch 'origin/copy'

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
2026-02-25 09:59:20 +01:00
9 changed files with 2100 additions and 7 deletions
+5
View File
@@ -12,6 +12,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- Dialogue UI hardening — confrontation italic voice (D-063), examine result overlay with 5s auto-dismiss and confidence coloring (#174)
- Knowledge/journal panel — right-side insert panel (J key), facts grouped by entity, contradicted entries in amber with strikethrough, stale entries dimmed, mutual exclusion with dialogue (#264)
- Sprint 18 client test suite — 50 gdUnit4 tests for dialogue (D-062, D-063, D-064) and journal (KG parsing, scene structure, UIStrings), plus test plan document
- D-084: dual-namespace line ID scheme for auto-generated NPCs — role pool (shared, unchanged) + instance override (opt-in, seeded counter). Resolves Q-028 (#544)
- Tier 1 drama module schema (`content/schemas/drama_module.schema.yaml`) — entry conditions, NPC requirements, event sequences, outcomes, pool format (#158)
- Smuggling ring v0.1 stub module (`content/modules/tier1/smuggling_ring_v0_1.yaml`) — vertical slice Tier 1 module with 6 NPC roles, dual event sequences, 5 outcomes (#158)
- Line ID authoring guide (`docs/design/line-id-authoring-guide.md`) — dual-namespace conventions for hand-authored and auto-generated NPC content
- Tier 1 module authoring guide (`docs/design/tier1-module-authoring.md`) — field reference, NPC pattern/motivation tables, design principles, pre-submission checklist
- Sprint 18: Touch planned — 14 tickets (server 9, client 3, copy 2) covering examine mechanic, NPC awareness, social propagation, minimap, dialogue UI, save state model
- `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services)
- KnowledgeGrant untagged enum with Fact and Entity variants, ContentEntityRegistry for NPC spawn-time entity resolution (D-079, #545)
+106
View File
@@ -0,0 +1,106 @@
# Fact catalog: ring
# Smuggling ring module event trail — observable evidence produced by module events.
# These facts are made discoverable by specific events in smuggling_ring_v0_1.yaml.
# Stub entries — description and confidence level confirmed; progression text TBD.
# Ticket: #158 | Sprint 18
facts:
- fact_id: ring.cargo_discrepancy_pattern
description: Cargo manifests at The Terminal show small, systematic discrepancies — weight variances, unverified entries, containers with no return log
typical_confidence: suspects
characters: [smuggler, detective]
notes: >
Detective's analytical lattice may flag this automatically during Terminal walkthrough.
Smuggler encounters it during operational paperwork checks. Pattern becomes clearer over
multiple observations (once: false event — repeats). Starting entry point for both
investigation arcs.
- fact_id: ring.kael_missed_verification
description: Kael Davan was absent from a scheduled cargo verification — another dock worker covered without explanation
typical_confidence: suspects
characters: [smuggler, detective]
notes: >
Discoverable via cargo manifest review (Terminal office) or analytical lattice flag
(detective). Smuggler notices the irregularity in their own paperwork. First concrete
evidence that Kael is deviating from his normal operational reliability.
- fact_id: ring.kael_unauthorized_corridor_access
description: Kael was observed in restricted maintenance corridor B-7 during off-shift hours with an unknown contact
typical_confidence: knows_of
characters: [smuggler, detective]
notes: >
Core FRIEND contradiction observable (D-034). Player must be in visual range of B-7
to discover this, OR examine the corridor door access log (investigative action).
Both characters can discover this fact — interpreted differently (smuggler: security
breach / detective: suspicious contact). Upgrades from suspects to knows_of when
contact identity confirmed, knows_details when purpose confirmed.
- fact_id: ring.kael_unusual_meridian_activity
description: Encrypted Meridian packets sent from cargo bay terminals — frequent, patterned, not from personal devices
typical_confidence: suspects
characters: [detective]
notes: >
Detective-only. Requires analytical lattice to flag the outgoing packet pattern.
Player must be in or adjacent to The Terminal cargo bay. Content not accessible —
only the existence and frequency of the encrypted messages. Hints at Kael's
off-district contact without revealing who.
- fact_id: ring.voss_kael_private_meeting
description: Voss called Kael into the supervisor's office — closed door, raised voices audible from adjacent position
typical_confidence: suspects
characters: [smuggler, detective]
notes: >
Observable via spatial positioning (supervisor's office area). Smuggler can witness
the approach and departure without hearing content. Detective observes Kael's visible
strain immediately after if present in The Terminal. Upgrade to knows_of via
trust-gated dialogue with Kael ("Are you alright?" option unlocks post-meeting).
- fact_id: ring.voss_accelerating_timeline
description: Voss is pushing to close ring operations faster than normal — someone is watching
typical_confidence: knows_of
characters: [smuggler, detective]
notes: >
Smuggler: Voss mentions "the schedule moving up" in guarded conversation (trust-gated,
ring-insider access required). Detective: observe Voss and Nils in two exchanges within
the same shift and note behavioral change. Not directly stated — inferred from context.
- fact_id: ring.commission_cargo_flag
description: A Commission internal note flags The Terminal's cargo variance rate as statistically unusual
typical_confidence: knows_of
characters: [detective]
notes: >
Detective-only. Accessible via institutional Commission query (authority access tier,
Terminal records). Not a formal investigation — just an internal flag from Maret Korr.
Confirms the detective's suspicions with institutional weight. Smuggler has no way to
know this exists.
- fact_id: ring.final_shipment_scheduled
description: The ring has scheduled a major final drop — after it clears, operations go dark
typical_confidence: knows_of
characters: [smuggler, detective]
notes: >
Smuggler: direct notification from Voss (ring-insider access). Detective: cargo manifest
shows an unusually large entry scheduled 3 days out — no obvious legitimate reason for
the volume. This fact creates the closing window — both characters know time is running
out, for different reasons.
- fact_id: ring.sera_avoidance_behavior
description: Sera Venn takes longer routes through The Last Shift to avoid standing near Torek Lintar
typical_confidence: suspects
characters: [detective]
notes: >
Detective-only observable. Player must observe Sera's movement pattern on two separate
occasions — requires forward vision cone and awareness of her baseline route. The
avoidance is visible but not explained. Points toward Sera holding information about
Torek's investigation without having acted on it.
- fact_id: ring.nils_unlogged_cargo
description: Nils makes regular supply runs to maintenance corridor C-7 with containers that never appear in return logs
typical_confidence: suspects
characters: [smuggler, detective]
notes: >
Discoverable by watching Nils's cargo handling pattern over two shifts, or by examining
the maintenance corridor C-7 access log directly. The missing return entry is the tell —
the container went in but never came back. Both characters can find this; smuggler may
already suspect given operational context.
@@ -0,0 +1,692 @@
# 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."
+718
View File
@@ -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 (110). Higher = more likely to be
included in a given playthrough's active module set. Default: 5.
compatible_districts:
type: array
items:
type: string
description: >
District slugs where this module can activate, or omit for "any".
Example: ["sova-transit"]
incompatible_with:
type: array
items:
type: string
pattern: "^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$"
description: >
Module IDs that cannot run concurrently with this one.
The storyteller will not activate both in the same playthrough.
max_concurrent:
type: integer
minimum: 1
default: 1
description: >
Maximum simultaneous active instances. Almost always 1.
Set to 2+ only for modules designed to stack (rare).
# ── ENTRY CONDITIONS ──────────────────────────────────────────────────────
# All listed conditions must be true for the module to become activatable.
# The storyteller checks these each tick after min_play_ticks.
entry_conditions:
type: object
required:
- activation
additionalProperties: false
description: >
World-state prerequisites. The storyteller activates the module when
ALL conditions are satisfied AND the activation trigger fires.
properties:
world_state:
type: array
items:
$ref: "#/$defs/world_state_condition"
description: "World-state conditions checked each tick."
player:
type: array
items:
$ref: "#/$defs/player_condition"
description: >
Optional player-state conditions. Module can activate without
player engagement — these gate on player-specific world state,
not on player noticing the module.
activation:
type: object
required:
- trigger
additionalProperties: false
description: "How and when activation is evaluated."
properties:
trigger:
type: string
enum:
- proximity # Player comes within range of a key NPC/location
- storyteller_push # Storyteller activates on its own schedule
- player_action # Player performs a specific action
description: "What pushes the module from 'eligible' to 'active'."
min_play_ticks:
type: integer
minimum: 0
description: >
Minimum ticks of game time before this module can activate.
Enforces D-027 success criterion #1: 30 minutes of daily-life
breathing room. At 1 tick/second, 30 minutes ≈ 1800 ticks.
proximity_location:
type: string
description: >
Required when trigger = proximity. Location slug the player
must enter or approach. Example: "maintenance-corridors"
proximity_radius_tiles:
type: integer
minimum: 1
description: >
Required when trigger = proximity. Tile radius around the
location's anchor point.
player_action_required:
type: string
description: >
Required when trigger = player_action. The action that fires
activation. Example: "examine:cargo-manifest"
# ── NPC REQUIREMENTS ──────────────────────────────────────────────────────
# NPC slots this module requires. Each slot is filled at module load time.
# Named bindings resolve to specific authored NPCs; generated bindings
# are filled from the district's generated NPC pool.
npc_requirements:
type: array
minItems: 1
items:
$ref: "#/$defs/npc_slot"
description: >
Module-internal NPC role slots. Roles are referenced by slug throughout
the rest of this document. Hand-authored NPCs use named bindings.
Generated NPCs use constraint-based bindings.
# ── EVENTS ────────────────────────────────────────────────────────────────
# Ordered sequences and unordered event pools the storyteller can fire.
# Sequences are narrative beats in a defined order.
# Pools are events the storyteller can fire in any order when conditions are met.
events:
type: object
additionalProperties: false
description: "Event sequences and pools the storyteller manages."
properties:
sequences:
type: array
items:
$ref: "#/$defs/event_sequence"
description: >
Ordered event sequences. Steps fire in order; the next step
becomes eligible only after the previous one fires.
pools:
type: array
items:
$ref: "#/$defs/event_pool"
description: >
Unordered event pools. The storyteller may fire any eligible
event in the pool when its trigger conditions are met.
# ── OUTCOMES ──────────────────────────────────────────────────────────────
# Resolution states the module can reach. The storyteller checks outcome
# conditions each tick. First matching outcome wins.
# Every module MUST include an expiry outcome.
outcomes:
type: array
minItems: 1
items:
$ref: "#/$defs/outcome"
description: >
Terminal and transitional resolution states. The storyteller checks
these each tick and applies the first matching outcome.
# ── AUTHORING NOTES ───────────────────────────────────────────────────────
notes:
type: string
description: "Authoring-only field. Design rationale, cross-references. Ignored at load time."
dual_lens:
type: object
additionalProperties: false
description: "Authoring-only. How smuggler vs detective experience this module."
properties:
smuggler: { type: string }
detective: { type: string }
# ── SHARED DEFINITIONS ────────────────────────────────────────────────────────
$defs:
# World-state condition types
world_state_condition:
type: object
required:
- type
description: "A single world-state prerequisite for module activation."
oneOf:
- # NPC with the given module role is present in the district
properties:
type: { type: string, const: "npc_present" }
role: { type: string, description: "Module-internal NPC role slug." }
required: [type, role]
additionalProperties: false
- # A specific location is accessible to the player
properties:
type: { type: string, const: "location_accessible" }
location: { type: string, description: "Location slug." }
required: [type, location]
additionalProperties: false
- # Player has NOT yet discovered a specific fact
properties:
type: { type: string, const: "fact_not_known" }
fact_id: { type: string, description: "Fact ID from global/knowledge/." }
required: [type, fact_id]
additionalProperties: false
- # No other Tier 1 module of the given ID is currently active
properties:
type: { type: string, const: "no_active_module" }
module_id: { type: string }
required: [type, module_id]
additionalProperties: false
- # A named fact IS known (module requires precondition awareness)
properties:
type: { type: string, const: "fact_known" }
fact_id: { type: string }
known_by: { type: string, enum: [smuggler, detective, any] }
required: [type, fact_id]
additionalProperties: false
# Player-state condition types
player_condition:
type: object
required:
- type
description: "A player-state prerequisite."
oneOf:
- # Player has reached minimum relationship threshold with an NPC
properties:
type: { type: string, const: "relationship_threshold" }
npc_role: { type: string, description: "Module-internal NPC role." }
min_state:
type: string
enum: [stranger, known, friendly]
description: "Minimum RelationshipState required."
required: [type, npc_role, min_state]
additionalProperties: false
- # Minimum game ticks elapsed
properties:
type: { type: string, const: "min_ticks" }
ticks: { type: integer, minimum: 0 }
required: [type, ticks]
additionalProperties: false
# NPC slot definition
npc_slot:
type: object
required:
- role
- binding
additionalProperties: false
description: >
One NPC slot in the module. Named binding = specific authored NPC.
Generated binding = constraint-matched NPC from district pool.
properties:
role:
type: string
pattern: "^[a-z][a-z0-9-]*$"
description: >
Module-internal role slug. Referenced in events, outcomes, and
triggers. Example: "ring-leader", "ring-member-exiting", "witness"
display_hint:
type: string
description: "Authoring note. What this role is narratively."
binding:
type: string
enum: [named, generated]
description: >
named = resolves to a specific authored NPC (use named_npc).
generated = any district NPC matching the axis constraints.
named_npc:
type: string
pattern: "^npc:[a-z][a-z0-9-]*$"
description: >
Required when binding = named. Short-form NPC canonical ID.
Example: "npc:kael-davan"
axes:
type: array
items:
$ref: "#/$defs/axis_constraint"
description: >
Required when binding = generated. The NPC must satisfy all
listed axis constraints to fill this slot.
must_have_pattern:
type: string
enum: [FRIEND, MIRROR, ANCHOR, GHOST, CATALYST, THRESHOLD, REMNANT, SYSTEM, NOBODY]
description: "Optional: NPC must have this pattern (D-024)."
must_have_motivation:
type: string
enum: [HANDLER, WITNESS, TURNCOAT, CIVILIAN, OPERATOR, SKEPTIC]
description: "Optional: NPC must have this motivation (D-024)."
is_optional:
type: boolean
default: false
description: >
If true, the module can activate without this slot filled.
Optional slots produce degraded but valid module runs.
# NPC axis constraint (used in generated bindings)
axis_constraint:
type: object
required:
- axis
- constraint
additionalProperties: false
properties:
axis:
type: string
enum: [want, secret, relationships, tolerance, routine, information, contentment, personality, tells, skills]
description: "Which NPC axis to constrain (D-024)."
constraint:
type: string
description: >
Constraint expression. Freeform string interpreted by the storyteller.
Convention: "has_{value}", "min_{N}", "not_{value}".
Examples: "has_major_secret", "min_contentment_-3", "not_combat_trained"
# Event sequence
event_sequence:
type: object
required:
- sequence_id
- steps
additionalProperties: false
description: "An ordered sequence of narrative events."
properties:
sequence_id:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
label:
type: string
description:
type: string
steps:
type: array
minItems: 1
items:
$ref: "#/$defs/event_step"
# Unordered event pool
event_pool:
type: object
required:
- pool_id
- events
additionalProperties: false
properties:
pool_id:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
label:
type: string
description:
type: string
events:
type: array
minItems: 1
items:
$ref: "#/$defs/event_step"
# Individual event step
event_step:
type: object
required:
- event_id
- triggers
additionalProperties: false
description: "A single storyteller-managed event with triggers and effects."
properties:
event_id:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
description: "Unique within this module. Used in outcome conditions."
label:
type: string
description:
type: string
description: "What happens narratively when this event fires."
triggers:
type: array
minItems: 1
items:
$ref: "#/$defs/event_trigger"
description: "ANY trigger being true fires this event."
effects:
type: array
items:
$ref: "#/$defs/event_effect"
description: "What changes in the world when this event fires."
once:
type: boolean
default: true
description: "If true, fires only once. If false, may repeat when conditions reset."
sets_flag:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
description: "Module-internal flag set when this event fires. Queryable in outcomes."
# Event trigger conditions
event_trigger:
type: object
required:
- type
description: "A condition that causes an event to fire."
oneOf:
- # Ticks elapsed since module activation
properties:
type: { type: string, const: "ticks_since_activation" }
ticks: { type: integer, minimum: 1 }
required: [type, ticks]
additionalProperties: false
- # Ticks elapsed since a previous event fired
properties:
type: { type: string, const: "ticks_since_event" }
after_event: { type: string }
ticks: { type: integer, minimum: 1 }
required: [type, after_event, ticks]
additionalProperties: false
- # Player enters a location or comes within range of NPC
properties:
type: { type: string, const: "player_proximity" }
target_type: { type: string, enum: [location, npc_role] }
target: { type: string }
radius_tiles: { type: integer, minimum: 1 }
required: [type, target_type, target]
additionalProperties: false
- # Player performs an interaction
properties:
type: { type: string, const: "player_action" }
action:
type: string
enum: [talk, examine, confront, follow, observe]
target_role: { type: string, description: "Module NPC role or location slug." }
required: [type, action, target_role]
additionalProperties: false
- # Player has discovered a specific fact
properties:
type: { type: string, const: "fact_known_by_player" }
fact_id: { type: string }
required: [type, fact_id]
additionalProperties: false
- # A module flag has been set
properties:
type: { type: string, const: "flag_set" }
flag: { type: string }
required: [type, flag]
additionalProperties: false
- # NPC enters a specific mood state
properties:
type: { type: string, const: "npc_mood" }
npc_role: { type: string }
mood:
type: string
enum: [anxious, frustrated, content, suspicious, warm, hostile, relieved, focused]
required: [type, npc_role, mood]
additionalProperties: false
# Event effects
event_effect:
type: object
required:
- type
description: "A world change triggered by an event."
oneOf:
- # NPC deviates from their normal routine
properties:
type: { type: string, const: "npc_routine_deviation" }
npc_role: { type: string }
description: { type: string, description: "What the deviation looks like." }
duration_ticks: { type: integer }
required: [type, npc_role, description]
additionalProperties: false
- # A fact becomes discoverable (moves to Rumoured confidence)
properties:
type: { type: string, const: "fact_becomes_discoverable" }
fact_id: { type: string }
discoverable_by:
type: string
enum: [smuggler, detective, any]
discovery_method:
type: string
description: "How the player can discover this. Authoring note."
required: [type, fact_id, discoverable_by]
additionalProperties: false
- # NPC tell behavior becomes more pronounced
properties:
type: { type: string, const: "tell_intensify" }
npc_role: { type: string }
description: { type: string }
required: [type, npc_role]
additionalProperties: false
- # A module-internal flag is set
properties:
type: { type: string, const: "flag_set" }
flag: { type: string, pattern: "^[a-z][a-z0-9_-]*$" }
required: [type, flag]
additionalProperties: false
- # Something changes about a location
properties:
type: { type: string, const: "location_state" }
location: { type: string }
description: { type: string }
required: [type, location, description]
additionalProperties: false
- # NPC's access to information changes
properties:
type: { type: string, const: "npc_knowledge_update" }
npc_role: { type: string }
fact_id: { type: string }
description: { type: string }
required: [type, npc_role, fact_id]
additionalProperties: false
# Module outcome definition
outcome:
type: object
required:
- outcome_id
- label
- is_terminal
additionalProperties: false
description: >
A resolution state the module can reach. Conditions are checked each tick.
The first matching outcome is applied. is_terminal = true ends the module.
properties:
outcome_id:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
label:
type: string
description:
type: string
description: "What this outcome means narratively."
is_terminal:
type: boolean
description: "If true, this outcome ends the module permanently."
is_expiry:
type: boolean
default: false
description: >
If true, this is the quiet-exit outcome when the player never engages.
Every module must include exactly one expiry outcome.
conditions:
type: object
additionalProperties: false
description: "ALL conditions must be true to reach this outcome."
properties:
facts_known:
type: array
items: { type: string }
description: "Player must know all these facts."
facts_not_known:
type: array
items: { type: string }
description: "Player must NOT know any of these facts."
flags_set:
type: array
items: { type: string }
description: "All these module flags must be set."
flags_not_set:
type: array
items: { type: string }
description: "None of these module flags may be set."
events_fired:
type: array
items: { type: string }
description: "All these events must have fired."
ticks_since_activation:
type: integer
description: "Module has been active for at least this many ticks."
effects:
type: array
items:
$ref: "#/$defs/outcome_effect"
description: "Effects applied when this outcome is reached."
# Outcome-level effects (broader scope than event effects)
outcome_effect:
type: object
required:
- type
oneOf:
- # NPC disposition toward player changes
properties:
type: { type: string, const: "npc_disposition" }
npc_role: { type: string }
shift:
type: string
enum: [hostile, suspicious, neutral, friendly]
description: { type: string }
required: [type, npc_role, shift]
additionalProperties: false
- # Faction reaction
properties:
type: { type: string, const: "faction_reaction" }
faction: { type: string }
reaction:
type: string
enum: [hostile, suspicious, neutral, friendly, grateful]
description: { type: string }
required: [type, faction, reaction]
additionalProperties: false
- # Location becomes restricted or opens up
properties:
type: { type: string, const: "location_access_change" }
location: { type: string }
change:
type: string
enum: [restricted, locked, open]
description: { type: string }
required: [type, location, change]
additionalProperties: false
- # A fact is now permanently known/unknown
properties:
type: { type: string, const: "fact_state" }
fact_id: { type: string }
state:
type: string
enum: [known, hidden, destroyed]
description: { type: string }
required: [type, fact_id, state]
additionalProperties: false
- # NPC leaves the district or changes role
properties:
type: { type: string, const: "npc_exit" }
npc_role: { type: string }
description: { type: string }
required: [type, npc_role]
additionalProperties: false
+2 -2
View File
@@ -12,10 +12,10 @@ Cross-domain decisions live in one file with cross-reference notes in related fi
|------|--------|-----------|
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073 |
| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072, D-076, D-077, D-078 |
| [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074 |
| [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074, D-075, D-084 |
| [scope.md](scope.md) | Game concept, prototype | D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065 |
| [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 |
| [questions.md](questions.md) | Open questions | Q-001 through Q-026 |
| [questions.md](questions.md) | Open questions | Q-001 through Q-028 |
| [rejected.md](rejected.md) | Rejected alternatives | R-001 through R-010 |
## Querying Decisions
+23 -1
View File
@@ -182,6 +182,28 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
- **Raised by:** Tyre (technical analysis, architecture synthesis)
- **Dissent:** Gestalt endorses D-075 (reviewed 2026-02-19). The emergent archetype distinction is sufficient: access tier tags already encode "authority figure lines" vs "insider lines" in content; starting knowledge differentials produce different dialogue gate timings per character; adding an archetype filter would create per-character content maintenance burden and dilute the "two keyholes on the same world" experience (D-027). Knowledge vocabulary doc (#368) confirms this works in practice — same fact IDs, different starting confidence levels, different gate-open timing per character. *Nigel's input still pending.*
### D-084: Dual-namespace line ID scheme — role pool + instance override
- **Date:** 2026-02-25
- **Decision:** Line IDs for auto-generated NPC content use a dual-namespace approach that eliminates the Q-028 collision problem without altering the existing ID format.
- **Role namespace (unchanged, primary):** `{role-slug}_{d|m|e}_{###}` — e.g., `dock-worker_d_001`. These are shared lines delivered by any instance of the role. One file, one ID sequence per role-at-location. No per-instance authoring, no collision possible. All existing authored content is unaffected.
- **Instance namespace (new, opt-in):** `{role-slug}-{zero-padded counter}_{d|m|e}_{###}` — e.g., `dock-worker-07_d_001`. Counter is the generation-order rank within the role group for that district, starting at 01, assigned deterministically from the world seed. Used only when a specific generated NPC needs authored content that differs from the role pool.
- **Key design choices:**
- **Why the collision problem is mostly already solved:** The Q-028 collision framing assumed NPC-scoped IDs require per-instance ID sequences. They don't. D-028 tagged line pools are role-scoped: `dock-worker_d_001` is content that any dock worker can deliver. Forty dock workers all drawing from `dock-worker_d_###` is correct behavior, not a collision. A collision would only occur if two *distinct authored lines* shared the same ID — which the role namespace prevents by definition (one file, one sequence).
- **Instance namespace scope:** Opt-in only. Tier 3 (flat wallpaper) and Tier 2 (mundane triangles) auto-generated NPCs use the role pool exclusively. Instance pools are authored only when a specific generated NPC needs content variation the role pool cannot supply (e.g., a generated NPC flagged as a triangle member with unique tell lines).
- **History log disambiguation:** The speaker of a line is identified by `StableId`, not by line ID. Line ID identifies content; `StableId` identifies the speaker. `(StableId: 42, line_id: "dock-worker_d_001")` and `(StableId: 43, line_id: "dock-worker_d_001")` are two different log entries for the same content line — no collision in the log.
- **Counter stability:** Generation order within a role group is seeded from the world seed. Same seed → same order → same counter assignments. Counter is recorded in the district's NPC roster at world-gen time. The counter survives save/load because it is part of the generated NPC's profile, not recomputed at runtime.
- **Schema compatibility:** The existing ID regex `^[a-z][a-z0-9-]*_[dme]_\d{3}$` already accepts `dock-worker-07_d_001`. No regex change required. No content migration required.
- **Rejected alternatives:**
- **StableId prefix (`npc-00042_d_001`):** StableId is assigned at load time from sorted canonical IDs. Authors cannot know it before writing files. A generate-then-bake pipeline would break the content/generation separation principle. Rejected.
- **UUID suffix (`dock-worker-a3f2_d_001`):** UUIDs are stable per seed but change across seeds, orphaning any authored instance content on replay. Rejected.
- **Slug registry with collision resolution (`dock-worker`, `dock-worker-2`, ...):** First instance gets a privileged non-suffixed slug while all others get a counter, creating asymmetry with no upside. Rejected.
- **Implementation requirement:** The content registry (`server/src/knowledge/registry.rs` or a new `server/src/content/npc_slug.rs`) tracks a `RoleCounter: BTreeMap<String, u32>` per district. Incremented when a generated NPC claims an instance namespace slot. Stored in the district manifest. Provides `generate_instance_slug(role_slug) -> String` returning `{role-slug}-{counter:02}`.
- **Hand-authored NPCs:** Unchanged. `kael-davan`, `sera-venn`, and all named authored NPCs keep their current slugs and ID sequences. No migration.
- **Resolves:** Q-028
- **Cross-reference:** Line ID scheme ([D-035](#d-035-converged-tag-taxonomy-for-dialogue-and-monologue-line-pools)), population model ([D-029](#d-029-population-entanglement-ratio--305020)), NPC generation ([D-024](#d-024-npc-generation-model--10-axes--combat-component))
- **Raised by:** Gestalt (Sprint 18, #544). Endorsed by Tyre pending implementation review.
- **Dissent:** None.
---
*16 decisions. Last updated: 2026-02-19 (D-075 dissent updated)*
*17 decisions. Last updated: 2026-02-25 (D-084 added — Q-028 resolution)*
+4 -4
View File
@@ -161,13 +161,13 @@ Tracked questions awaiting discussion or resolution.
- **Source:** Sprint 10 PR review discussion (2026-02-19)
### Q-028: Collision-resistant line IDs for auto-generated NPCs
- **Status:** Open
- **Question:** The D-035 NPC-scoped line ID scheme uses NPC slugs as prefix (`kael-davan_d_001`). Hand-authored NPCs have unique slugs, but auto-generated populations (D-029: hundreds of NPCs) will produce collisions when the generator creates multiple NPCs with the same role slug (e.g., two `dock-worker` NPCs). What collision-resistance mechanism should be used? Options: (1) Short UUID/hash suffix on auto-gen slugs (`dock-worker-a7f3_d_001`), (2) Entity UUID as prefix, (3) Slug registry that guarantees uniqueness at generation time, (4) Composite key (entity ID + sequence) in server, human-readable slug only for authored content.
- **Constraints:** Line IDs must be globally unique across entire save file lifetime (history log readiness). Must stay human-readable for hand-authored content. Server treats IDs as opaque strings — solution lives in content/generation layer. Must be compatible with D-035 NPC-scoped namespace.
- **Status:** Resolved → [D-084](content.md#d-084-dual-namespace-line-id-scheme--role-pool--instance-override)
- **Resolution:** The collision problem is mostly already solved by the role-pool architecture: `dock-worker_d_###` lines are shared content for all instances of the role, not per-instance IDs. A true collision (two distinct authored lines sharing the same ID) cannot occur with one file per role. For the edge case of authored instance-specific content, a role-slug + zero-padded generation counter suffix produces `dock-worker-07_d_001`. Counter is seeded-deterministic. No schema change, no migration. Hand-authored NPCs unchanged.
- **Closed by:** Gestalt (Sprint 18, #544). 2026-02-25.
- **Ticket:** #544
- **Assigned to:** Gestalt, Tyre
- **Source:** Sprint 16 PR #59 review discussion (2026-02-23)
---
*28 questions (6 resolved, 1 partially resolved, 21 open). Last updated: 2026-02-24*
*28 questions (7 resolved, 1 partially resolved, 20 open). Last updated: 2026-02-25 (Q-028 resolved → D-084)*
+100
View File
@@ -0,0 +1,100 @@
# Line ID Authoring Guide
**Decision:** D-084 (dual-namespace line ID scheme)
**Resolves:** Q-028 (collision-resistant IDs for auto-generated NPCs)
**Ticket:** #544
---
## The Short Version
- **Role pool lines:** Use `{role-slug}_d_{###}` — e.g., `dock-worker_d_001`. These lines are shared by all NPCs with that role. This is the default for all auto-generated NPC content.
- **Named NPC lines:** Use `{npc-slug}_d_{###}` — e.g., `kael-davan_d_001`. Unchanged from current practice.
- **Instance-specific lines (rare):** Use `{role-slug}-{counter}_d_{###}` — e.g., `dock-worker-07_d_001`. Only needed when a specific generated NPC needs content different from the role pool.
---
## How Line IDs Work
A line ID identifies **content**, not speaker. The speaker is identified by their `StableId` in the history log. So `dock-worker_d_001` being said by 40 different dock workers is correct: the log records `(StableId: 12, dock-worker_d_001)`, `(StableId: 37, dock-worker_d_001)`, etc. No collision.
This means the role pool approach already handles most cases — the "collision problem" is mainly a concern for the rare case where you want a specific generated NPC to say something *different* from others of the same role.
---
## Namespace Reference
### Named NPC lines (Tier 1 and Tier 2 authored NPCs)
```
Format: {npc-slug}_{content-type}_{###}
Example: kael-davan_d_001 (Kael's dialogue line 1)
sera-venn_d_015 (Sera's dialogue line 15)
pc-smuggler_m_s_001 (Smuggler monologue line 1)
```
File location: One file per NPC (e.g., `dialogue/maintenance-corridors/kael-davan.yaml`)
Numbering: Sequential within the file. Gaps are acceptable (deleted lines leave permanent gaps). Never reuse a number.
---
### Role pool lines (auto-generated NPCs, Tier 3 flat, Tier 2 mundane)
```
Format: {role-slug}_{content-type}_{###}
Example: dock-worker_d_001 (any dock worker, dialogue line 1)
bar-regular_d_008 (any bar regular, dialogue line 8)
transit-worker_d_003 (any transit worker, dialogue line 3)
```
File location: One file per role-at-location (e.g., `dialogue/the-terminal/dock-worker.yaml`)
These lines are shared by **all instances** of the role. Write them to suit any dock worker, not a specific one.
---
### Instance-specific lines (opt-in, rare)
Use only when the generation system has flagged a specific NPC as needing content that differs from the role pool. Examples: a generated dock worker who is also a triangle member with a specific tell; a generated bar regular who witnessed a specific event.
```
Format: {role-slug}-{zero-padded counter}_{content-type}_{###}
Example: dock-worker-07_d_001 (instance 7 of dock-worker role, line 1)
bar-regular-02_d_005 (instance 2 of bar-regular role, line 5)
```
The counter (01, 02, ... N) is assigned by the generation system in world-seed-deterministic order. The NPC's generated profile file will tell you which counter to use.
File location: Same directory as the role pool file, separate file with instance slug as name (e.g., `dialogue/the-terminal/dock-worker-07.yaml`)
---
## Quick Decision Guide
| Situation | ID format to use |
|-----------|------------------|
| Named authored NPC (Kael, Sera, Voss...) | `{npc-slug}_d_{###}` |
| Lines any dock worker can say | `dock-worker_d_{###}` |
| Lines any bar regular can say | `bar-regular_d_{###}` |
| Generated NPC with specific triangle role | `{role-slug}-{counter}_d_{###}` |
| Generated NPC who's just background | `{role-slug}_d_{###}` — no instance ID needed |
---
## Schema Compatibility
The existing ID regex `^[a-z][a-z0-9-]*_[dme]_\d{3}$` accepts all three formats. No schema change is required. The content validator (`make validate-content`) checks for duplicate IDs across all files in a district.
---
## Numbering Rules
1. Start at `001`, increment by 1 for each new line.
2. Never reuse a number, even if a line is deleted. Gaps are fine.
3. Lines within a single file have a contiguous prefix — `dock-worker_d_001` through `dock-worker_d_042`, etc.
4. Cross-file: `kael-davan.yaml` at the terminal and `kael-davan.yaml` at maintenance corridors both use the `kael-davan_d_###` namespace. Continue numbering from where the other file left off (check the existing files first, use a fresh sequence if the NPC is new to a location).
---
*D-084 — authored by Gestalt, Sprint 18*
+450
View File
@@ -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 110. 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).*