Files
gupi-osint-board/docs/story-graph.md
T
gitprovandClaude Opus 4.8 aa789bbadb Cleanup: drop vestigial utterance columns, sync story-graph doc
- 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>
2026-08-18 16:28:11 +02:00

192 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).