Files
gupi-osint-board/docs/story-graph.md
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

9.7 KiB
Raw Permalink Blame History

Story flow graph: the mystery editor

Status: implemented (migrations 018021). 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.

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.

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.

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 childlinear next line (rendered as a solid wire).
  • 2+ childrenplayer 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.

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 graphstory_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.

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).