2026-08-18 15:37:46 +02:00
# Story flow graph: the mystery editor
2026-08-18 16:28:11 +02:00
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 ).
2026-08-18 15:37:46 +02:00
## Concept
2026-08-18 16:28:11 +02:00
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` ).
2026-08-18 15:37:46 +02:00
2026-08-18 16:28:11 +02:00
- 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.
2026-08-18 15:37:46 +02:00
## Node types
| type | what it does | terminals |
|---|---|---|
2026-08-18 16:28:11 +02:00
| `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+ |
2026-08-18 15:37:46 +02:00
2026-08-18 16:28:11 +02:00
Gates are resolved server-side during `advance` and never surfaced to the player.
2026-08-18 15:37:46 +02:00
## Data model
2026-08-18 16:28:11 +02:00
Two core tables plus `utterances` ; type-specific scalars are folded onto the node
(kept honest by CHECKs) rather than in per-type subtype tables.
2026-08-18 15:37:46 +02:00
```sql
CREATE TABLE osint . story_nodes (
2026-08-18 16:28:11 +02:00
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
2026-08-18 15:37:46 +02:00
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 ,
2026-08-18 16:28:11 +02:00
-- Folded, type-specific scalars. Nullable during authoring; "required for type"
-- is a publish-time concern, so only these exclusion CHECKs are enforced.
2026-08-18 15:37:46 +02:00
level_template_version_id UUID REFERENCES osint . level_template_versions ( id ),
2026-08-18 16:28:11 +02:00
component_key TEXT , -- cutscene: frontend component; gate: (future) backend gate fn
2026-08-18 15:37:46 +02:00
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' ))
);
2026-08-18 16:28:11 +02:00
-- Output ports. A terminal owns its single outgoing wire; no edges table.
2026-08-18 15:37:46 +02:00
CREATE TABLE osint . story_node_terminals (
2026-08-18 16:28:11 +02:00
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
2026-08-18 15:37:46 +02:00
parent_node_id UUID NOT NULL REFERENCES osint . story_nodes ( id ) ON DELETE CASCADE ,
terminal_key TEXT NOT NULL ,
label TEXT NOT NULL DEFAULT '' ,
2026-08-18 16:28:11 +02:00
to_node_id UUID REFERENCES osint . story_nodes ( id ) ON DELETE SET NULL , -- NULL = unwired/end
2026-08-18 15:37:46 +02:00
sort_order INTEGER NOT NULL DEFAULT 0 ,
UNIQUE ( parent_node_id , terminal_key )
);
2026-08-18 16:28:11 +02:00
-- 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 ;
2026-08-18 15:37:46 +02:00
```
2026-08-18 16:28:11 +02:00
### Utterances — a dialogue node's content
2026-08-18 15:37:46 +02:00
2026-08-18 16:28:11 +02:00
One table for both NPC lines and player choices, distinguished by `utterer` .
2026-08-18 15:37:46 +02:00
```sql
CREATE TABLE osint . utterances (
2026-08-18 16:28:11 +02:00
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
2026-08-18 15:37:46 +02:00
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
2026-08-18 16:28:11 +02:00
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
2026-08-18 15:37:46 +02:00
ypos DOUBLE PRECISION NOT NULL DEFAULT 0 ,
sort_order INTEGER NOT NULL DEFAULT 0
);
```
2026-08-18 16:28:11 +02:00
**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.
2026-08-18 15:37:46 +02:00
### Cutscene component registry (frontend)
2026-08-18 16:28:11 +02:00
Mirrors the exhibit registry: `component_key → React component` . Each owns its
presentation and signals completion with an optional terminal key.
2026-08-18 15:37:46 +02:00
```ts
type CutsceneComponent = React . FC < { onComplete : ( terminalKey? : string ) => void } >
2026-08-18 16:28:11 +02:00
// registry: { 'glass-harbour-diversion': GlassHarbourDiversion }
2026-08-18 15:37:46 +02:00
```
For a single-terminal cutscene, `onComplete()` follows the only terminal.
2026-08-18 16:28:11 +02:00
## Editing UX
2026-08-18 15:37:46 +02:00
2026-08-18 16:28:11 +02:00
The same canvas engine (pan, zoom, drag, curved wires, click-a-wire-to-delete) is
reused at two levels.
2026-08-18 15:37:46 +02:00
2026-08-18 16:28:11 +02:00
**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.
2026-08-18 15:37:46 +02:00
2026-08-18 16:28:11 +02:00
**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:
2026-08-18 15:37:46 +02:00
2026-08-18 16:28:11 +02:00
- 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.
2026-08-18 15:37:46 +02:00
2026-08-18 16:28:11 +02:00
## Runtime traversal
2026-08-18 15:37:46 +02:00
2026-08-18 16:28:11 +02:00
The playthrough tracks position with `current_node_id` (and `current_level_id` ,
set while on a level node); the slot fields were dropped.
2026-08-18 15:37:46 +02:00
```sql
2026-08-18 16:28:11 +02:00
ALTER TABLE osint . playthroughs ADD COLUMN current_node_id UUID REFERENCES osint . story_nodes ( id ) ON DELETE SET NULL ;
2026-08-18 15:37:46 +02:00
```
2026-08-18 16:28:11 +02:00
- **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.
2026-08-18 15:37:46 +02:00
2026-08-18 16:28:11 +02:00
## Glass Harbour seed
2026-08-18 15:37:46 +02:00
2026-08-18 16:28:11 +02:00
Authored in `mysteries/glass-harbor/mystery.json` (`narrative.graph` ) and seeded by
the importer, so it survives re-imports. It is linear:
2026-08-18 15:37:46 +02:00
```
2026-08-18 16:28:11 +02:00
[cutscene: glass-harbour-diversion] → [dialogue: Briefing] → [level: glass-harbor] → [dialogue: Debrief] → (end)
2026-08-18 15:37:46 +02:00
```
2026-08-18 16:28:11 +02:00
Branching (player options routing to different terminals) is supported and tested,
just not used in the seed.
2026-08-18 15:37:46 +02:00
2026-08-18 16:28:11 +02:00
## Implemented vs deferred
2026-08-18 15:37:46 +02:00
2026-08-18 16:28:11 +02:00
**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.
2026-08-18 15:37:46 +02:00
2026-08-18 16:28:11 +02:00
**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.
2026-08-18 15:37:46 +02:00
## Validation rules
2026-08-18 16:28:11 +02:00
- 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).