From 9cec3df68c54a0e905a34cca17530c070981e60b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 02:27:33 +0100 Subject: [PATCH 1/5] =?UTF-8?q?feat(content):=20D-084=20dual-namespace=20l?= =?UTF-8?q?ine=20ID=20scheme=20=E2=80=94=20resolve=20Q-028=20(#544)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Role namespace (shared pool) stays unchanged; instance namespace (role-slug + seeded counter) added as opt-in for per-instance authored content on auto-generated NPCs. No schema migration needed. Co-Authored-By: Claude Opus 4.6 --- decisions/README.md | 2 +- decisions/content.md | 24 +++++- decisions/questions.md | 8 +- docs/design/line-id-authoring-guide.md | 100 +++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 docs/design/line-id-authoring-guide.md diff --git a/decisions/README.md b/decisions/README.md index 98ca46d9a..eed582653 100644 --- a/decisions/README.md +++ b/decisions/README.md @@ -12,7 +12,7 @@ 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 | diff --git a/decisions/content.md b/decisions/content.md index 1870407a0..21a0b9e17 100644 --- a/decisions/content.md +++ b/decisions/content.md @@ -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` 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)* diff --git a/decisions/questions.md b/decisions/questions.md index d2dbf7d79..b6f50ea63 100644 --- a/decisions/questions.md +++ b/decisions/questions.md @@ -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)* diff --git a/docs/design/line-id-authoring-guide.md b/docs/design/line-id-authoring-guide.md new file mode 100644 index 000000000..7373df6fd --- /dev/null +++ b/docs/design/line-id-authoring-guide.md @@ -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* From 16a79c928c0e8c860631231225ca45759c212dd0 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 02:27:39 +0100 Subject: [PATCH 2/5] feat(content): Tier 1 drama module schema and smuggling ring stub (#158) JSON Schema for drama modules covering entry conditions, NPC requirements, event sequences, and outcomes. Includes v0.1 vertical slice stub module and authoring guide with review feedback from Mellanie (pattern/motivation reference, terminal outcome semantics, fact ID conventions, trigger type bridge). Co-Authored-By: Claude Opus 4.6 --- .../modules/tier1/smuggling_ring_v0_1.yaml | 655 ++++++++++++++++ content/schemas/drama_module.schema.yaml | 718 ++++++++++++++++++ docs/design/tier1-module-authoring.md | 450 +++++++++++ 3 files changed, 1823 insertions(+) create mode 100644 content/modules/tier1/smuggling_ring_v0_1.yaml create mode 100644 content/schemas/drama_module.schema.yaml create mode 100644 docs/design/tier1-module-authoring.md diff --git a/content/modules/tier1/smuggling_ring_v0_1.yaml b/content/modules/tier1/smuggling_ring_v0_1.yaml new file mode 100644 index 000000000..64526e8cb --- /dev/null +++ b/content/modules/tier1/smuggling_ring_v0_1.yaml @@ -0,0 +1,655 @@ +# yaml-language-server: $schema=../../schemas/drama_module.schema.yaml +# +# Tier 1 Drama Module: The Smuggling Ring (v0.1) +# The vertical slice Tier 1 module for D-027. +# +# NARRATIVE CORE: +# A logistics worker (the smuggler PC, if played) is embedded in a small ring +# smuggling unlicensed lattice components through Sova Transit District (D-037). +# The ring is led by Voss from The Terminal. Kael Davan — a ring member and the +# smuggler's FRIEND — is quietly trying to exit to protect his partner Naia Tamm. +# Sera Venn (the detective's FRIEND) has noticed Kael's manifest discrepancies +# but hasn't reported them, protecting Naia by proxy. +# +# DUAL-LENS EXPERIENCE: +# Smuggler plays INSIDE the ring: manage drops, cover tracks, notice Kael going cold. +# Detective plays OUTSIDE: cargo anomalies → follow Kael → witness secret meeting → +# confront or protect. +# +# SUCCESS CRITERIA (D-027): +# #1: 30 minutes of daily-life play before the ring activates (min_play_ticks: 2100) +# #3: Player names Kael as an NPC they felt conflicted about +# #4: observe→notice→follow→discover emerges from systems, not scripts + +module_id: smuggling_ring_v0_1 +display_name: "The Smuggling Ring" +version: "0.1" +tier: 1 +description: > + A small ring of logistics workers smuggling unlicensed lattice components through + Sova Transit District. The ring's weakest link — Kael Davan — is trying to exit + to protect his partner. The detective investigates cargo anomalies. The smuggler + manages ring operations and navigates Kael's loyalty crisis. Neither character + knows the other's full picture until confrontation forces it. + +notes: > + This module IS the vertical slice (D-027). It exercises every system at full depth: + dual-lens NPC observation, tell progression, trust-gated dialogue, knowledge graph + confidence accumulation, confrontation weight (D-063), walk-away consequences (D-064), + and THE FRIEND contradiction arc (D-034). All outcome paths must feel earned. + No outcome is "the right answer" — Kael's situation has no clean resolution. + +dual_lens: + smuggler: > + You're inside the ring. Voss manages operations; you handle logistics cover. + Kael used to be reliable. Lately he's absent, distracted, making excuses. + The drop schedule is at risk. Do you pressure him, cover for him, or cut him? + You don't know he's trying to get out. He doesn't know you've noticed. + detective: > + Cargo manifest discrepancies in The Terminal. Small, systematic, deniable. + Your analytical lattice flags them before your conscious mind does. + Follow the thread: discrepancy → dock worker with odd schedule → Kael Davan → + maintenance corridors → someone he shouldn't be meeting. And then what? + Arrest a man trying to leave a ring he never wanted to join? + +pool: + weight: 8 + compatible_districts: + - sova-transit + max_concurrent: 1 + +# ── ENTRY CONDITIONS ───────────────────────────────────────────────────────── +# Ring activity begins after player has had time to establish routine (D-027 #1). +# The ring is already running at game start — the module activates when the +# storyteller decides the tension has built enough to surface. + +entry_conditions: + world_state: + - type: npc_present + role: ring-leader + - type: npc_present + role: ring-member-exiting + - type: location_accessible + location: the-terminal + - type: location_accessible + location: maintenance-corridors + + activation: + trigger: storyteller_push + min_play_ticks: 2100 # ~35 minutes at 1 tick/second — D-027 criterion #1 + # The storyteller pushes activation when player has established presence + # in The Terminal or The Last Shift through routine interaction. + # Proximity trigger (maintenance-corridors) is a secondary activation path + # if the player wanders there early. + +# ── NPC REQUIREMENTS ───────────────────────────────────────────────────────── +# All core roles are named (hand-authored NPCs from the vertical slice). +# No generated NPC slots in v0.1 — the smuggling ring uses the 15 authored NPCs. + +npc_requirements: + - role: ring-leader + display_hint: > + Runs the ring from The Terminal. Logistics authority = cover. + Never handles contraband directly. Pressure source for Kael. + binding: named + named_npc: "npc:voss" + must_have_motivation: HANDLER + + - role: ring-member-exiting + display_hint: > + Kael Davan. Dock worker, ring member, smuggler's FRIEND. + Trying to exit quietly to protect Naia. This is THE FRIEND contradiction. + Every event sequence runs through this role. + binding: named + named_npc: "npc:kael-davan" + must_have_pattern: FRIEND + must_have_motivation: TURNCOAT + + - role: partner-uninvolved + display_hint: > + Naia Tamm. Kael's partner. Does not know about the ring. + Her safety is Kael's motivation for exiting. Her ignorance is the moral weight. + Discovery of her connection to Kael is a late-investigation revelation. + binding: named + named_npc: "npc:naia-tamm" + must_have_motivation: CIVILIAN + + - role: evidence-holder + display_hint: > + Sera Venn. Detective's FRIEND. Commission field tech. + She has noticed Kael's manifest discrepancies but hasn't reported them — + she knows Naia, and filing means Kael's arrest and Naia's exposure. + Her silence IS the detective's investigation blocker in phase 1. + binding: named + named_npc: "npc:sera-venn" + must_have_pattern: FRIEND + must_have_motivation: WITNESS + + - role: ring-operative + display_hint: > + The ring's operational member in maintenance corridors. + Handles physical drops. Not a speaking character — observable behavior only. + Can be the anonymous contact Kael meets. + binding: named + named_npc: "npc:nils" + is_optional: false + + - role: institutional-watcher + display_hint: > + Maret Korr. A Commission observer embedded at The Terminal. + Her growing attention is the external pressure that accelerates the timeline. + She doesn't know about the ring specifically — she's tracking cargo patterns. + binding: named + named_npc: "npc:maret-korr" + must_have_motivation: OPERATOR + is_optional: true # Module runs without Maret, but with degraded tension arc + +# ── EVENTS ─────────────────────────────────────────────────────────────────── +# Two sequences + one pool. +# Sequence A: Kael's exit arc (the FRIEND contradiction backbone) +# Sequence B: Investigation pressure arc (escalating discovery opportunities) +# Pool: ambient ring activity (fires opportunistically throughout the module) + +events: + + sequences: + + # SEQUENCE A: Kael's Exit Arc + # The narrative spine. Each step makes Kael's contradiction more visible. + # Observable to both characters, interpreted differently. + + - sequence_id: kael_exit_arc + label: "Kael's Exit Arc" + description: > + Kael Davan's progressive attempt to leave the ring. + Tells intensify. Routine deviations appear. The secret meeting is the + pivot point — after it fires, both characters' understanding shifts. + steps: + + - event_id: kael_goes_cold + label: "Kael Goes Cold" + description: > + Kael starts missing social patterns he'd normally keep — fewer bar visits, + shorter responses at The Terminal, leaving early. His tell system activates: + the shoulder-check behavior appears. Nothing dramatic. Just absence where + there was presence. The smuggler notices because they work together. + The detective might notice if they've been tracking Kael's baseline. + triggers: + - type: ticks_since_activation + ticks: 300 # ~5 minutes after module activates + effects: + - type: npc_routine_deviation + npc_role: ring-member-exiting + description: > + Kael skips his usual post-shift drink at The Last Shift. + Leaves the terminal 15 minutes early. No explanation. + - type: tell_intensify + npc_role: ring-member-exiting + description: > + Kael's shoulder-check behavior activates at The Terminal. + Visible to any character with forward vision cone in his direction. + sets_flag: kael_behavior_changed + + - event_id: drop_happens_without_kael + label: "Scheduled Drop — Kael Absent" + description: > + A ring drop occurs in maintenance corridor C-7. Kael was supposed + to verify the cargo. He wasn't there. Nils covered it. + The smuggler notices the irregularity in the paperwork. + The detective — if watching cargo patterns — sees a manifest entry + with no verifying signature where one is normally present. + triggers: + - type: ticks_since_event + after_event: kael_goes_cold + ticks: 450 # ~7.5 minutes after goes-cold + effects: + - type: fact_becomes_discoverable + fact_id: "ring.kael_missed_verification" + discoverable_by: any + discovery_method: > + Smuggler: check the cargo manifest in The Terminal office. + Detective: analytical lattice flags unsigned verification entry. + - type: location_state + location: maintenance-corridors + description: "An unsigned cargo verification entry exists in corridor C-7's log." + sets_flag: kael_missed_drop + + - event_id: kael_secret_meeting + label: "Kael's Secret Meeting" + description: > + Kael meets an off-district contact in maintenance corridor B-7. + This is the observable contradiction (D-034): Kael, in a restricted + area he has no logged reason to be in, talking to someone who's + not in any district NPC roster. His body language is tense. + If the player is in visual range: this is the pivot moment. + If not: the meeting happens anyway — the world doesn't wait. + triggers: + - type: ticks_since_event + after_event: drop_happens_without_kael + ticks: 600 # ~10 minutes after the dropped verification + - type: player_proximity + target_type: location + target: maintenance-corridors + radius_tiles: 12 # Player wandering near triggers the meeting early + effects: + - type: npc_routine_deviation + npc_role: ring-member-exiting + description: > + Kael enters maintenance corridor B-7. Locked door to restricted + supply closet. Emerges with the ring-operative 8 minutes later. + Neither acknowledges the encounter publicly. + - type: fact_becomes_discoverable + fact_id: "ring.kael_unauthorized_corridor_access" + discoverable_by: any + discovery_method: > + Player must be in visual range of corridor B-7. + Or examine the corridor door access log (investigative action). + - type: tell_intensify + npc_role: ring-member-exiting + description: > + After the meeting, Kael's shoulder-check frequency doubles. + Also: he avoids eye contact with the smuggler at The Terminal. + sets_flag: secret_meeting_occurred + + - event_id: kael_sends_message + label: "Kael Sends the Message" + description: > + Kael sends an encrypted Meridian message to an off-district contact. + The detective's analytical lattice can detect an anomalous outgoing + packet from the district node — not the content, just the pattern + (frequent, encrypted, sent from cargo bay terminals, not personal devices). + The smuggler won't see this unless they're specifically watching Kael. + triggers: + - type: ticks_since_event + after_event: kael_secret_meeting + ticks: 200 + effects: + - type: fact_becomes_discoverable + fact_id: "ring.kael_unusual_meridian_activity" + discoverable_by: detective + discovery_method: > + Detective's analytical lattice flags the outgoing packet pattern. + Requires player to be in or adjacent to The Terminal cargo bay. + sets_flag: kael_message_sent + + - event_id: ring_leader_confronts_kael + label: "Voss Confronts Kael" + description: > + Voss calls Kael into The Terminal supervisor's office. + Closed door. Raised voices (audible only from adjacent room/position). + Kael emerges pale. Voss emerges neutral. The smuggler can witness + the approach/departure without hearing content. The detective can + observe Kael's state immediately after if in The Terminal. + This is Voss applying pressure. Kael is now visibly under strain. + triggers: + - type: flag_set + flag: kael_message_sent + - type: ticks_since_event + after_event: kael_message_sent + ticks: 400 + effects: + - type: npc_routine_deviation + npc_role: ring-leader + description: "Voss calls Kael into the supervisor's office. Door closed." + - type: npc_routine_deviation + npc_role: ring-member-exiting + description: > + Kael emerges from the meeting looking strained. His shoulder-check + is now constant. He takes an unscheduled break outside, alone. + - type: tell_intensify + npc_role: ring-member-exiting + description: > + Kael's contentment hits lowest observed level. He now actively avoids + the ring-operative (Nils) in public. The disconnection is visible. + - type: fact_becomes_discoverable + fact_id: "ring.voss_kael_private_meeting" + discoverable_by: any + discovery_method: > + Observe the meeting room door (spatial). Or ask Kael directly + after (trust-gated dialogue unlocks "Are you alright?" option). + sets_flag: voss_pressure_applied + + # SEQUENCE B: Investigation Pressure Arc + # External pressure that escalates the timeline. + # Fires in parallel with Sequence A. + + - sequence_id: investigation_pressure + label: "Investigation Pressure Arc" + description: > + Maret Korr's institutional attention creates a closing window. + Her growing interest is the reason the module can't stay in equilibrium forever. + She doesn't know about the ring — she's a pattern-watcher. But patterns + are what the detective investigates too. Their paths converge. + steps: + + - event_id: maret_flags_anomaly + label: "Maret Flags the Cargo Anomaly" + description: > + Maret Korr files an internal Commission note flagging The Terminal's + cargo variance rate as statistically unusual. Not an investigation — + just a flag. The detective's institutional access can pull this note. + The smuggler has no way to know it exists (unless the detective tells them). + triggers: + - type: ticks_since_activation + ticks: 900 # ~15 minutes after activation + effects: + - type: fact_becomes_discoverable + fact_id: "ring.commission_cargo_flag" + discoverable_by: detective + discovery_method: > + Detective queries Commission data via institutional access + (authority access tier, Terminal records). + sets_flag: commission_flag_exists + + - event_id: maret_increases_presence + label: "Maret Increases Her Presence" + description: > + Maret starts spending more time in The Terminal. More frequent + walkthroughs during shift changes. Her attention to the cargo bay + area is noticeable to anyone watching. Ring members are unnerved. + Voss starts accelerating the timeline to close operations before + institutional attention becomes formal investigation. + triggers: + - type: ticks_since_event + after_event: maret_flags_anomaly + ticks: 600 + - type: player_action + action: examine + target_role: institutional-watcher + effects: + - type: npc_routine_deviation + npc_role: institutional-watcher + description: > + Maret adds two extra Terminal walkthroughs per shift cycle. + Spends 15 minutes studying the cargo bay manifest terminals. + - type: tell_intensify + npc_role: ring-leader + description: > + Voss becomes quieter, more deliberate. Less casual conversation. + His tell — the stillness before speaking — becomes more frequent. + - type: fact_becomes_discoverable + fact_id: "ring.voss_accelerating_timeline" + discoverable_by: any + discovery_method: > + Smuggler: Voss mentions "the schedule moving up" in a guarded + conversation (trust-gated, ring-insider access required). + Detective: observe Voss and Nils in two exchanges within same shift. + sets_flag: timeline_accelerating + + - event_id: final_shipment_scheduled + label: "The Final Shipment Is Scheduled" + description: > + The ring schedules the last major drop — after this, they go dark. + This is the closing window. If the detective hasn't uncovered enough + by the time this fires, the ring disperses and the operation closes + without exposure (escaped outcome). If they have, confrontation + becomes unavoidable. The smuggler knows about this drop. Kael doesn't + want to participate. Voss insists. + triggers: + - type: flag_set + flag: timeline_accelerating + - type: ticks_since_event + after_event: maret_increases_presence + ticks: 800 + effects: + - type: fact_becomes_discoverable + fact_id: "ring.final_shipment_scheduled" + discoverable_by: any + discovery_method: > + Smuggler: direct notification from Voss. + Detective: cargo manifest shows an unusual large entry for 3 days out. + - type: npc_routine_deviation + npc_role: ring-member-exiting + description: > + Kael's schedule changes: he's assigned to the cargo bay + during the drop window. He doesn't want to be there. + sets_flag: final_shipment_known + + pools: + + # POOL: Ambient ring activity — opportunistic events that add texture + - pool_id: ambient_ring_activity + label: "Ambient Ring Activity" + description: > + Low-level ring business that happens throughout the module regardless of + player engagement. Creates the sense that the ring exists independently. + Players who look closely will find more; players who don't still feel the world moving. + events: + - event_id: cargo_discrepancy_appears + label: "Small Cargo Discrepancy Appears" + description: > + A minor manifest irregularity appears in The Terminal records. + Small enough to be deniable. Systematic enough to be a pattern. + The detective's analytical lattice may flag it. The smuggler can + correct it if they notice it — covering tracks is part of their role. + triggers: + - type: ticks_since_activation + ticks: 150 # Fires early and repeats + effects: + - type: fact_becomes_discoverable + fact_id: "ring.cargo_discrepancy_pattern" + discoverable_by: any + discovery_method: > + Detective: analytical lattice flags during Terminal walkthrough. + Smuggler: check manifest terminals (or get flagged by the discrepancy + in their own work). + once: false # Repeats — pattern builds over time + + - event_id: sera_avoids_torek + label: "Sera Avoids Torek at The Bar" + description: > + Sera Venn reroutes her usual path through The Last Shift to avoid + standing near Torek Lintar (the Commission enforcement officer). + Anyone watching Sera's normal pattern would notice. + This is the detective's first clue that Sera's behavior is odd. + triggers: + - type: ticks_since_activation + ticks: 500 + effects: + - type: npc_routine_deviation + npc_role: evidence-holder + description: > + Sera takes a longer route to her usual seat, passing through + the back of the bar to avoid Torek's sightline. + - type: fact_becomes_discoverable + fact_id: "ring.sera_avoidance_behavior" + discoverable_by: detective + discovery_method: > + Observer Sera's path through the bar on two separate occasions. + Requires forward vision cone and awareness of her baseline route. + once: false + + - event_id: nils_makes_supply_run + label: "Nils Makes an Unscheduled Supply Run" + description: > + The ring-operative (Nils) enters the maintenance corridors with a + small container logged as "calibration tools". The container isn't + logged for return. Someone paying attention to cargo flow would notice. + triggers: + - type: ticks_since_activation + ticks: 700 + effects: + - type: npc_routine_deviation + npc_role: ring-operative + description: "Nils takes a container to maintenance corridor C-7." + - type: fact_becomes_discoverable + fact_id: "ring.nils_unlogged_cargo" + discoverable_by: any + discovery_method: > + Watch Nils's cargo handling pattern over two shifts. + Or examine maintenance corridor C-7 access log. + once: false + +# ── OUTCOMES ───────────────────────────────────────────────────────────────── +# Five resolution states. Checked each tick after the first sequence step fires. +# Order matters — the storyteller applies the first matching outcome. +# is_terminal: true ends the module. + +outcomes: + + # 1. RING EXPOSED + # Detective successfully uncovers the operation. + # Commission becomes involved. Arrests/flight follow. + - outcome_id: ring_exposed + label: "Ring Exposed" + is_terminal: true + description: > + The detective accumulates enough evidence to trigger a formal Commission + inquiry. The ring collapses: arrests, flight, or both. Voss is detained. + Kael's situation is now public. The smuggler (if played) faces consequences. + Naia learns what Kael was doing — and why he was trying to leave. + No clean endings. The right outcome for the detective who goes all the way. + conditions: + facts_known: + - "ring.cargo_discrepancy_pattern" + - "ring.kael_unauthorized_corridor_access" + - "ring.voss_kael_private_meeting" + events_fired: + - secret_meeting_occurred + effects: + - type: npc_disposition + npc_role: ring-leader + shift: hostile + description: "Voss is detained or flees. Commission inquiry opens." + - type: npc_disposition + npc_role: ring-member-exiting + shift: hostile + description: > + Kael is arrested or disappears. His exit attempt is now moot. + His relationship with Naia is exposed. + - type: faction_reaction + faction: lattice-commission + reaction: grateful + description: "Commission credits the detective's investigation." + - type: npc_exit + npc_role: ring-leader + description: "Voss leaves the district — detained, fled, or both." + + # 2. KAEL ESCAPES THE RING + # Unique path. Requires the player to engage with Kael directly + # and choose to help him rather than expose the ring wholesale. + - outcome_id: kael_escapes + label: "Kael Escapes the Ring" + is_terminal: true + description: > + Through the player's choices — helping Kael cover his exit, or warning him, + or simply choosing not to act on what they know — Kael successfully leaves + the ring before the final shipment. He and Naia leave the district quietly. + The ring continues without him, smaller and more cautious. + This outcome requires discovering Kael's secret AND choosing restraint. + The smuggler can engineer this by covering for Kael with Voss. + The detective can achieve this by confronting Kael privately rather than + filing a report. The most morally complicated path. + conditions: + facts_known: + - "ring.kael_unauthorized_corridor_access" + flags_set: + - secret_meeting_occurred + - voss_pressure_applied + # ring_exposed is checked first in the outcomes list and is terminal, + # so kael_escapes only evaluates if ring_exposed hasn't fired. + # No flags_not_set needed here — outcome ordering handles priority. + effects: + - type: npc_disposition + npc_role: ring-member-exiting + shift: friendly + description: "Kael remembers whoever helped him. He's gone, but grateful." + - type: npc_exit + npc_role: ring-member-exiting + description: "Kael and Naia leave Sova Transit District." + - type: faction_reaction + faction: the-ring + reaction: suspicious + description: "The ring is destabilized by Kael's exit. Voss is alert to further leaks." + + # 3. RING COMPLETES OPERATION + # The ring finishes the final shipment and goes dark before discovery. + # Default path if the detective doesn't move fast enough. + - outcome_id: ring_completes + label: "Ring Completes the Operation" + is_terminal: true + description: > + The final shipment clears. The ring disperses. Voss transfers. Nils goes quiet. + Kael stays — he's now out by default, the ring having dissolved around him. + The evidence trail goes cold. The detective closes the case as inconclusive. + The smuggler completes their last run and waits to see if there's another. + Unsatisfying only if you expected a tidy resolution. The world moved on. + conditions: + events_fired: + - final_shipment_known + flags_not_set: + - kael_message_sent # If this is NOT set, Kael's cooperation went unnoticed + ticks_since_activation: 3600 # Module ran for ~60 minutes without full exposure + effects: + - type: faction_reaction + faction: the-ring + reaction: neutral + description: "The ring successfully completed this operation. They'll be back." + - type: npc_exit + npc_role: ring-leader + description: "Voss transfers to another station for 'career development'." + - type: location_access_change + location: maintenance-corridors + change: open + description: "The restricted supply closet is now empty. Access log shows it cleared." + + # 4. RING SPLINTERS + # Partial discovery. The ring fractures but doesn't fully collapse. + # An incomplete ending that leaves threads for future investigation. + - outcome_id: ring_splinters + label: "Ring Splinters" + is_terminal: false # Not terminal — splinter state can evolve + description: > + Enough evidence surfaces that the ring knows it's been partially seen. + Voss shuts down active operations. Nils disappears. Kael stays — now the + one person in the district who knows what happened and has no one to tell. + The formal investigation stalls for lack of a clear chain of evidence. + The detective has facts but not the complete picture. The smuggler + faces an awkward return to normalcy. Both know the ring isn't gone — just quiet. + conditions: + facts_known: + - "ring.cargo_discrepancy_pattern" + events_fired: + - kael_goes_cold + flags_not_set: + - kael_message_sent + ticks_since_activation: 2400 + effects: + - type: npc_disposition + npc_role: ring-leader + shift: suspicious + description: "Voss goes quiet. He's watching to see who knows what." + - type: npc_exit + npc_role: ring-operative + description: "Nils stops appearing at The Terminal. Transferred, officially." + - type: faction_reaction + faction: the-ring + reaction: suspicious + description: "The ring is alerted to exposure risk. Future operations will be more careful." + + # 5. MODULE EXPIRY (quiet exit) + # Player never engaged. Module times out without drama. + # NOTE (Gestalt, Sprint 18): Condition uses facts_not_known, not flags_not_set. + # kael_behavior_changed fires automatically at tick 300 (time-triggered), making + # flags_not_set: [kael_behavior_changed] permanently false after tick 300. + # Gate expiry on player-action-required facts instead. + - outcome_id: module_abandoned + label: "Module Abandoned" + is_terminal: true + is_expiry: true + description: > + The player never engaged with the ring's signals. The final shipment + completed without incident. The ring disperses on its own schedule. + Kael stays. The world is unchanged. This is not failure — it's the game + acknowledging that not every conspiracy needs a protagonist. + The 70% mundane majority (D-029) plays out: life continued. + conditions: + facts_not_known: + - "ring.cargo_discrepancy_pattern" # Only known via player examination of terminal + - "ring.kael_unauthorized_corridor_access" # Only known via player observing Kael in B-7 + ticks_since_activation: 5400 # ~90 minutes with zero player investigation + effects: + - type: faction_reaction + faction: the-ring + reaction: neutral + description: "The ring closed operations without incident. No record of compromise." diff --git a/content/schemas/drama_module.schema.yaml b/content/schemas/drama_module.schema.yaml new file mode 100644 index 000000000..02fb039d5 --- /dev/null +++ b/content/schemas/drama_module.schema.yaml @@ -0,0 +1,718 @@ +# Drama Module Schema — Tier 1 Content (D-023) +# YAML expression of JSON Schema 2020-12 +# Validated against this schema: content/modules/tier1/*.yaml +# +# Ownership: +# Dramatic structure (this file): Paula +# YAML validation tooling / serde structs: Gestalt / Tyre +# Authoring ergonomics review: Mellanie +# +# See: docs/design/tier1-module-authoring.md for field-by-field guide. + +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "drama_module.schema.yaml" +title: "Tier 1 Drama Module" +description: > + A hand-authored drama module drawn from the pool at game start. + The storyteller activates one or more modules per playthrough based on + entry conditions, then fires events and detects outcomes. Tier 1 modules + are the conspiracy layer of D-023 — authored, optional, relocatable. +type: object +required: + - module_id + - display_name + - version + - tier + - pool + - entry_conditions + - npc_requirements + - events + - outcomes +additionalProperties: false + +properties: + + # ── IDENTITY ──────────────────────────────────────────────────────────────── + + module_id: + type: string + pattern: "^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$" + description: > + Stable unique slug. Format: {name}_v{major}_{minor}. + Never reuse IDs. Increment version on breaking structural changes. + Example: "smuggling_ring_v0_1" + + display_name: + type: string + minLength: 1 + description: "Human-readable title shown in dev/debug tooling." + + version: + type: string + pattern: "^[0-9]+\\.[0-9]+$" + description: "Authoring version. Semantic: major.minor." + + tier: + type: integer + const: 1 + description: "Always 1 for Tier 1 drama modules." + + description: + type: string + description: "One-paragraph authoring summary. Not shown in-game." + + # ── POOL METADATA ───────────────────────────────────────────────────────── + # Controls how the storyteller includes this module in the per-playthrough pool. + + pool: + type: object + required: + - weight + additionalProperties: false + description: "How the storyteller samples this module from the pool." + properties: + weight: + type: integer + minimum: 1 + maximum: 10 + description: > + Relative selection probability (1–10). Higher = more likely to be + included in a given playthrough's active module set. Default: 5. + compatible_districts: + type: array + items: + type: string + description: > + District slugs where this module can activate, or omit for "any". + Example: ["sova-transit"] + incompatible_with: + type: array + items: + type: string + pattern: "^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$" + description: > + Module IDs that cannot run concurrently with this one. + The storyteller will not activate both in the same playthrough. + max_concurrent: + type: integer + minimum: 1 + default: 1 + description: > + Maximum simultaneous active instances. Almost always 1. + Set to 2+ only for modules designed to stack (rare). + + # ── ENTRY CONDITIONS ────────────────────────────────────────────────────── + # All listed conditions must be true for the module to become activatable. + # The storyteller checks these each tick after min_play_ticks. + + entry_conditions: + type: object + required: + - activation + additionalProperties: false + description: > + World-state prerequisites. The storyteller activates the module when + ALL conditions are satisfied AND the activation trigger fires. + properties: + world_state: + type: array + items: + $ref: "#/$defs/world_state_condition" + description: "World-state conditions checked each tick." + player: + type: array + items: + $ref: "#/$defs/player_condition" + description: > + Optional player-state conditions. Module can activate without + player engagement — these gate on player-specific world state, + not on player noticing the module. + activation: + type: object + required: + - trigger + additionalProperties: false + description: "How and when activation is evaluated." + properties: + trigger: + type: string + enum: + - proximity # Player comes within range of a key NPC/location + - storyteller_push # Storyteller activates on its own schedule + - player_action # Player performs a specific action + description: "What pushes the module from 'eligible' to 'active'." + min_play_ticks: + type: integer + minimum: 0 + description: > + Minimum ticks of game time before this module can activate. + Enforces D-027 success criterion #1: 30 minutes of daily-life + breathing room. At 1 tick/second, 30 minutes ≈ 1800 ticks. + proximity_location: + type: string + description: > + Required when trigger = proximity. Location slug the player + must enter or approach. Example: "maintenance-corridors" + proximity_radius_tiles: + type: integer + minimum: 1 + description: > + Required when trigger = proximity. Tile radius around the + location's anchor point. + player_action_required: + type: string + description: > + Required when trigger = player_action. The action that fires + activation. Example: "examine:cargo-manifest" + + # ── NPC REQUIREMENTS ────────────────────────────────────────────────────── + # NPC slots this module requires. Each slot is filled at module load time. + # Named bindings resolve to specific authored NPCs; generated bindings + # are filled from the district's generated NPC pool. + + npc_requirements: + type: array + minItems: 1 + items: + $ref: "#/$defs/npc_slot" + description: > + Module-internal NPC role slots. Roles are referenced by slug throughout + the rest of this document. Hand-authored NPCs use named bindings. + Generated NPCs use constraint-based bindings. + + # ── EVENTS ──────────────────────────────────────────────────────────────── + # Ordered sequences and unordered event pools the storyteller can fire. + # Sequences are narrative beats in a defined order. + # Pools are events the storyteller can fire in any order when conditions are met. + + events: + type: object + additionalProperties: false + description: "Event sequences and pools the storyteller manages." + properties: + sequences: + type: array + items: + $ref: "#/$defs/event_sequence" + description: > + Ordered event sequences. Steps fire in order; the next step + becomes eligible only after the previous one fires. + pools: + type: array + items: + $ref: "#/$defs/event_pool" + description: > + Unordered event pools. The storyteller may fire any eligible + event in the pool when its trigger conditions are met. + + # ── OUTCOMES ────────────────────────────────────────────────────────────── + # Resolution states the module can reach. The storyteller checks outcome + # conditions each tick. First matching outcome wins. + # Every module MUST include an expiry outcome. + + outcomes: + type: array + minItems: 1 + items: + $ref: "#/$defs/outcome" + description: > + Terminal and transitional resolution states. The storyteller checks + these each tick and applies the first matching outcome. + + # ── AUTHORING NOTES ─────────────────────────────────────────────────────── + + notes: + type: string + description: "Authoring-only field. Design rationale, cross-references. Ignored at load time." + + dual_lens: + type: object + additionalProperties: false + description: "Authoring-only. How smuggler vs detective experience this module." + properties: + smuggler: { type: string } + detective: { type: string } + +# ── SHARED DEFINITIONS ──────────────────────────────────────────────────────── + +$defs: + + # World-state condition types + + world_state_condition: + type: object + required: + - type + description: "A single world-state prerequisite for module activation." + oneOf: + - # NPC with the given module role is present in the district + properties: + type: { type: string, const: "npc_present" } + role: { type: string, description: "Module-internal NPC role slug." } + required: [type, role] + additionalProperties: false + + - # A specific location is accessible to the player + properties: + type: { type: string, const: "location_accessible" } + location: { type: string, description: "Location slug." } + required: [type, location] + additionalProperties: false + + - # Player has NOT yet discovered a specific fact + properties: + type: { type: string, const: "fact_not_known" } + fact_id: { type: string, description: "Fact ID from global/knowledge/." } + required: [type, fact_id] + additionalProperties: false + + - # No other Tier 1 module of the given ID is currently active + properties: + type: { type: string, const: "no_active_module" } + module_id: { type: string } + required: [type, module_id] + additionalProperties: false + + - # A named fact IS known (module requires precondition awareness) + properties: + type: { type: string, const: "fact_known" } + fact_id: { type: string } + known_by: { type: string, enum: [smuggler, detective, any] } + required: [type, fact_id] + additionalProperties: false + + # Player-state condition types + + player_condition: + type: object + required: + - type + description: "A player-state prerequisite." + oneOf: + - # Player has reached minimum relationship threshold with an NPC + properties: + type: { type: string, const: "relationship_threshold" } + npc_role: { type: string, description: "Module-internal NPC role." } + min_state: + type: string + enum: [stranger, known, friendly] + description: "Minimum RelationshipState required." + required: [type, npc_role, min_state] + additionalProperties: false + + - # Minimum game ticks elapsed + properties: + type: { type: string, const: "min_ticks" } + ticks: { type: integer, minimum: 0 } + required: [type, ticks] + additionalProperties: false + + # NPC slot definition + + npc_slot: + type: object + required: + - role + - binding + additionalProperties: false + description: > + One NPC slot in the module. Named binding = specific authored NPC. + Generated binding = constraint-matched NPC from district pool. + properties: + role: + type: string + pattern: "^[a-z][a-z0-9-]*$" + description: > + Module-internal role slug. Referenced in events, outcomes, and + triggers. Example: "ring-leader", "ring-member-exiting", "witness" + display_hint: + type: string + description: "Authoring note. What this role is narratively." + binding: + type: string + enum: [named, generated] + description: > + named = resolves to a specific authored NPC (use named_npc). + generated = any district NPC matching the axis constraints. + named_npc: + type: string + pattern: "^npc:[a-z][a-z0-9-]*$" + description: > + Required when binding = named. Short-form NPC canonical ID. + Example: "npc:kael-davan" + axes: + type: array + items: + $ref: "#/$defs/axis_constraint" + description: > + Required when binding = generated. The NPC must satisfy all + listed axis constraints to fill this slot. + must_have_pattern: + type: string + enum: [FRIEND, MIRROR, ANCHOR, GHOST, CATALYST, THRESHOLD, REMNANT, SYSTEM, NOBODY] + description: "Optional: NPC must have this pattern (D-024)." + must_have_motivation: + type: string + enum: [HANDLER, WITNESS, TURNCOAT, CIVILIAN, OPERATOR, SKEPTIC] + description: "Optional: NPC must have this motivation (D-024)." + is_optional: + type: boolean + default: false + description: > + If true, the module can activate without this slot filled. + Optional slots produce degraded but valid module runs. + + # NPC axis constraint (used in generated bindings) + + axis_constraint: + type: object + required: + - axis + - constraint + additionalProperties: false + properties: + axis: + type: string + enum: [want, secret, relationships, tolerance, routine, information, contentment, personality, tells, skills] + description: "Which NPC axis to constrain (D-024)." + constraint: + type: string + description: > + Constraint expression. Freeform string interpreted by the storyteller. + Convention: "has_{value}", "min_{N}", "not_{value}". + Examples: "has_major_secret", "min_contentment_-3", "not_combat_trained" + + # Event sequence + + event_sequence: + type: object + required: + - sequence_id + - steps + additionalProperties: false + description: "An ordered sequence of narrative events." + properties: + sequence_id: + type: string + pattern: "^[a-z][a-z0-9_-]*$" + label: + type: string + description: + type: string + steps: + type: array + minItems: 1 + items: + $ref: "#/$defs/event_step" + + # Unordered event pool + + event_pool: + type: object + required: + - pool_id + - events + additionalProperties: false + properties: + pool_id: + type: string + pattern: "^[a-z][a-z0-9_-]*$" + label: + type: string + description: + type: string + events: + type: array + minItems: 1 + items: + $ref: "#/$defs/event_step" + + # Individual event step + + event_step: + type: object + required: + - event_id + - triggers + additionalProperties: false + description: "A single storyteller-managed event with triggers and effects." + properties: + event_id: + type: string + pattern: "^[a-z][a-z0-9_-]*$" + description: "Unique within this module. Used in outcome conditions." + label: + type: string + description: + type: string + description: "What happens narratively when this event fires." + triggers: + type: array + minItems: 1 + items: + $ref: "#/$defs/event_trigger" + description: "ANY trigger being true fires this event." + effects: + type: array + items: + $ref: "#/$defs/event_effect" + description: "What changes in the world when this event fires." + once: + type: boolean + default: true + description: "If true, fires only once. If false, may repeat when conditions reset." + sets_flag: + type: string + pattern: "^[a-z][a-z0-9_-]*$" + description: "Module-internal flag set when this event fires. Queryable in outcomes." + + # Event trigger conditions + + event_trigger: + type: object + required: + - type + description: "A condition that causes an event to fire." + oneOf: + - # Ticks elapsed since module activation + properties: + type: { type: string, const: "ticks_since_activation" } + ticks: { type: integer, minimum: 1 } + required: [type, ticks] + additionalProperties: false + + - # Ticks elapsed since a previous event fired + properties: + type: { type: string, const: "ticks_since_event" } + after_event: { type: string } + ticks: { type: integer, minimum: 1 } + required: [type, after_event, ticks] + additionalProperties: false + + - # Player enters a location or comes within range of NPC + properties: + type: { type: string, const: "player_proximity" } + target_type: { type: string, enum: [location, npc_role] } + target: { type: string } + radius_tiles: { type: integer, minimum: 1 } + required: [type, target_type, target] + additionalProperties: false + + - # Player performs an interaction + properties: + type: { type: string, const: "player_action" } + action: + type: string + enum: [talk, examine, confront, follow, observe] + target_role: { type: string, description: "Module NPC role or location slug." } + required: [type, action, target_role] + additionalProperties: false + + - # Player has discovered a specific fact + properties: + type: { type: string, const: "fact_known_by_player" } + fact_id: { type: string } + required: [type, fact_id] + additionalProperties: false + + - # A module flag has been set + properties: + type: { type: string, const: "flag_set" } + flag: { type: string } + required: [type, flag] + additionalProperties: false + + - # NPC enters a specific mood state + properties: + type: { type: string, const: "npc_mood" } + npc_role: { type: string } + mood: + type: string + enum: [anxious, frustrated, content, suspicious, warm, hostile, relieved, focused] + required: [type, npc_role, mood] + additionalProperties: false + + # Event effects + + event_effect: + type: object + required: + - type + description: "A world change triggered by an event." + oneOf: + - # NPC deviates from their normal routine + properties: + type: { type: string, const: "npc_routine_deviation" } + npc_role: { type: string } + description: { type: string, description: "What the deviation looks like." } + duration_ticks: { type: integer } + required: [type, npc_role, description] + additionalProperties: false + + - # A fact becomes discoverable (moves to Rumoured confidence) + properties: + type: { type: string, const: "fact_becomes_discoverable" } + fact_id: { type: string } + discoverable_by: + type: string + enum: [smuggler, detective, any] + discovery_method: + type: string + description: "How the player can discover this. Authoring note." + required: [type, fact_id, discoverable_by] + additionalProperties: false + + - # NPC tell behavior becomes more pronounced + properties: + type: { type: string, const: "tell_intensify" } + npc_role: { type: string } + description: { type: string } + required: [type, npc_role] + additionalProperties: false + + - # A module-internal flag is set + properties: + type: { type: string, const: "flag_set" } + flag: { type: string, pattern: "^[a-z][a-z0-9_-]*$" } + required: [type, flag] + additionalProperties: false + + - # Something changes about a location + properties: + type: { type: string, const: "location_state" } + location: { type: string } + description: { type: string } + required: [type, location, description] + additionalProperties: false + + - # NPC's access to information changes + properties: + type: { type: string, const: "npc_knowledge_update" } + npc_role: { type: string } + fact_id: { type: string } + description: { type: string } + required: [type, npc_role, fact_id] + additionalProperties: false + + # Module outcome definition + + outcome: + type: object + required: + - outcome_id + - label + - is_terminal + additionalProperties: false + description: > + A resolution state the module can reach. Conditions are checked each tick. + The first matching outcome is applied. is_terminal = true ends the module. + properties: + outcome_id: + type: string + pattern: "^[a-z][a-z0-9_-]*$" + label: + type: string + description: + type: string + description: "What this outcome means narratively." + is_terminal: + type: boolean + description: "If true, this outcome ends the module permanently." + is_expiry: + type: boolean + default: false + description: > + If true, this is the quiet-exit outcome when the player never engages. + Every module must include exactly one expiry outcome. + conditions: + type: object + additionalProperties: false + description: "ALL conditions must be true to reach this outcome." + properties: + facts_known: + type: array + items: { type: string } + description: "Player must know all these facts." + facts_not_known: + type: array + items: { type: string } + description: "Player must NOT know any of these facts." + flags_set: + type: array + items: { type: string } + description: "All these module flags must be set." + flags_not_set: + type: array + items: { type: string } + description: "None of these module flags may be set." + events_fired: + type: array + items: { type: string } + description: "All these events must have fired." + ticks_since_activation: + type: integer + description: "Module has been active for at least this many ticks." + effects: + type: array + items: + $ref: "#/$defs/outcome_effect" + description: "Effects applied when this outcome is reached." + + # Outcome-level effects (broader scope than event effects) + + outcome_effect: + type: object + required: + - type + oneOf: + - # NPC disposition toward player changes + properties: + type: { type: string, const: "npc_disposition" } + npc_role: { type: string } + shift: + type: string + enum: [hostile, suspicious, neutral, friendly] + description: { type: string } + required: [type, npc_role, shift] + additionalProperties: false + + - # Faction reaction + properties: + type: { type: string, const: "faction_reaction" } + faction: { type: string } + reaction: + type: string + enum: [hostile, suspicious, neutral, friendly, grateful] + description: { type: string } + required: [type, faction, reaction] + additionalProperties: false + + - # Location becomes restricted or opens up + properties: + type: { type: string, const: "location_access_change" } + location: { type: string } + change: + type: string + enum: [restricted, locked, open] + description: { type: string } + required: [type, location, change] + additionalProperties: false + + - # A fact is now permanently known/unknown + properties: + type: { type: string, const: "fact_state" } + fact_id: { type: string } + state: + type: string + enum: [known, hidden, destroyed] + description: { type: string } + required: [type, fact_id, state] + additionalProperties: false + + - # NPC leaves the district or changes role + properties: + type: { type: string, const: "npc_exit" } + npc_role: { type: string } + description: { type: string } + required: [type, npc_role] + additionalProperties: false diff --git a/docs/design/tier1-module-authoring.md b/docs/design/tier1-module-authoring.md new file mode 100644 index 000000000..3606139f3 --- /dev/null +++ b/docs/design/tier1-module-authoring.md @@ -0,0 +1,450 @@ +# Tier 1 Drama Module — Authoring Guide + +**Schema:** `content/schemas/drama_module.schema.yaml` +**Module pool:** `content/modules/tier1/*.yaml` +**Decisions:** D-023 (three-tier model), D-027 (vertical slice), D-029 (30/50/20 population), D-034 (FRIEND pattern) +**Vertical slice reference:** `content/modules/tier1/smuggling_ring_v0_1.yaml` + +--- + +## What Is a Tier 1 Drama Module? + +Tier 1 is the authored conspiracy layer of D-023. Drama modules are the things that can go wrong — or go very right, or simply happen — beneath the surface of daily life in Sova Transit. They are: + +- **Hand-authored.** Every event sequence, every NPC role, every outcome was written by a person. +- **Pool-based.** Multiple modules exist. The storyteller draws from the pool at game start and activates a subset based on the district and the storyteller's pacing decisions. +- **Optional from the player's perspective.** The player can play 60 minutes without engaging the ring. The ring happens anyway. D-027 criterion #4: the observe→notice→follow→discover sequence must emerge from *systems*, not *scripts*. +- **Dual-lens.** Every module must be experienced differently by the smuggler and detective characters. Same world, different keyholes. + +What they are **not:** +- Not quests with markers or objectives. +- Not scripted cutscenes. +- Not balanced challenge encounters. + +The storyteller uses the module as a *schedule* — a series of world events it will fire, and conditions it monitors to determine how the world resolves. The player is a witness and agent in a world that moves with or without them. + +--- + +## File Structure + +``` +content/ + schemas/ + drama_module.schema.yaml ← Schema reference (this file validates against it) + modules/ + tier1/ + smuggling_ring_v0_1.yaml ← The v0.1 vertical slice module + future_module_v0_1.yaml ← Future modules go here +``` + +One `.yaml` file per drama module. The storyteller's content loader scans `content/modules/tier1/` at startup and adds all valid modules to the pool. + +--- + +## Field Reference + +### Identity Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `module_id` | Yes | Stable slug: `{name}_v{major}_{minor}`. Never reuse. Increment on breaking structural change. | +| `display_name` | Yes | Human-readable title for dev tooling. Not shown in-game. | +| `version` | Yes | Authoring version: `{major}.{minor}`. | +| `tier` | Yes | Always `1`. | +| `description` | No | One-paragraph design summary. Authoring-only. | +| `notes` | No | Design rationale, cross-references. Ignored at load time. | +| `dual_lens` | No | How smuggler vs detective experience this module. Authoring-only. **Write this first** — it disciplines the design. | + +--- + +### Pool Metadata + +Controls how the storyteller samples this module. + +| Field | Required | Description | +|-------|----------|-------------| +| `pool.weight` | Yes | Selection probability 1–10. Higher = more likely per playthrough. Default 5. | +| `pool.compatible_districts` | No | District slugs. Omit for "any". | +| `pool.incompatible_with` | No | Module IDs that can't run concurrently. | +| `pool.max_concurrent` | No | Almost always 1. | + +**Design note on weight:** Use weight to tune narrative variety, not difficulty. A weight-1 module is a rare playthrough surprise. A weight-8 module like the smuggling ring is "this is usually what's happening in Sova Transit." + +--- + +### Entry Conditions + +Defines when the module becomes eligible for activation. ALL world-state conditions must be true. The activation trigger determines *how* it fires. + +#### World-State Condition Types + +| Type | Required Fields | Use When | +|------|----------------|----------| +| `npc_present` | `role` | The module requires a specific NPC to be in the district. | +| `location_accessible` | `location` | The module requires a location the player can physically reach. | +| `fact_not_known` | `fact_id` | Module shouldn't activate if a precondition has already been discovered. | +| `no_active_module` | `module_id` | Prevents two incompatible modules running at once. | +| `fact_known` | `fact_id`, `known_by` | Module requires prior knowledge to make sense. | + +#### Player Conditions (Optional) + +Player conditions are *optional* — modules can and should activate without player engagement as a prerequisite. Use player conditions sparingly, only when the module literally cannot function without a minimum relationship state. + +#### Activation Triggers + +| Trigger | When to Use | +|---------|-------------| +| `storyteller_push` | Default. Storyteller activates on its own pacing. Most Tier 1 modules. | +| `proximity` | Module activates when player wanders near a key location. Useful for "stumble-upon" conspiracies. | +| `player_action` | Reserved for modules that require player initiation. Use rarely. | + +**The `min_play_ticks` field is load-bearing for D-027 criterion #1.** At approximately 1 tick/second, 30 minutes of play ≈ 1800 ticks. Set `min_play_ticks` to at least 1800. The vertical slice uses 2100 to give extra breathing room. + +--- + +### NPC Requirements + +Each module specifies its NPC slots. Roles are internal slugs used throughout the rest of the document. + +| Field | Required | Description | +|-------|----------|-------------| +| `role` | Yes | Module-internal slug. Kebab-case. Used in event triggers and outcome conditions. | +| `display_hint` | No | Authoring note: who this role is narratively. | +| `binding` | Yes | `named` (specific authored NPC) or `generated` (any matching NPC). | +| `named_npc` | Conditional | Required when `binding: named`. Short-form canonical ID: `npc:{slug}`. | +| `axes` | Conditional | Required when `binding: generated`. Axis constraints the NPC must satisfy. | +| `must_have_pattern` | No | Optional NPC pattern (D-024 System A). | +| `must_have_motivation` | No | Optional NPC motivation (D-024 System B). | +| `is_optional` | No | Default false. If true, module runs without this slot filled (degraded experience). | + +#### Named vs. Generated Bindings + +**Named bindings** reference specific hand-authored NPCs from the district. All v0.1 roles are named. This is the right choice for: +- THE FRIEND NPCs (D-034) — they have authored arcs, not generic behavior +- NPCs with unique relationships in the 5-triangle web +- Roles where voice, history, and moral weight matter + +**Generated bindings** are for future modules set in different districts or using procedurally generated NPCs. They use axis constraints: + +```yaml +axes: + - axis: secret + constraint: has_major_secret + - axis: contentment + constraint: min_contentment_-3 # Discontented, susceptible to opportunity +``` + +Constraint conventions: `has_{value}`, `min_{N}`, `not_{value}`. The server's NPC filter system interprets these. + +#### What "Roles" Are Not + +NPC roles in a drama module are **not** the same as NPC patterns (FRIEND, MIRROR, etc.) or motivations (HANDLER, WITNESS, etc.). Module roles are: +- Functional slots within the module's narrative (ring-leader, witness, evidence-holder) +- Module-local: "ring-leader" in the smuggling ring module ≠ "ring-leader" in any other module +- Used to reference the same NPC across events and outcomes without hardcoding the NPC slug + +#### NPC Pattern and Motivation Reference + +Patterns (System A, `must_have_pattern`) encode the NPC's thematic function in the player's experience: + +| Pattern | What It Means | +|---------|---------------| +| `FRIEND` | Emotionally complex anchor; the contradiction arc lives here (D-034) | +| `MIRROR` | Reflects the player character's own path back at them | +| `ANCHOR` | Reliable presence; stability the player can always return to | +| `GHOST` | Presence felt more than seen; past hangs over current events | +| `CATALYST` | Actions cause cascading effects on other NPCs | +| `THRESHOLD` | Gatekeeper; controls access to deeper information or relationships | +| `REMNANT` | Survivor of a prior event; carries knowledge others want buried | +| `SYSTEM` | Embodies an institution or faction rather than personal stakes | +| `NOBODY` | Genuinely flat; texture and atmosphere, no arc | + +Motivations (System B, `must_have_motivation`) encode why the NPC acts within the module's conspiracy: + +| Motivation | What It Means | +|------------|---------------| +| `HANDLER` | Organizes or directs others; the operational center | +| `WITNESS` | Knows something they haven't decided to act on | +| `TURNCOAT` | Wants out, or has already switched allegiance | +| `CIVILIAN` | No conspiracy involvement; proximity creates moral weight | +| `OPERATOR` | Executes tasks; functional cog in the system | +| `SKEPTIC` | Doubts the conspiracy exists; useful foil for investigation | + +**Full definitions and canonical usage:** `decisions/content.md` D-024. + +--- + +### Events + +Events are world-state changes the storyteller fires. They are not scripted player experiences — they happen in the world, and the player may or may not observe them. + +#### Sequences vs. Pools + +| Structure | Use For | +|-----------|---------| +| **Sequence** | Ordered narrative beats. Step N+1 becomes eligible only after step N fires. Use for character arcs. | +| **Pool** | Unordered ambient activity. The storyteller fires any eligible event at any time. Use for texture and background. | + +The vertical slice uses: +- `kael_exit_arc` (sequence) — Kael's ordered character arc +- `investigation_pressure` (sequence) — Parallel pressure escalation +- `ambient_ring_activity` (pool) — Background ring business that runs throughout + +Most modules should have 1-2 sequences plus 1 pool. + +#### Event Step Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `event_id` | Yes | Unique within module. Used in outcome conditions and `ticks_since_event` triggers. | +| `label` | No | Short human-readable label for dev tooling. | +| `description` | No | What happens narratively. Write this first — events should have a clear observable presence. | +| `triggers` | Yes | ANY trigger being true fires the event. Multiple triggers = OR logic. | +| `effects` | No | What changes in the world. | +| `once` | No | Default `true`. Set `false` for repeating events (ambient discrepancies, etc.). | +| `sets_flag` | No | Module-internal flag set when event fires. Used in outcome conditions. | + +#### Trigger Types + +| Type | Fires When | Key Fields | +|------|-----------|------------| +| `ticks_since_activation` | N ticks after module activated | `ticks` | +| `ticks_since_event` | N ticks after a previous event fired | `after_event`, `ticks` | +| `player_proximity` | Player near NPC/location | `target_type`, `target`, `radius_tiles` | +| `player_action` | Player interacts with target | `action`, `target_role` | +| `fact_known_by_player` | Player has discovered a fact | `fact_id` | +| `flag_set` | A module flag has been set | `flag` | +| `npc_mood` | NPC enters a mood state | `npc_role`, `mood` | + +**Design principle: events should fire without the player.** Every event must have at least one tick-based trigger (`ticks_since_activation` or `ticks_since_event`). Proximity and action triggers are secondary paths that fire the event *earlier* if the player engages. The world moves at its own pace; the player accelerates or delays, not controls. + +#### Effect Types + +| Type | Use For | +|------|---------| +| `npc_routine_deviation` | Visible NPC behavior change. Write this descriptively — it's what the player sees. | +| `fact_becomes_discoverable` | Gates a fact into the knowledge graph at Rumoured confidence. | +| `tell_intensify` | NPC's tell behavior becomes more frequent/pronounced. | +| `flag_set` | Internal state tracking. Not visible to player. | +| `location_state` | Something visible changes in a location. | +| `npc_knowledge_update` | An NPC learns something new. | + +**On `fact_becomes_discoverable`:** This makes a fact discoverable, not known. The player still has to find it — through proximity, examination, dialogue, or observation. The `discovery_method` field is an authoring note for how: be specific enough that a Mellanie can write the dialogue or monologue that surfaces it, and a Gestalt can define the trigger condition in the fact catalog. + +**Fact ID convention:** Use `{module-slug}.{fact_name}` — e.g., `ring.kael_unauthorized_corridor_access`. The module slug prefix namespaces the fact to avoid collisions across modules. Before creating a new fact ID, check `content/global/knowledge/` to see if an equivalent fact already exists; reuse it rather than creating a duplicate. + +**Mapping `discovery_method` to D-035 trigger types:** The `discovery_method` note should describe exactly how the player triggers fact discovery. This maps directly to the D-035 monologue trigger taxonomy (full list in `decisions/content.md` D-035 and `content/global/enums/triggers.yaml`): + +| If discovery happens via… | D-035 trigger type | What to author | +|--------------------------|-------------------|----------------| +| Player enters the location where something is visible | `enter_location` | Monologue line flagging the anomaly on arrival | +| Player watches an NPC doing something unusual | `observe_npc` | Monologue line on NPC observation; dialogue option unlocks | +| Player examines an object or terminal | `observe_anomaly` | Examine verb interaction; monologue on result | +| Player witnesses two NPCs interacting | `witness_interaction` | Monologue line; trust-gated gossip unlock | +| Player finishes a conversation with the relevant NPC | `post_conversation` | Monologue beat after talking to the NPC | +| Player discovers a physical object (cargo, message) | `discover_evidence` | Examine verb; monologue on discovery | +| Player returns to a location they've been before | `return_visit` | Monologue on changed state vs. prior visit | + +Write the `discovery_method` note to specify which of these applies — ideally two methods for redundancy (e.g., `enter_location` plus `observe_anomaly`) so players aren't funneled into a single approach. + +--- + +### Outcomes + +Outcomes are resolution states. The storyteller checks all outcome conditions each tick after the module activates. The first matching outcome is applied. + +**Every module must include:** +- At least one terminal outcome that represents "the investigation succeeded" +- At least one terminal outcome that represents "the conspiracy ran its course" +- Exactly one expiry outcome (`is_expiry: true`) for quiet player non-engagement + +#### Outcome Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `outcome_id` | Yes | Unique slug. | +| `label` | Yes | Short label. | +| `is_terminal` | Yes | `true` = module ends. `false` = transitional state (module can continue evolving). | +| `is_expiry` | No | `true` = this is the quiet-exit outcome. One per module. | +| `conditions` | No | ALL conditions must be true. See below. | +| `effects` | No | World changes when outcome is reached. | + +**On `is_terminal: false`:** A non-terminal outcome fires its effects and applies its label, but the module remains active — the storyteller keeps checking for the next matching outcome. Use this for intermediate states where the world has visibly shifted but the situation hasn't resolved: the `ring_splinters` outcome in the vertical slice is non-terminal because the ring going quiet is a change of state, not a conclusion. A module with only non-terminal outcomes will run forever; always ensure there is a reachable terminal outcome (or expiry) downstream. + +#### Outcome Conditions + +| Condition | Description | +|-----------|-------------| +| `facts_known` | Player must know all listed facts. | +| `facts_not_known` | Player must NOT know any listed facts. | +| `flags_set` | All listed module flags must be set. | +| `flags_not_set` | None of listed flags may be set. | +| `events_fired` | All listed events must have fired. | +| `ticks_since_activation` | Module has been running for at least N ticks. | + +#### Outcome Effects + +| Type | Description | +|------|-------------| +| `npc_disposition` | NPC's relationship state with player shifts. | +| `faction_reaction` | Faction reputation change. | +| `location_access_change` | Location becomes restricted, locked, or open. | +| `fact_state` | Fact is permanently known, hidden, or destroyed. | +| `npc_exit` | NPC leaves the district or becomes inaccessible. | + +--- + +## Design Principles for Tier 1 Modules + +### 1. The World Moves First + +Events happen on a tick schedule. The player is a witness who can accelerate, delay, or redirect — not a trigger. If your module can only function if the player takes specific actions, it's a quest, not a drama module. + +### 2. Both Characters Must Have a Story + +Every event and outcome must mean something different to the smuggler and the detective. Write the `dual_lens` authoring field first. If you can't write both lenses, the module is character-agnostic filler — not Tier 1. + +### 3. No Clean Resolutions + +D-034 and D-027 both require moral ambiguity. The smuggling ring doesn't have a "good" ending. The detective arresting Kael is not obviously better than letting him go. Every outcome must have a cost. If one outcome is obviously correct, you've failed the design. + +### 4. THE FRIEND Contradiction Is the Pivot + +If your module involves a FRIEND-pattern NPC, the observable contradiction (D-034) must be: +- **Observable from spatial positioning** — not from dialogue, not from menus +- **Ambiguous before context** — the player sees the behavior before they understand what it means +- **Irreversible once witnessed** — seeing changes the relationship, even if the player does nothing + +The secret meeting in corridor B-7 is the canonical example. After witnessing it, neither character can pretend they don't know what they saw. + +### 5. Expiry Is Not Failure + +The `module_abandoned` expiry outcome should feel like a natural ending, not a penalty. The world closes around this conspiracy without the player. That's the 70% mundane reality (D-029): most conspiracies don't get protagonists. Write the expiry description to feel melancholy but not punitive. + +### 6. Facts, Not Flags, Drive Investigation + +Facts (from `global/knowledge/`) are the player's knowledge graph. Flags are the storyteller's internal state tracking. The key design question: "Is this something the player knows, or is this something the storyteller tracks?" If the player knows it, it's a fact. If the storyteller tracks it, it's a flag. + +Facts should be discoverable through multiple methods (observation, dialogue, examination, proximity). Never require a single specific action to surface a critical fact. + +--- + +## Validation and Format Rules (Gestalt) + +These rules cover the schema's format constraints and the validation gaps that JSON Schema cannot enforce. All of these are also caught by Tier 2 build-time validation (`make validate-content`), but catching them during authoring saves a pipeline run. + +### ID and Slug Formats + +| Field | Regex | Example | +|-------|-------|---------| +| `module_id` | `^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$` | `smuggling_ring_v0_1` | +| `sequence_id`, `pool_id` | `^[a-z][a-z0-9_-]*$` | `kael_exit_arc` | +| `event_id` | `^[a-z][a-z0-9_-]*$` | `kael_goes_cold` | +| `outcome_id` | `^[a-z][a-z0-9_-]*$` | `ring_exposed` | +| `sets_flag` / flag references | `^[a-z][a-z0-9_-]*$` | `kael_behavior_changed` | +| `role` (npc slot) | `^[a-z][a-z0-9-]*$` | `ring-member-exiting` | +| `named_npc` | `^npc:[a-z][a-z0-9-]*$` | `npc:kael-davan` | +| `version` | `^[0-9]+\\.[0-9]+$` | `0.1` | + +Note the difference: `event_id`, `outcome_id`, `sequence_id`, and flags use underscores and hyphens (`[a-z0-9_-]*`). NPC `role` slugs use hyphens only (`[a-z0-9-]*`). Mixing them in wrong fields will fail schema validation. + +### Flag Naming Convention + +Flags are module-internal state. Every flag name that appears in `sets_flag` on an event **must** also appear in at least one outcome's `flags_set` or `flags_not_set` condition — or the flag serves no purpose. Convention: + +- Use `snake_case` with underscores: `kael_behavior_changed`, `voss_pressure_applied` +- Name by what happened, not what it enables: `handler_pressure_applied` not `kael_ready_to_flee` +- Flags set by events accumulate — they are never automatically cleared +- A flag set by a time-triggered event (not player-triggered) cannot be used as an expiry gate (see "Common Mistakes" below) + +### Axis Constraint Syntax (Generated NPC Bindings) + +The `constraint` field in `axes` is a freeform string. The storyteller's NPC filter interprets it. Convention (author responsibility — schema does not enforce): + +| Prefix | Example | Meaning | +|--------|---------|---------| +| `has_` | `has_major_secret` | NPC axis value includes this descriptor | +| `min_contentment_` | `min_contentment_-3` | Contentment axis value ≤ N (more discontented) | +| `not_` | `not_combat_trained` | Axis value does NOT include this descriptor | +| `is_` | `is_ring_member` | Boolean flag set on NPC profile | + +### What JSON Schema Cannot Validate (Tier 2 Catches These) + +| Issue | Where to Look | Impact | +|-------|--------------|--------| +| `fact_id` not defined in `global/knowledge/` | Effect `fact_becomes_discoverable`, outcome `facts_known` | Fact silently never becomes discoverable | +| `sets_flag` name not referenced in any outcome condition | Event `sets_flag` | Flag is set but never meaningful | +| `flags_set`/`flags_not_set` reference flag never set by any event | Outcome conditions | Condition permanently true or false | +| `ticks_since_event.after_event` references unknown event_id | Event trigger | Trigger never fires | +| `named_npc` ID doesn't exist in district NPC profiles | NPC requirements | Load-time failure | +| Multiple outcomes have `is_expiry: true` | Outcomes list | Undefined storyteller behavior | +| `faction` in outcome effects not in `global/factions/` | Outcome effects | Effect silently ignored | + +### The Expiry Condition Pitfall + +This is the most common authoring mistake for expiry outcomes. **The expiry condition must use `facts_not_known`, not `flags_not_set`.** Reason: + +Events with `ticks_since_activation` triggers fire automatically without player engagement. If an auto-firing event sets a flag, and your expiry checks `flags_not_set: [that_flag]`, the expiry condition becomes permanently false after the event fires — the module can never expire quietly. + +**Wrong:** +```yaml +# kael_goes_cold fires automatically at tick 300, sets kael_behavior_changed +# This expiry can never fire after tick 300 +- outcome_id: module_abandoned + is_expiry: true + conditions: + flags_not_set: + - kael_behavior_changed # This flag is always set by tick 300 + ticks_since_activation: 5400 +``` + +**Correct:** +```yaml +# facts_not_known gates on player investigative action, not auto-fired events +- outcome_id: module_abandoned + is_expiry: true + conditions: + facts_not_known: + - "ring.cargo_discrepancy_pattern" # Only known if player examined terminal + - "ring.kael_unauthorized_corridor_access" # Only known if player observed Kael + ticks_since_activation: 5400 +``` + +--- + +## Checklist Before Submitting a New Module + +- [ ] `module_id` uses correct format and doesn't collide with existing modules +- [ ] `dual_lens` is written and shows clearly different experiences per character +- [ ] `min_play_ticks` ≥ 1800 (30 minutes at 1 tick/second) +- [ ] Every event sequence step has at least one tick-based trigger +- [ ] Every `fact_becomes_discoverable` effect has a `discovery_method` note +- [ ] The module includes at least one named FRIEND-pattern NPC (for v0.1 modules) +- [ ] Expiry outcome is present (`is_expiry: true`) with conditions gated on `facts_not_known`, NOT `flags_not_set` +- [ ] All outcomes have been reviewed for moral ambiguity — no "obviously correct" resolution +- [ ] `npc_requirements` covers every role referenced in events and outcomes +- [ ] All fact IDs used in effects/conditions exist in `global/knowledge/` +- [ ] All `sets_flag` names appear in at least one outcome condition +- [ ] All `flags_set`/`flags_not_set` names are set by at least one event's `sets_flag` +- [ ] `make validate-content` passes + +--- + +## Cross-References + +| Topic | Location | +|-------|----------| +| Three-tier content model | `decisions/content.md` D-023 | +| NPC 10-axis model | `decisions/content.md` D-024 | +| Vertical slice scope | `decisions/scope.md` D-027 | +| Population ratios | `decisions/content.md` D-029 | +| THE FRIEND pattern | `decisions/content.md` D-034 | +| Smuggling ring module | `content/modules/tier1/smuggling_ring_v0_1.yaml` | +| Drama module schema | `content/schemas/drama_module.schema.yaml` | +| Fact catalog | `content/global/knowledge/` | +| NPC profiles (v0.1) | `content/campaigns/main/systems/krenn/` | +| Storyteller stub | `server/src/storyteller/mod.rs` | + +--- + +*Ticket #158 — Tier 1 drama module schema. Paula (dramatic structure), Gestalt (schema format), Mellanie (authoring review).* From 2493dcb5f0740e5845d3db81f7b48b79e475a509 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 02:27:57 +0100 Subject: [PATCH 3/5] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8fd6a61a..dcf74165c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- 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) From c55474f46d440a235a4c04f549b8bc5b4ec1c9aa Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 02:42:43 +0100 Subject: [PATCH 4/5] =?UTF-8?q?fix(content):=20PR=20#64=20review=20?= =?UTF-8?q?=E2=80=94=20schema=20compliance=20and=20orphaned=20flags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix events_fired referencing flag names instead of event IDs (two outcomes unreachable). Wire 4 orphaned flags into narratively coupled outcome conditions. Fix typo and stale question count. Co-Authored-By: Claude Opus 4.6 --- .../modules/tier1/smuggling_ring_v0_1.yaml | 21 ++++++++++++------- decisions/README.md | 2 +- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/content/modules/tier1/smuggling_ring_v0_1.yaml b/content/modules/tier1/smuggling_ring_v0_1.yaml index 64526e8cb..7834b25a3 100644 --- a/content/modules/tier1/smuggling_ring_v0_1.yaml +++ b/content/modules/tier1/smuggling_ring_v0_1.yaml @@ -455,7 +455,7 @@ events: fact_id: "ring.sera_avoidance_behavior" discoverable_by: detective discovery_method: > - Observer Sera's path through the bar on two separate occasions. + Observe Sera's path through the bar on two separate occasions. Requires forward vision cone and awareness of her baseline route. once: false @@ -504,8 +504,9 @@ outcomes: - "ring.cargo_discrepancy_pattern" - "ring.kael_unauthorized_corridor_access" - "ring.voss_kael_private_meeting" - events_fired: - - secret_meeting_occurred + 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 @@ -544,8 +545,9 @@ outcomes: facts_known: - "ring.kael_unauthorized_corridor_access" flags_set: - - secret_meeting_occurred - - voss_pressure_applied + - 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. @@ -575,8 +577,9 @@ outcomes: The smuggler completes their last run and waits to see if there's another. Unsatisfying only if you expected a tidy resolution. The world moved on. conditions: - events_fired: - - final_shipment_known + flags_set: + - final_shipment_known # set by final_shipment_scheduled event + - timeline_accelerating # set by maret_increases_presence — Maret forced their hand flags_not_set: - kael_message_sent # If this is NOT set, Kael's cooperation went unnoticed ticks_since_activation: 3600 # Module ran for ~60 minutes without full exposure @@ -610,7 +613,9 @@ outcomes: facts_known: - "ring.cargo_discrepancy_pattern" events_fired: - - kael_goes_cold + - kael_goes_cold # event ID — Kael's behavioral shift fired + flags_set: + - kael_missed_drop # set by drop_happens_without_kael — ring destabilized flags_not_set: - kael_message_sent ticks_since_activation: 2400 diff --git a/decisions/README.md b/decisions/README.md index eed582653..fa69fe58a 100644 --- a/decisions/README.md +++ b/decisions/README.md @@ -15,7 +15,7 @@ Cross-domain decisions live in one file with cross-reference notes in related fi | [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 From cc898d60c7a7be1df445ca836ab7c7e57fffdb4e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 09:49:09 +0100 Subject: [PATCH 5/5] =?UTF-8?q?fix(content):=20PR=20#64=20round=202=20?= =?UTF-8?q?=E2=80=94=20NPC=20ID,=20outcome=20gating,=20fact=20stubs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix npc:nils → npc:nils-davan canonical ID mismatch. Replace auto-flag gates on ring_completes/ring_splinters with player- action-gated fact conditions (unreachable outcomes). Add ring_stalemate terminal exit for non-terminal ring_splinters. Create ring.yaml with 10 fact stubs for content/global/knowledge/. Co-Authored-By: Claude Opus 4.6 --- content/global/knowledge/ring.yaml | 106 ++++++++++++++++++ .../modules/tier1/smuggling_ring_v0_1.yaml | 48 ++++++-- 2 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 content/global/knowledge/ring.yaml diff --git a/content/global/knowledge/ring.yaml b/content/global/knowledge/ring.yaml new file mode 100644 index 000000000..fc084e20f --- /dev/null +++ b/content/global/knowledge/ring.yaml @@ -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. diff --git a/content/modules/tier1/smuggling_ring_v0_1.yaml b/content/modules/tier1/smuggling_ring_v0_1.yaml index 7834b25a3..9e968575c 100644 --- a/content/modules/tier1/smuggling_ring_v0_1.yaml +++ b/content/modules/tier1/smuggling_ring_v0_1.yaml @@ -131,7 +131,7 @@ npc_requirements: Handles physical drops. Not a speaking character — observable behavior only. Can be the anonymous contact Kael meets. binding: named - named_npc: "npc:nils" + named_npc: "npc:nils-davan" is_optional: false - role: institutional-watcher @@ -580,9 +580,11 @@ outcomes: flags_set: - final_shipment_known # set by final_shipment_scheduled event - timeline_accelerating # set by maret_increases_presence — Maret forced their hand - flags_not_set: - - kael_message_sent # If this is NOT set, Kael's cooperation went unnoticed + 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 @@ -611,14 +613,14 @@ outcomes: faces an awkward return to normalcy. Both know the ring isn't gone — just quiet. conditions: facts_known: - - "ring.cargo_discrepancy_pattern" + - "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 - flags_not_set: - - kael_message_sent 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 @@ -632,8 +634,38 @@ outcomes: reaction: suspicious description: "The ring is alerted to exposure risk. Future operations will be more careful." - # 5. MODULE EXPIRY (quiet exit) - # Player never engaged. Module times out without drama. + # 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.