Merge remote-tracking branch 'origin/copy'
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
@@ -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
|
||||
+2
-2
@@ -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
@@ -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)*
|
||||
|
||||
@@ -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)*
|
||||
|
||||
@@ -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*
|
||||
@@ -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).*
|
||||
Reference in New Issue
Block a user