Files
gupi-osint-board/docs/story-graph.md
T

10 KiB
Raw 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<{
  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 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. 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.

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