Add story-graph narrative system (campaigns, editors, runtime)

Introduce the narrative layer as a directed story-flow graph: an authored
campaign a player walks node by node, replacing the interim slot/chapter model.

Schema (migrations 015-021):
- mysteries, global NPC templates + named poses, per-user playthroughs
- story_nodes, terminals, utterances (the flow graph and dialogue trees)
- clean cutover: retire slot cutscenes/chapters/seen_dialogue

Runtime:
- New Game creates a playthrough bound to the JWT identity (dev test-user fallback)
- advance() walks the graph cutscene -> dialogue -> level -> ..., auto-skipping gates
- branching dialogue: player choices route out through node terminals

Admin authoring:
- NPC editor: upload named poses to the gupi MinIO bucket
- mystery graph editor: vertical node canvas, wiring, entrypoint, delete-by-click
- dialogue crafter: utterance tree, Tab to add child, 1/2 speaker, undo

Content authored via the manifest importer / admin panel and seeded for Glass
Harbour. MinIO added to the dev stack; dev container runs in development mode.

Also includes a folder-widget simplification (removes open/close) and a
resolveUserId auth helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 15:37:46 +02:00
co-authored by Claude Opus 4.8
parent 0237da74cf
commit ddb3a386f0
30 changed files with 3016 additions and 55 deletions
+95
View File
@@ -0,0 +1,95 @@
-- Narrative layer, first slice: a mystery (campaign) wrapping ordered level
-- template versions, an authored NPC cast with named poses, scripted cutscenes,
-- and a per-user playthrough that owns game state. Dialogue content is fixed and
-- owned by the mystery; progress is a reference recorded in seen_dialogue.
CREATE TABLE osint.mysteries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE osint.mystery_chapters (
mystery_id UUID NOT NULL REFERENCES osint.mysteries(id) ON DELETE CASCADE,
chapter_index INTEGER NOT NULL CHECK (chapter_index >= 1),
level_template_version_id UUID NOT NULL REFERENCES osint.level_template_versions(id),
PRIMARY KEY (mystery_id, chapter_index)
);
CREATE TABLE osint.npcs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mystery_id UUID NOT NULL REFERENCES osint.mysteries(id) ON DELETE CASCADE,
npc_key TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT '',
default_pose_key TEXT,
UNIQUE (mystery_id, npc_key)
);
-- A pose is a named portrait variant backed by an immutable shared asset. A
-- missing pose is never an error; the client falls back to the NPC default pose
-- and then to no artwork.
CREATE TABLE osint.npc_poses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
npc_id UUID NOT NULL REFERENCES osint.npcs(id) ON DELETE CASCADE,
pose_key TEXT NOT NULL,
asset_id UUID REFERENCES osint.assets(id),
UNIQUE (npc_id, pose_key)
);
CREATE TABLE osint.cutscenes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mystery_id UUID NOT NULL REFERENCES osint.mysteries(id) ON DELETE CASCADE,
chapter_index INTEGER,
slot TEXT NOT NULL CHECK (slot IN ('mystery_intro','level_intro','level_debrief','mystery_resolution')),
title TEXT NOT NULL DEFAULT '',
-- Chapter-scoped slots carry a chapter; mystery-scoped slots do not.
CHECK (
(slot IN ('mystery_intro','mystery_resolution') AND chapter_index IS NULL)
OR (slot IN ('level_intro','level_debrief') AND chapter_index IS NOT NULL)
)
);
CREATE INDEX cutscenes_mystery_idx ON osint.cutscenes (mystery_id);
-- One utterance. `kind` reserves the LLM path; `body_text` is required for
-- scripted steps and null for generated ones. `advances_to` reserves branching;
-- null means "next by sort_order".
CREATE TABLE osint.dialogue_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cutscene_id UUID NOT NULL REFERENCES osint.cutscenes(id) ON DELETE CASCADE,
step_key TEXT NOT NULL,
sort_order INTEGER NOT NULL,
npc_id UUID NOT NULL REFERENCES osint.npcs(id),
pose_key TEXT,
kind TEXT NOT NULL DEFAULT 'scripted' CHECK (kind IN ('scripted','generated')),
body_text TEXT,
advances_to TEXT,
UNIQUE (cutscene_id, sort_order),
UNIQUE (cutscene_id, step_key),
CHECK ((kind = 'scripted' AND body_text IS NOT NULL) OR kind = 'generated')
);
-- The single object that owns a player's game state, bound to the JWT identity.
CREATE TABLE osint.playthroughs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
mystery_id UUID NOT NULL REFERENCES osint.mysteries(id) ON DELETE CASCADE,
current_chapter_index INTEGER NOT NULL DEFAULT 1 CHECK (current_chapter_index >= 1),
current_level_id UUID REFERENCES osint.levels(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','finished')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX playthroughs_user_idx ON osint.playthroughs (user_id, updated_at DESC);
-- Progress as a reference to fixed authored dialogue, never a copy of its text.
CREATE TABLE osint.seen_dialogue (
playthrough_id UUID NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
cutscene_id UUID NOT NULL REFERENCES osint.cutscenes(id) ON DELETE CASCADE,
seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (playthrough_id, cutscene_id)
);
COMMENT ON TABLE osint.playthroughs IS 'Per-user instance of a mystery; owns current chapter and live level. Bound to the JWT sub (or the development test user).';
COMMENT ON TABLE osint.dialogue_steps IS 'Fixed authored utterances owned by the immutable mystery. seen_dialogue references these ids; they are never cloned per playthrough.';
@@ -0,0 +1,7 @@
-- A dialogue step is meaningless without its speaker. Cascade deletes from npcs
-- so that removing an NPC (or dropping a whole mystery, which cascades to its
-- cast) also removes the steps that reference it, rather than failing on the
-- non-cascading foreign key.
ALTER TABLE osint.dialogue_steps DROP CONSTRAINT dialogue_steps_npc_id_fkey;
ALTER TABLE osint.dialogue_steps ADD CONSTRAINT dialogue_steps_npc_id_fkey
FOREIGN KEY (npc_id) REFERENCES osint.npcs(id) ON DELETE CASCADE;
+10
View File
@@ -0,0 +1,10 @@
-- NPCs become a reusable, global template library rather than per-mystery copies.
-- A NULL mystery_id marks a global template; mysteries reference templates by key
-- when authored, so editing an NPC in the admin panel survives re-imports.
ALTER TABLE osint.npcs ALTER COLUMN mystery_id DROP NOT NULL;
-- Enforce one global template per key (the existing UNIQUE(mystery_id, npc_key)
-- does not constrain rows where mystery_id IS NULL).
CREATE UNIQUE INDEX npcs_global_key_idx ON osint.npcs (npc_key) WHERE mystery_id IS NULL;
COMMENT ON TABLE osint.npcs IS 'Reusable NPC templates. mystery_id IS NULL for a global template; mysteries reference templates by npc_key when authored.';
+42
View File
@@ -0,0 +1,42 @@
-- Story flow graph (Phase 1: authoring only). A mystery is a directed graph of
-- nodes; each node has N output terminals, and a terminal carries its own single
-- outgoing wire (to_node_id) — there is no separate edges table. The runtime is
-- untouched in this phase; it still plays via the slot model.
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 (an author drops a
-- node, then configures it); "required for its type" is a publish-time check.
-- These exclusion CHECKs only stop a column being set on the wrong node_type.
level_template_version_id UUID REFERENCES osint.level_template_versions(id),
component_key TEXT, -- cutscene: frontend component; gate: backend gate function
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
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'))
);
CREATE INDEX story_nodes_mystery_idx ON osint.story_nodes (mystery_id);
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 '',
-- The single wire out of this port. NULL = unwired (authoring, or deliberate
-- end). SET NULL keeps the port when its target node is deleted.
to_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE (parent_node_id, terminal_key)
);
CREATE INDEX story_terminals_node_idx ON osint.story_node_terminals (parent_node_id);
-- One entrypoint per mystery. Nullable so nodes can be inserted before it is set
-- (avoids a chicken-and-egg with story_nodes.mystery_id).
ALTER TABLE osint.mysteries ADD COLUMN entry_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL;
COMMENT ON TABLE osint.story_nodes IS 'Nodes of a mystery story flow graph. Same-mystery integrity for terminal wiring is enforced in the repository (Phase 1).';
+25
View File
@@ -0,0 +1,25 @@
-- Utterances: the content of a dialogue node (and utterance-bearing cutscenes).
-- One table for both NPC lines and player choices, distinguished by `utterer`.
-- The intra-node conversation graph uses two self-references; `terminal_id` is the
-- exit that leaves the node via one of its output terminals. Runtime is Phase 2.
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 '',
-- Intra-node conversation graph:
parent_utterance_id UUID REFERENCES osint.utterances(id) ON DELETE CASCADE, -- player options hang under the NPC prompt they answer
advances_to_utterance_id UUID REFERENCES osint.utterances(id) ON DELETE SET NULL, -- next line; may loop back to the same one
-- Inter-node exit:
terminal_id UUID REFERENCES osint.story_node_terminals(id) ON DELETE SET NULL,
effect TEXT, -- optional authored side-effect hook (vocabulary TBD)
xpos DOUBLE PRECISION NOT NULL DEFAULT 0, -- position in the node's utterance sub-canvas
ypos DOUBLE PRECISION NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX utterances_node_idx ON osint.utterances (node_id);
COMMENT ON TABLE osint.utterances IS 'Dialogue content per story node. Same-node integrity for parent/advances_to/terminal links is enforced in the repository (Phase 1).';
@@ -0,0 +1,4 @@
-- Phase 2 runtime: a playthrough walks the story graph. current_node_id is where
-- the player is; current_level_id (existing) is set while on a level node. The
-- slot-based fields remain for mysteries without a graph (fallback).
ALTER TABLE osint.playthroughs ADD COLUMN current_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL;
+9
View File
@@ -0,0 +1,9 @@
-- Clean cutover to the graph runtime: retire the slot/chapter model. Progression
-- is now a walk of the story graph (playthroughs.current_node_id); cutscene and
-- dialogue content lives in story_nodes/utterances. No users exist yet, so this
-- drops the superseded tables outright rather than migrating their data.
DROP TABLE IF EXISTS osint.seen_dialogue;
DROP TABLE IF EXISTS osint.dialogue_steps;
DROP TABLE IF EXISTS osint.cutscenes;
DROP TABLE IF EXISTS osint.mystery_chapters;
ALTER TABLE osint.playthroughs DROP COLUMN IF EXISTS current_chapter_index;