# Story flow graph: the mystery editor Status: **implemented** (migrations `018`–`021`). A mystery plays as a walk of an authored node graph; the slot/chapter model has been retired in a clean cutover. Gates are stubbed and the LLM gate is not built (see *Implemented vs deferred*). Builds on the NPC/dialogue work in [narrative-todo.md](narrative-todo.md). ## Concept A mystery is a **directed graph of nodes**. Each node has one implicit input and N output **terminals**; a terminal carries its single outgoing wire (`to_node_id`) — there is no separate edges table. A playthrough walks the graph from the mystery's single **entrypoint node** (`mysteries.entry_node_id`). - At the **node level** cycles are allowed (a gate can route back to a level). The runtime only advances a node when the player acts, so loops never spin on their own. - Within a **dialogue node** the utterances form a **tree** (each utterance has one parent), so back-edges/loops are not expressible there yet. ## Node types | type | what it does | terminals | |---|---|---| | `cutscene` | Renders a bespoke React component chosen from a frontend registry (`component_key`), e.g. a title card. Opaque to the graph so set-pieces don't clutter the dialogue tree. | usually 1 (`continue`) | | `dialogue` | The standard NPC dialogue box, driven by an utterance tree (NPC lines + player choices). | 1+ (e.g. `continue`, or `proceed`/`retry` for a branch) | | `level` | Instantiates a playable board from a level template version and hands over to the investigation. | 1 (`report_back`) | | `det_gate` | Deterministic gate. **Currently a stub**: auto-follows its first terminal. Intended to inspect the previous node's output (a level's case report, a dialogue's chosen path) and route accordingly. | 1+ | | `llm_gate` | LLM gate — **not implemented**. Intended: read allowlisted player state, return one terminal key (constrained output). | 1+ | Gates are resolved server-side during `advance` and never surfaced to the player. ## Data model Two core tables plus `utterances`; type-specific scalars are folded onto the node (kept honest by CHECKs) rather than in per-type subtype tables. ```sql CREATE TABLE osint.story_nodes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), mystery_id UUID NOT NULL REFERENCES osint.mysteries(id) ON DELETE CASCADE, node_type TEXT NOT NULL CHECK (node_type IN ('cutscene','dialogue','level','det_gate','llm_gate')), label TEXT NOT NULL DEFAULT '', has_utterances BOOLEAN NOT NULL DEFAULT FALSE, xpos DOUBLE PRECISION NOT NULL, ypos DOUBLE PRECISION NOT NULL, -- Folded, type-specific scalars. Nullable during authoring; "required for type" -- is a publish-time concern, so only these exclusion CHECKs are enforced. level_template_version_id UUID REFERENCES osint.level_template_versions(id), component_key TEXT, -- cutscene: frontend component; gate: (future) backend gate fn CHECK (level_template_version_id IS NULL OR node_type = 'level'), CHECK (component_key IS NULL OR node_type IN ('cutscene','det_gate','llm_gate')) ); -- Output ports. A terminal owns its single outgoing wire; no edges table. CREATE TABLE osint.story_node_terminals ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), parent_node_id UUID NOT NULL REFERENCES osint.story_nodes(id) ON DELETE CASCADE, terminal_key TEXT NOT NULL, label TEXT NOT NULL DEFAULT '', to_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL, -- NULL = unwired/end sort_order INTEGER NOT NULL DEFAULT 0, UNIQUE (parent_node_id, terminal_key) ); -- Same-mystery integrity for to_node_id is enforced in the repository (not a -- DB trigger). entry_node_id is nullable so nodes can be inserted before it is set. ALTER TABLE osint.mysteries ADD COLUMN entry_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL; ``` ### Utterances — a dialogue node's content One table for both NPC lines and player choices, distinguished by `utterer`. ```sql CREATE TABLE osint.utterances ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), node_id UUID NOT NULL REFERENCES osint.story_nodes(id) ON DELETE CASCADE, utterer TEXT NOT NULL DEFAULT 'npc' CHECK (utterer IN ('npc','player')), npc_id UUID REFERENCES osint.npcs(id), -- speaker for NPC lines; NULL for player choices pose_key TEXT, -- resolved with the usual pose fallback text TEXT NOT NULL DEFAULT '', parent_utterance_id UUID REFERENCES osint.utterances(id) ON DELETE CASCADE, -- the utterance this one follows advances_to_utterance_id UUID REFERENCES osint.utterances(id) ON DELETE SET NULL, -- VESTIGIAL / unused (see below) terminal_id UUID REFERENCES osint.story_node_terminals(id) ON DELETE SET NULL, -- exit: leave the node via this terminal effect TEXT, -- reserved side-effect hook (unused) xpos DOUBLE PRECISION NOT NULL DEFAULT 0, -- position in the utterance sub-canvas ypos DOUBLE PRECISION NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0 ); ``` **The model is parent-only with count-based meaning.** An utterance's *children* (the utterances whose `parent_utterance_id` points to it) are what come after it: - **0 children** + `terminal_id` set ⇒ this line **exits** the node via that terminal. - **1 child** ⇒ **linear** next line (rendered as a solid wire). - **2+ children** ⇒ **player options** (rendered as dotted wires); by convention the children are `player` utterances and the parent is an NPC prompt. The root of a node's tree is the utterance with no parent. `advances_to_utterance_id` is a leftover column from an earlier design and is **not used**; a follow-up migration can drop it. Because each utterance has a single parent the graph is a tree — no back-edges/loops within a node yet. ### Cutscene component registry (frontend) Mirrors the exhibit registry: `component_key → React component`. Each owns its presentation and signals completion with an optional terminal key. ```ts type CutsceneComponent = React.FC<{ onComplete: (terminalKey?: string) => void }> // registry: { 'glass-harbour-diversion': GlassHarbourDiversion } ``` For a single-terminal cutscene, `onComplete()` follows the only terminal. ## Editing UX The same canvas engine (pan, zoom, drag, curved wires, click-a-wire-to-delete) is reused at two levels. **Mystery graph** — `story_nodes` as cards, wired by `terminal.to_node_id`. Laid out **vertically**: the input port is on **top**, output terminals along the **bottom**, and flow runs downward. Double-clicking a dialogue node drills into its utterances. **Utterance crafter** — a dialogue node's utterances as draggable cards; the node's output terminals appear as **exit sinks** docked to the right. Each card has one output port: - Drag a card's port to another card ⇒ that card becomes a **child** (1 child = solid linear; 2+ = dotted options). - Drag to an exit sink ⇒ the card leaves the node via that `terminal_id`. - **Tab** on a selected utterance adds a child (a lone child stays a linear NPC line; a second flips them to player options). **1** / **2** set the selected card's speaker. **Ctrl/Cmd+Z** undoes connection/creation edits. Cards colour by speaker and auto-expand to full text. ## Runtime traversal The playthrough tracks position with `current_node_id` (and `current_level_id`, set while on a level node); the slot fields were dropped. ```sql ALTER TABLE osint.playthroughs ADD COLUMN current_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL; ``` - **New Game** creates a playthrough at the entrypoint (bound to the JWT identity, with a dev test-user fallback). - **`getCurrentPlaythrough`** returns the resolved current node: a cutscene (`componentKey`), a level (`levelSlug`), or a **dialogue tree** (all utterances with resolved speaker/pose, ordered `childIds`, and each exit's `terminalKey`, plus the `rootId`). - **`advance(terminalKey?)`** follows a terminal, **auto-skips gates** (the stub det_gate takes its first terminal), instantiates the board when entering a level, and finishes when a followed terminal has no target. - **Dialogue is walked on the client** (`DialoguePlayer`): play NPC lines with the typewriter; at a branch (2+ player children) present choice buttons; a chosen child leads to its next line or, if it has a `terminalKey`, calls `advance(terminalKey)` to leave the node — routing the graph to a different next node. ## Glass Harbour seed Authored in `mysteries/glass-harbor/mystery.json` (`narrative.graph`) and seeded by the importer, so it survives re-imports. It is linear: ``` [cutscene: glass-harbour-diversion] → [dialogue: Briefing] → [level: glass-harbor] → [dialogue: Debrief] → (end) ``` Branching (player options routing to different terminals) is supported and tested, just not used in the seed. ## Implemented vs deferred **Implemented:** graph schema, node/terminal/utterance CRUD + admin editors, the runtime cutover (cutscene / dialogue-tree / level traversal, branching dialogue), the vertical mystery-graph canvas, the utterance crafter, and the seed. **Deferred:** - **Real gates.** `det_gate` is a hardcoded "first terminal" stub; `llm_gate` is unbuilt. Both want the **Case Report / Claims** (Milestone 5) as input, plus a gate-function registry keyed like cutscene components. - **Within-node loops** (an utterance's single parent makes each dialogue a tree). - **Drop `advances_to_utterance_id`** and the reserved `effect` column. - **Cutscene params** and real pose art. ## Validation rules - One `entry_node_id` per mystery (soft-recommended to be a `cutscene`). - A terminal's `to_node_id` must share its parent node's mystery (repo-enforced). - An utterance's `terminal_id` must belong to its node; parent links stay in-node. - Unreachable nodes and unwired terminals are allowed (deliberate ends).