- migration 022 drops utterances.advances_to_utterance_id and .effect, which the
parent-only/child-count dialogue model never used; purge their references.
- rewrite docs/story-graph.md to match what was built (parent-child utterances,
vertical mystery graph, graph runtime, gate stubs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Status: design proposal for discussion. Supersedes the slot/chapter progression
Status: **implemented** (migrations `018`–`021`). A mystery plays as a walk of an
model (`mystery_chapters` + `cutscenes.slot` + `selectPendingCutscene`). Builds on
authored node graph; the slot/chapter model has been retired in a clean cutover.
the NPC/dialogue/cutscene work in [narrative-todo.md](narrative-todo.md).
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
## Concept
A mystery is authored as a **directed flow graph** of nodes. The author drags
A mystery is a **directed graph of nodes**. Each node has one implicit input and N
nodes on a canvas and connects an **output terminal** of one node to another node
output **terminals**; a terminal carries its single outgoing wire (`to_node_id`) —
with a directed, curved edge. A playthrough is a walk of that graph.
there is no separate edges table. A playthrough walks the graph from the mystery's
single **entrypoint node** (`mysteries.entry_node_id`).
-It is a **graph, not a tree**: gates may route back to earlier nodes ("not
-At the **node level** cycles are allowed (a gate can route back to a level). The
convincing → keep investigating"), so cycles are expected and intended. The
runtime only advances a node when the player acts, so loops never spin on their own.
runtime only re-traverses a node when the player acts again, so loops never spin
- Within a **dialogue node** the utterances form a **tree** (each utterance has one
on their own.
parent), so back-edges/loops are not expressible there yet.
- Every mystery has exactly **one entrypoint node**, stored as a foreign key on
the mystery (`mysteries.entry_node_id`) so "at most one" is structural.
## Node types
## Node types
Five types, one uniform node+terminal shape. Terminals mean different things per
type, but the wiring (terminal → edge → node) is identical everywhere.
| type | what it does | terminals |
| type | what it does | terminals |
|---|---|---|
|---|---|---|
| `cutscene` | Renders a **custom presentation component** (title card, video, 3D) chosen from a frontend registry. Deliberately opaque to the graph so bespoke set-pieces don't clutter the dialogue tree. | usually 1 (`continue`), N allowed |
| `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: a sequence of steps, optionally ending in player **choices**. | 1 (linear) or 1 per choice |
| `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 control to the investigation. | 1 (`report_back`) |
| `level` | Instantiates a playable board from a level template version and hands over to the investigation. | 1 (`report_back`) |
| `det_gate` | Deterministic gate: evaluates conditions on player/board state and routes to a matching terminal. | 1 per condition branch (+ else) |
| `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-powered gate: the model reads allowlisted player state and returns one of the terminal keys (constrained output → reliable routing). | 1 per verdict |
A **cutscene** and a **dialogue** are different because a cutscene is arbitrary
Gates are resolved server-side during `advance` and never surfaced to the player.
custom UI (its component decides its own presentation and when it completes),
while a dialogue is the shared, data-driven NPC box. Keeping them separate means a
one-off React set-piece never has to pretend to be a dialogue sequence.
## Cutscenes, Dialogue node and "utterances"
A node has zero to many utterances.
[ ] Cutscene : That react can use potential **utterances** such that for example, it can play
an ordered sequence utterance that brief the player: `(speaker NPC, pose, text)`.
This is relevant for the linear cutscene component. (No user actions)
A node can be marked has_utterances which permits the admin user to add utterances in order.
[ ] Dialogue. This is another type of node that always has utterances. It invokes the standard NPC dialogue component. The node consist of a graph where utterances either are spoken by the NPC or available for selection.
Example : if the NPC utters "Are you ready?" this has two child utterances marked "player" which could be "yes" and no. The user may select these. "No" could in principle point back to the same utterance and "yes" to the next. If an utterance has a non-NULL terminal id reference, then the game advances to the node pointed to by that terminal. Available terminals are only those who have the current dialogue node as it parent.
# Gates
[ ] There exists "det_gate" nodes and "llm_gate" nodes. We begin with the deterministic gate only. The end result of a previous node is sent to a "det_gate". For dialogue nodes, the entire "dialogue" array (chosen utterances and spoken NPC utterance) are sent. For levels, typically the case report is sent. The det gate can inspect the output of previous and determine if the story should advance through one of its output terminals. For now, we implement a particularly dumb det-gate, which always returns first terminal leading to the mystery being solved.
## Data model
## Data model
Core graph (three tables), plus one typed subtype table per node type — no
Two core tables plus `utterances`; type-specific scalars are folded onto the node
untyped `config` blob, consistent with the exhibit model.
(kept honest by CHECKs) rather than in per-type subtype tables.
xposDOUBLEPRECISIONNOTNULLDEFAULT0,-- position in the node's utterance sub-canvas
yposDOUBLEPRECISIONNOTNULLDEFAULT0,
yposDOUBLEPRECISIONNOTNULLDEFAULT0,
sort_orderINTEGERNOTNULLDEFAULT0
sort_orderINTEGERNOTNULLDEFAULT0
);
);
```
```
Several **choices may share one terminal.
Example : Player selects an utterance that points to a terminal, which in turn points to another node of type "det_gate". The code loads the component_key "det_gate_lvl_1" which is is a small and passes the outcome
**The model is parent-only with count-based meaning.** An utterance's *children*
of the previous node to it.
(the utterances whose `parent_utterance_id` points to it) are what come after it:
The det_gate script counts the number of correct assertions and determines the correct output
terminal; the correct answer → a `proceed` terminal). A dialogue node with **no
- **0 children** + `terminal_id` set ⇒ this line **exits** the node via that terminal.
choices** has a single default terminal, followed when the sequence ends — which
- **1 child** ⇒ **linear** next line (rendered as a solid wire).
is how today's linear intro/debrief become plain dialogue nodes.
- **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)
### Cutscene component registry (frontend)
Like the exhibit registry: `component_key → React component`. Each component owns
Mirrors the exhibit registry: `component_key → React component`. Each owns its
its own presentation and signals completion with an optional terminal key:
presentation and signals completion with an optional terminal key.
- **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.
- **cutscene** → render its component; on `onComplete(key?)` follow that terminal's edge.
## Glass Harbour seed
- **dialogue** → play steps; if it ends in choices, the picked choice's `terminal_id` is followed; otherwise the single default terminal.
- **level** → instantiate the board, set `current_level_id`, hand off to the investigation; the `report_back` terminal fires when the player reports back.
- **det_gate** → evaluate terminals in `sort_order`, first satisfied wins (NULL condition = else); follow it. No player-facing UI.
- **llm_gate** → gather allowlisted state, call the latest Claude model constrained to return one terminal key, follow it. Falls back to a designated terminal on error/timeout.
- **no outgoing edge** → the mystery ends.
Progress (visited nodes / seen dialogue) continues to reference stable authored
Authored in `mysteries/glass-harbor/mystery.json` (`narrative.graph`) and seeded by
ids, exactly as `seen_dialogue` does today.
the importer, so it survives re-imports. It is linear:
## Worked example — the Glass Harbour POC
The exact anatomy to build:
```
```
[cutscene: "glass-harbour-diversion"] (title card, fades in/out on black)
constledger=awaitclient.query<{count: string}>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
constledger=awaitclient.query<{count: string}>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
expect(ledger.rows[0].count).toBe('21')
expect(ledger.rows[0].count).toBe('22')
constconnectionColumns=awaitclient.query<{column_name: string}>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`)
constconnectionColumns=awaitclient.query<{column_name: string}>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`)
consteventOccurrence=awaitclient.query<{is_nullable: string}>(`SELECT is_nullable FROM information_schema.columns WHERE table_schema='osint' AND table_name='event_exhibits' AND column_name='occurred_at'`)
consteventOccurrence=awaitclient.query<{is_nullable: string}>(`SELECT is_nullable FROM information_schema.columns WHERE table_schema='osint' AND table_name='event_exhibits' AND column_name='occurred_at'`)
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,advances_to_utterance_id,terminal_id,effect,xpos,ypos,sort_order FROM osint.utterances WHERE id=$1`,[id])
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,terminal_id,xpos,ypos,sort_order FROM osint.utterances WHERE id=$1`,[id])
returnmapUtterance(created.rows[0])
returnmapUtterance(created.rows[0])
},
},
@@ -201,7 +201,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
if(!owner.rows[0])return{ok: false,error:'Utterance not found'}
if(!owner.rows[0])return{ok: false,error:'Utterance not found'}
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.