# 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<{ label: string presentation?: CutscenePresentation | null onComplete: (terminalKey?: string) => void }> // registry: { 'glass-harbour-diversion': GlassHarbourDiversion } ``` For a single-terminal cutscene, `onComplete()` follows the only terminal. Report-aware cutscenes receive server-resolved, read-only presentation data. The `case-adjudication` component uses the accepted report associated with the most recent level; it never evaluates or changes the verdict itself. ## 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`. `current_level_id` points to the most recently instantiated level until another level replaces it or the playthrough ends. Retaining that reference lets immediately downstream cutscenes and dialogue reload presentation data from the completed investigation. ```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).