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>
This commit is contained in:
2026-08-18 16:28:11 +02:00
co-authored by Claude Opus 4.8
parent ddb3a386f0
commit aa789bbadb
5 changed files with 130 additions and 190 deletions
+109 -174
View File
@@ -1,256 +1,191 @@
# Story flow graph: the mystery editor # Story flow graph: the mystery editor
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 | | `llm_gate` | LLM gate — **not implemented**. Intended: read allowlisted player state, return one terminal key (constrained output). | 1+ |
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.
```sql ```sql
CREATE TABLE osint.story_nodes ( CREATE TABLE osint.story_nodes (
id UUID PRIMARY KEY, id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mystery_id UUID NOT NULL REFERENCES osint.mysteries(id) ON DELETE CASCADE, 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')), node_type TEXT NOT NULL CHECK (node_type IN ('cutscene','dialogue','level','det_gate','llm_gate')),
label TEXT NOT NULL DEFAULT '', label TEXT NOT NULL DEFAULT '',
has_utterances BOOLEAN NOT NULL DEFAULT FALSE, has_utterances BOOLEAN NOT NULL DEFAULT FALSE,
xpos DOUBLE PRECISION NOT NULL, xpos DOUBLE PRECISION NOT NULL,
ypos DOUBLE PRECISION NOT NULL, ypos DOUBLE PRECISION NOT NULL,
-- Folded, type-specific scalars (nullable, kept honest by the CHECKs below). -- 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), level_template_version_id UUID REFERENCES osint.level_template_versions(id),
component_key TEXT, -- cutscene: frontend React component; gate: backend gate function component_key TEXT, -- cutscene: frontend component; gate: (future) backend gate fn
CHECK (node_type <> 'level' OR level_template_version_id IS NOT NULL),
CHECK (level_template_version_id IS NULL OR node_type = 'level'), CHECK (level_template_version_id IS NULL OR node_type = 'level'),
CHECK (node_type NOT IN ('cutscene','det_gate','llm_gate') OR component_key IS NOT NULL),
CHECK (component_key IS NULL OR node_type IN ('cutscene','det_gate','llm_gate')) CHECK (component_key IS NULL OR node_type IN ('cutscene','det_gate','llm_gate'))
); );
-- Output ports. A node has ONE implicit input (many edges may converge on it) -- Output ports. A terminal owns its single outgoing wire; no edges table.
-- and N explicit output terminals.
CREATE TABLE osint.story_node_terminals ( CREATE TABLE osint.story_node_terminals (
id UUID PRIMARY KEY, id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_node_id UUID NOT NULL REFERENCES osint.story_nodes(id) ON DELETE CASCADE, parent_node_id UUID NOT NULL REFERENCES osint.story_nodes(id) ON DELETE CASCADE,
terminal_key TEXT NOT NULL, terminal_key TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '', label TEXT NOT NULL DEFAULT '',
-- The wire out of this port. NULL = unwired (authoring in progress, or a to_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL, -- NULL = unwired/end
-- 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, sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE (parent_node_id, terminal_key) UNIQUE (parent_node_id, terminal_key)
); );
-- A deferred constraint trigger enforces that to_node_id shares parent_node_id's -- Same-mystery integrity for to_node_id is enforced in the repository (not a
-- mystery (same pattern as the existing same-board checks). -- 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;
-- entry_node_id must be nullable: nodes are inserted first, then the entrypoint
-- is set, avoiding a chicken-and-egg with story_nodes.mystery_id.
ALTER TABLE osint.mysteries ADD COLUMN entry_node_id UUID REFERENCES osint.story_nodes(id);
``` ```
A terminal carries its own single outgoing wire (`to_node_id`), so there is no ### Utterances — a dialogue node's content
separate edges table. Many terminals can still converge on one node.
### Subtype tables One table for both NPC lines and player choices, distinguished by `utterer`.
```sql ```sql
-- One table for both NPC lines and player choices, distinguished by `utterer`.
-- Belongs directly to a story_node (dialogue or utterance-bearing cutscene).
CREATE TABLE osint.utterances ( CREATE TABLE osint.utterances (
id UUID PRIMARY KEY, id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
node_id UUID NOT NULL REFERENCES osint.story_nodes(id) ON DELETE CASCADE, node_id UUID NOT NULL REFERENCES osint.story_nodes(id) ON DELETE CASCADE,
utterer TEXT NOT NULL DEFAULT 'npc' CHECK (utterer IN ('npc','player')), 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 npc_id UUID REFERENCES osint.npcs(id), -- speaker for NPC lines; NULL for player choices
pose_key TEXT, -- resolved with the usual pose fallback pose_key TEXT, -- resolved with the usual pose fallback
text TEXT NOT NULL, text TEXT NOT NULL DEFAULT '',
-- Intra-node conversation graph (WITHIN one dialogue node): parent_utterance_id UUID REFERENCES osint.utterances(id) ON DELETE CASCADE, -- the utterance this one follows
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, -- VESTIGIAL / unused (see below)
advances_to_utterance_id UUID REFERENCES osint.utterances(id) ON DELETE SET NULL, -- next line; may loop back to the same one ("No") terminal_id UUID REFERENCES osint.story_node_terminals(id) ON DELETE SET NULL, -- exit: leave the node via this terminal
-- Inter-node exit: if set, choosing/finishing this utterance leaves the node. effect TEXT, -- reserved side-effect hook (unused)
terminal_id UUID REFERENCES osint.story_node_terminals(id) ON DELETE SET NULL, xpos DOUBLE PRECISION NOT NULL DEFAULT 0, -- position in the utterance sub-canvas
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, ypos DOUBLE PRECISION NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0 sort_order INTEGER NOT NULL DEFAULT 0
); );
``` ```
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.
```ts ```ts
type CutsceneComponent = React.FC<{ onComplete: (terminalKey?: string) => void }> type CutsceneComponent = React.FC<{ onComplete: (terminalKey?: string) => void }>
// registry: { 'glass-harbour-diversion': GlassHarbourDiversion, ... } // registry: { 'glass-harbour-diversion': GlassHarbourDiversion }
``` ```
For a single-terminal cutscene, `onComplete()` follows the only terminal. For a single-terminal cutscene, `onComplete()` follows the only terminal.
## Editing UX: nested canvases ## Editing UX
The same canvas engine (drag, pan, zoom, curved wires) is reused at two levels, so The same canvas engine (pan, zoom, drag, curved wires, click-a-wire-to-delete) is
each view stays uncluttered: reused at two levels.
1. **Mystery graph**`story_nodes` as cards, their output terminals as ports; **Mystery graph**`story_nodes` as cards, wired by `terminal.to_node_id`. Laid out
wires are `terminal.to_node_id`. **vertically**: the input port is on **top**, output terminals along the **bottom**,
2. **Utterance graph** — opening a dialogue (or utterance-bearing cutscene) node and flow runs downward. Double-clicking a dialogue node drills into its utterances.
drills into *its* utterances. Utterances are draggable cards (`xpos`/`ypos`);
the parent node's output terminals appear as **docked exit sinks** along one
edge (no coordinates of their own — their home is the port in the mystery
graph).
Authoring a dialogue is: add/move utterance cards, wire them, and wire the **Utterance crafter** — a dialogue node's utterances as draggable cards; the node's
branch-ends to the exit sinks. A single "draw a wire" gesture maps to storage by output terminals appear as **exit sinks** docked to the right. Each card has one
what it connects: output port:
- NPC line → player option ⇒ option grouping (`parent_utterance_id`) - Drag a card's port to another card ⇒ that card becomes a **child** (1 child = solid
- utterance → next NPC line ⇒ flow (`advances_to_utterance_id`), and a wire back linear; 2+ = dotted options).
to the same card is a loop ("No" → re-ask) - Drag to an exit sink ⇒ the card leaves the node via that `terminal_id`.
- utterance → an exit sink ⇒ leave the node (`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.
A cutscene's utterance sub-canvas is the same editor, just linear (NPC-only, no ## Runtime traversal
player branches).
## Runtime traversal (Phase 2) The playthrough tracks position with `current_node_id` (and `current_level_id`,
set while on a level node); the slot fields were dropped.
The playthrough tracks a **current node** instead of a chapter:
```sql ```sql
-- replaces current_chapter_index; current_level_id stays (set while on a level node) ALTER TABLE osint.playthroughs ADD COLUMN current_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL;
ALTER TABLE osint.playthroughs ADD COLUMN current_node_id UUID REFERENCES osint.story_nodes(id);
``` ```
Walking the graph: - **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) [cutscene: glass-harbour-diversion] → [dialogue: Briefing] → [level: glass-harbor] → [dialogue: Debrief] → (end)
└─(continue)──▶ [dialogue: Almira briefing]
├─(correct choice)──▶ [level: glass-harbor board]
└─(wrong choices)───▶ (retry terminal → loops back) ← optional
(report_back)
[dialogue: Almira debrief]
├─(good)──▶ (end)
└─(back)──▶ loops to the level
``` ```
So: one cutscene node (custom component) → one dialogue node (Almira, choices) → Branching (player options routing to different terminals) is supported and tested,
one level node → one dialogue node (debrief). The entrypoint is the cutscene node. just not used in the seed.
## Migration from the slot model ## Implemented vs deferred
A clean cutover (only Glass Harbour exists), in the spirit of migration 006: **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.
- Retire `mystery_chapters`, `cutscenes.slot`, `cutscenes.chapter_index`, and the **Deferred:**
slot-selection logic (`selectPendingCutscene`, `getSceneBySlot`, `getPendingCutscene`). - **Real gates.** `det_gate` is a hardcoded "first terminal" stub; `llm_gate` is
- What is today a `cutscene` row (a `dialogue_steps` sequence) becomes a unbuilt. Both want the **Case Report / Claims** (Milestone 5) as input, plus a
**dialogue node**; `dialogue_steps` repoints to `dialogue_node_id`. gate-function registry keyed like cutscene components.
- The reserved `dialogue_choices` becomes real and gains `terminal_id`. - **Within-node loops** (an utterance's single parent makes each dialogue a tree).
- `playthroughs.current_chapter_index``current_node_id`. - **Drop `advances_to_utterance_id`** and the reserved `effect` column.
- Re-author Glass Harbour as the graph above through the editor. - **Cutscene params** and real pose art.
## Validation rules ## Validation rules
- Exactly one `entry_node_id`; recommended (soft) that it be a `cutscene`. - One `entry_node_id` per mystery (soft-recommended to be a `cutscene`).
- Per-type terminal rules: `cutscene` ≥1, `dialogue` ≥1, `level` exactly 1, - A terminal's `to_node_id` must share its parent node's mystery (repo-enforced).
gates ≥1. - An utterance's `terminal_id` must belong to its node; parent links stay in-node.
- Every terminal's `to_node_id` belongs to the same mystery as its parent node. - Unreachable nodes and unwired terminals are allowed (deliberate ends).
- Every utterance's `terminal_id` belongs to that utterance's node; its
`parent_utterance_id` / `advances_to_utterance_id` stay within the same node.
- Warn (don't block) on unreachable nodes and terminals with no edge (deliberate
ends are allowed).
## Phasing
1. **Schema + editor, authoring-only.** Graph tables + subtype stubs, CRUD API,
and the draggable node canvas with curved edges, port wiring, and entrypoint
marking. Reuses the board's screen↔canvas coordinate conversion, drag, and
pan/zoom. Runtime still plays Glass Harbour via the current slot model.
2. **Runtime cutover.** Graph traversal replaces slot selection; re-author Glass
Harbour as a graph; retire slots/chapters.
3. **Gates.** `det_gate` first (structured conditions), then `llm_gate`
(constrained terminal-key output), wiring the cognitive-shim hint loop.
## Open questions
- **Cutscene params:** do custom components need authored parameters, or is the
`component_key` plus its own assets enough for now? (Deferred; add typed params
if a component needs them.)
- **det_gate conditions:** the initial condition vocabulary
(`all_concepts_classified`, `min_claims`, `claim_exists`, …) — lock when we
build Phase 3.
- **Node input ports:** single implicit input assumed. Revisit only if a node ever
needs to distinguish where it was entered from.
@@ -0,0 +1,5 @@
-- The utterance model settled on parent-only successors (child count decides linear
-- vs. options), so advances_to_utterance_id was never used; the effect side-effect
-- hook was reserved but unbuilt. Drop both.
ALTER TABLE osint.utterances DROP COLUMN IF EXISTS advances_to_utterance_id;
ALTER TABLE osint.utterances DROP COLUMN IF EXISTS effect;
+3 -3
View File
@@ -33,7 +33,7 @@ suite('PostgreSQL migrations', () => {
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations') const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
const firstRun: string[] = [] const firstRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message)) await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(21) expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(22)
const client = new Client({ connectionString: testDatabaseUrl }) const client = new Client({ connectionString: testDatabaseUrl })
await client.connect() await client.connect()
@@ -49,7 +49,7 @@ suite('PostgreSQL migrations', () => {
])) ]))
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue'])) expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue']))
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations') const ledger = await client.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')
const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`) const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`)
expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset'])) expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset']))
const eventOccurrence = await client.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'`) const eventOccurrence = await client.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'`)
@@ -58,7 +58,7 @@ suite('PostgreSQL migrations', () => {
const secondRun: string[] = [] const secondRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message)) await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(21) expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(22)
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false) expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
}) })
}) })
+11 -11
View File
@@ -21,8 +21,8 @@ export type LevelTemplateOption = { versionId: string; slug: string; name: strin
export type Utterer = 'npc' | 'player' export type Utterer = 'npc' | 'player'
export type UtteranceDto = { export type UtteranceDto = {
id: string; nodeId: string; utterer: Utterer; npcId: string | null; poseKey: string | null; text: string id: string; nodeId: string; utterer: Utterer; npcId: string | null; poseKey: string | null; text: string
parentUtteranceId: string | null; advancesToUtteranceId: string | null; terminalId: string | null parentUtteranceId: string | null; terminalId: string | null
effect: string | null; xpos: number; ypos: number; sortOrder: number xpos: number; ypos: number; sortOrder: number
} }
// A sensible starter terminal set so a freshly dropped node is immediately wireable. // A sensible starter terminal set so a freshly dropped node is immediately wireable.
@@ -46,7 +46,7 @@ export interface StoryGraphRepository {
listLevelTemplates(): Promise<LevelTemplateOption[]> listLevelTemplates(): Promise<LevelTemplateOption[]>
listUtterances(nodeId: string): Promise<UtteranceDto[]> listUtterances(nodeId: string): Promise<UtteranceDto[]>
createUtterance(nodeId: string, input: { utterer: Utterer; xpos: number; ypos: number; text?: string }): Promise<UtteranceDto | null> createUtterance(nodeId: string, input: { utterer: Utterer; xpos: number; ypos: number; text?: string }): Promise<UtteranceDto | null>
updateUtterance(id: string, input: Partial<{ text: string; utterer: Utterer; npcId: string | null; poseKey: string | null; xpos: number; ypos: number; parentUtteranceId: string | null; advancesToUtteranceId: string | null; terminalId: string | null; effect: string | null }>): Promise<{ ok: boolean; error?: string }> updateUtterance(id: string, input: Partial<{ text: string; utterer: Utterer; npcId: string | null; poseKey: string | null; xpos: number; ypos: number; parentUtteranceId: string | null; terminalId: string | null }>): Promise<{ ok: boolean; error?: string }>
deleteUtterance(id: string): Promise<boolean> deleteUtterance(id: string): Promise<boolean>
authorGraph(mysteryId: string, spec: GraphSpec): Promise<{ nodes: number } | null> authorGraph(mysteryId: string, spec: GraphSpec): Promise<{ nodes: number } | null>
} }
@@ -179,7 +179,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
async listUtterances(nodeId) { async listUtterances(nodeId) {
const result = await pool.query<UtteranceRow>( const result = await pool.query<UtteranceRow>(
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,advances_to_utterance_id,terminal_id,effect,xpos,ypos,sort_order `SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,terminal_id,xpos,ypos,sort_order
FROM osint.utterances WHERE node_id=$1 ORDER BY sort_order,id`, [nodeId]) FROM osint.utterances WHERE node_id=$1 ORDER BY sort_order,id`, [nodeId])
return result.rows.map(mapUtterance) return result.rows.map(mapUtterance)
}, },
@@ -192,7 +192,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
await pool.query('INSERT INTO osint.utterances (id,node_id,utterer,text,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7)', await pool.query('INSERT INTO osint.utterances (id,node_id,utterer,text,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7)',
[id, nodeId, input.utterer, input.text || '', input.xpos, input.ypos, order.rows[0].next]) [id, nodeId, input.utterer, input.text || '', input.xpos, input.ypos, order.rows[0].next])
const created = await pool.query<UtteranceRow>( const created = await pool.query<UtteranceRow>(
`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])
return mapUtterance(created.rows[0]) return mapUtterance(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' }
const nodeId = owner.rows[0].node_id const nodeId = owner.rows[0].node_id
// Same-node integrity for the three links. // Same-node integrity for the three links.
for (const link of ['parentUtteranceId', 'advancesToUtteranceId'] as const) { for (const link of ['parentUtteranceId'] as const) {
const value = input[link] const value = input[link]
if (value) { if (value) {
const target = await pool.query<{ node_id: string }>('SELECT node_id FROM osint.utterances WHERE id=$1', [value]) const target = await pool.query<{ node_id: string }>('SELECT node_id FROM osint.utterances WHERE id=$1', [value])
@@ -214,7 +214,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
} }
const columns: Record<string, string> = { const columns: Record<string, string> = {
text: 'text', utterer: 'utterer', npcId: 'npc_id', poseKey: 'pose_key', xpos: 'xpos', ypos: 'ypos', text: 'text', utterer: 'utterer', npcId: 'npc_id', poseKey: 'pose_key', xpos: 'xpos', ypos: 'ypos',
parentUtteranceId: 'parent_utterance_id', advancesToUtteranceId: 'advances_to_utterance_id', terminalId: 'terminal_id', effect: 'effect', parentUtteranceId: 'parent_utterance_id', terminalId: 'terminal_id',
} }
const sets: string[] = [] const sets: string[] = []
const values: unknown[] = [id] const values: unknown[] = [id]
@@ -302,13 +302,13 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
type UtteranceRow = { type UtteranceRow = {
id: string; node_id: string; utterer: Utterer; npc_id: string | null; pose_key: string | null; text: string id: string; node_id: string; utterer: Utterer; npc_id: string | null; pose_key: string | null; text: string
parent_utterance_id: string | null; advances_to_utterance_id: string | null; terminal_id: string | null parent_utterance_id: string | null; terminal_id: string | null
effect: string | null; xpos: number; ypos: number; sort_order: number xpos: number; ypos: number; sort_order: number
} }
function mapUtterance(row: UtteranceRow): UtteranceDto { function mapUtterance(row: UtteranceRow): UtteranceDto {
return { return {
id: row.id, nodeId: row.node_id, utterer: row.utterer, npcId: row.npc_id, poseKey: row.pose_key, text: row.text, id: row.id, nodeId: row.node_id, utterer: row.utterer, npcId: row.npc_id, poseKey: row.pose_key, text: row.text,
parentUtteranceId: row.parent_utterance_id, advancesToUtteranceId: row.advances_to_utterance_id, terminalId: row.terminal_id, parentUtteranceId: row.parent_utterance_id, terminalId: row.terminal_id,
effect: row.effect, xpos: row.xpos, ypos: row.ypos, sortOrder: row.sort_order, xpos: row.xpos, ypos: row.ypos, sortOrder: row.sort_order,
} }
} }
+2 -2
View File
@@ -3,8 +3,8 @@ import { useCallback, useEffect, useRef, useState, type ReactElement } from 'rea
type Utterer = 'npc' | 'player' type Utterer = 'npc' | 'player'
type Utterance = { type Utterance = {
id: string; nodeId: string; utterer: Utterer; npcId: string | null; poseKey: string | null; text: string id: string; nodeId: string; utterer: Utterer; npcId: string | null; poseKey: string | null; text: string
parentUtteranceId: string | null; advancesToUtteranceId: string | null; terminalId: string | null parentUtteranceId: string | null; terminalId: string | null
effect: string | null; xpos: number; ypos: number; sortOrder: number xpos: number; ypos: number; sortOrder: number
} }
type Terminal = { id: string; terminalKey: string; label: string } type Terminal = { id: string; terminalKey: string; label: string }
type Npc = { id: string; name: string; poses: { poseKey: string; url: string }[] } type Npc = { id: string; name: string; poses: { poseKey: string; url: string }[] }