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:
@@ -2,6 +2,8 @@
|
||||
|
||||
This is the ordered implementation roadmap following the accepted exhibit model. Work generally proceeds from top to bottom. It is not a substitute for migrations or implementation issues.
|
||||
|
||||
The narrative layer — campaigns, NPC cutscenes, the admin authoring panel, and the LLM cognitive shim that keeps players oriented through complex real-world scam cases — is tracked separately in [narrative-todo.md](narrative-todo.md). It depends on Milestone 5 (Case Report / Claims) for the assistant's read-only view of the player's reasoning.
|
||||
|
||||
## Working agreement
|
||||
|
||||
- Spend roughly 60% of development time on features and model work, 25% on tests and bug fixing, and 15% on repository and deployment hygiene.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# First slice: splash → New Game → briefing → board
|
||||
|
||||
Scope: the thinnest end-to-end narrative thread. A player lands on a
|
||||
**PRINCIPAL INVESTIGATOR** splash, clicks **New Game**, watches one scripted
|
||||
briefing cutscene from the Glitch University professor (with a pose swap), and
|
||||
arrives on the existing Glass Harbor board. Identity is a hard-coded test user.
|
||||
All cutscene content is authored through the manifest importer — nothing
|
||||
hard-coded in React.
|
||||
|
||||
Explicitly **out of scope** for this slice: the admin authoring panel, multiple
|
||||
chapters, the debrief/back-to-board scenes, dialogue branching, and any LLM. The
|
||||
schema below only reserves those (`kind`, nullable `advances_to`) — it does not
|
||||
build them.
|
||||
|
||||
## Ordered tasks
|
||||
|
||||
### 1. Identity: hard-coded test user (do this first)
|
||||
- [ ] Add `resolveUserId(req)` to `server/auth.ts`: return `authClaims.sub` when
|
||||
present, else a single fixed dev `TEST_USER_ID`. Leave `requireAdmin` and the
|
||||
symmetric-secret admin path untouched.
|
||||
- [ ] Note in code that the external `glitch.university` key-exchange path
|
||||
replaces only the fallback branch later; the `user_id` column does not change.
|
||||
|
||||
### 2. Migration `015_narrative_layer.sql`
|
||||
Minimal tables to run one scripted scene against one chapter.
|
||||
- [ ] `mysteries (id, slug UNIQUE, title)` and `mystery_chapters (mystery_id,
|
||||
chapter_index, level_template_version_id, PK (mystery_id, chapter_index))`.
|
||||
- [ ] `npcs (id, mystery_id, name, role, default_pose_key)`.
|
||||
- [ ] `poses (id, npc_id, pose_key, asset_id → osint.assets, UNIQUE (npc_id,
|
||||
pose_key))`.
|
||||
- [ ] `cutscenes (id, mystery_id, chapter_index NULLABLE, slot, title)`.
|
||||
- [ ] `dialogue_steps (id, cutscene_id, step_key, sort_order, npc_id, pose_key,
|
||||
kind DEFAULT 'scripted', text, advances_to NULLABLE, UNIQUE (cutscene_id,
|
||||
sort_order))`.
|
||||
- [ ] `playthroughs (id, user_id, mystery_id, current_chapter_index,
|
||||
current_level_id, created_at)`.
|
||||
- [ ] `seen_dialogue (playthrough_id, cutscene_id, seen_at, PK (playthrough_id,
|
||||
cutscene_id))`. (Cutscene-level for the slice; step-level deferred.)
|
||||
|
||||
### 3. Repository (`server/narrativeRepository.ts`, or extend levelRepository)
|
||||
- [ ] `createPlaythrough(userId, mysterySlug)`: instantiate chapter 1's template
|
||||
version into a fresh level (reuse `instantiate_template_version`), insert the
|
||||
playthrough, return it.
|
||||
- [ ] `getCurrentPlaythrough(userId)`: newest unfinished playthrough for the user.
|
||||
- [ ] `getPendingCutscene(playthrough)`: earliest unseen cutscene matching the
|
||||
current slot/chapter, with steps joined to NPC + resolved pose asset URL.
|
||||
- [ ] `resolvePose(npc, poseKey)`: pure, unit-testable — requested `pose_key` →
|
||||
NPC `default_pose_key` → `null` (no artwork). Return the asset URL or null.
|
||||
- [ ] `markCutsceneSeen(playthroughId, cutsceneId)`: idempotent insert.
|
||||
|
||||
### 4. API (`server/index.ts`), all scoped to `resolveUserId(req)`
|
||||
- [ ] `POST /api/playthroughs` → create for the user, returns playthrough +
|
||||
`pendingCutscene` + current level id.
|
||||
- [ ] `GET /api/playthroughs/current` → the user's playthrough or 204/empty.
|
||||
- [ ] `POST /api/playthroughs/:id/cutscenes/:cutsceneId/seen` → idempotent; 403 if
|
||||
the playthrough's `user_id` is not the caller.
|
||||
|
||||
### 5. Manifest importer (content path)
|
||||
- [ ] Extend `MysteryManifest` in `scripts/importMysteryTemplate.ts` with `cast[]`
|
||||
(NPCs, each with `defaultPose` and `poses: { pose_key → asset path }`) and
|
||||
`cutscenes[]` (`{ slot, chapter?, steps: [{ npc, pose, text }] }`).
|
||||
- [ ] Freeze a `mysteries` row + one `mystery_chapters` entry pointing at the
|
||||
Glass Harbor template version, plus the cast and the intro cutscene, through the
|
||||
same authenticated operations already used for assets.
|
||||
- [ ] Tolerate a cast with **no pose images** (author an NPC with just a name/role
|
||||
so the slice runs art-free); upload images only if the manifest provides them.
|
||||
- [ ] Add a `mystery_intro` cutscene to `mysteries/glass-harbor/mystery.json`: the
|
||||
professor briefing, 3–5 scripted steps, referencing pose keys that may not exist
|
||||
yet (fallback handles it).
|
||||
|
||||
### 6. Frontend
|
||||
- [ ] `SplashScreen`: title **PRINCIPAL INVESTIGATOR** over "Glitch University" in
|
||||
the existing boot aesthetic; **New Game** always, **Resume** when `current`
|
||||
returns a playthrough.
|
||||
- [ ] `DialogueOverlay`: render `pendingCutscene` steps; portrait from the
|
||||
resolved pose (fallback to name+text when null); advance on click/Space/Enter;
|
||||
typewriter with instant-reveal on first press; `prefers-reduced-motion`
|
||||
respected; on finish call `…/seen` then reveal the board.
|
||||
- [ ] App boot: `GET …/current` → no playthrough shows splash; New Game POSTs,
|
||||
loads the returned level (reuse existing level fetch/render), and plays
|
||||
`pendingCutscene` before handing control to the board.
|
||||
|
||||
### 7. Tests
|
||||
- [ ] Unit: `resolvePose` across requested / default / none; `getPendingCutscene`
|
||||
returns only unseen scenes.
|
||||
- [ ] Integration: New Game creates a playthrough bound to the test user;
|
||||
`current` returns it; `seen` is idempotent; a second user id cannot read or mark
|
||||
the first user's playthrough.
|
||||
- [ ] Integration: importing the extended manifest freezes the NPC + intro
|
||||
cutscene, and a New Game surfaces exactly those steps.
|
||||
- [ ] (Optional) Browser smoke: splash → New Game → overlay appears and advances →
|
||||
board visible; reload does not replay the seen briefing.
|
||||
|
||||
## Definition of done (slice)
|
||||
Against the test user, New Game creates a playthrough, the professor's scripted
|
||||
briefing plays with a pose swap (or clean name+text when art is absent), the Glass
|
||||
Harbor board loads, and a reload resumes without replaying the briefing — with the
|
||||
cutscene authored via the manifest, not hard-coded.
|
||||
|
||||
## Open defaults (flag before coding if you disagree)
|
||||
- Second New Game **resumes** an unfinished playthrough (explicit Restart), rather
|
||||
than always starting fresh.
|
||||
- `seen_dialogue` is **cutscene-level**, not step-level (no mid-scene resume yet).
|
||||
- One migration `015` holds all slice tables rather than several.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Narrative layer: campaigns, NPC cutscenes, and authoring
|
||||
|
||||
This roadmap covers the narrative layer end to end: the **campaign** that chains
|
||||
levels into a mystery, the **NPCs / poses / cutscenes** the player watches
|
||||
between levels, and the **admin authoring panel** that lets a game designer build
|
||||
all of it in-app. Work generally proceeds top to bottom.
|
||||
|
||||
**Design intent.** These mysteries are real-world scam cases that must actually be
|
||||
investigated to be understood. Such cases cannot be simplified, they
|
||||
can only be staged for didactic discovery into levels.
|
||||
|
||||
The LLM is not a decoration on the cutscenes — it
|
||||
is a **cognitive shim**: the assistant that keeps a player oriented as case
|
||||
complexity grows. A player can use this feature many times, but some very bright
|
||||
players might get it right on the first go.
|
||||
This is what lets a mystery be as intricate as the real case
|
||||
demands without the player getting lost. The scripted narrative layer below is the
|
||||
delivery channel and the fallback; the LLM is the layer that scales comprehension.
|
||||
Section 9 is deferred in build order but primary in intent, so the model is shaped
|
||||
now to accommodate it (the `generated` step kind, the read-only context contract,
|
||||
and the authored case model).
|
||||
|
||||
## Working agreement
|
||||
|
||||
- All narrative content — campaigns, NPCs, poses, cutscenes, dialogue text — is
|
||||
authored template data frozen through supported operations. None of it is
|
||||
hard-coded into React components or SQL seed literals. The exception to this rule is
|
||||
custom cutscenes (react components) which are registered as components and referenced by the node.
|
||||
- The admin panel is a GUI over the **same** operations available to the manifest
|
||||
importer; both paths freeze the same immutable template data.
|
||||
- A cutscene never mutates a board. The professor's scene *narrates* the new
|
||||
document and goal; those exhibits and the updated brief already live in the
|
||||
next chapter's template.
|
||||
- A narrative behavior is complete only when its PostgreSQL representation, API
|
||||
behavior, frontend presentation, persistence, and focused tests agree.
|
||||
- Pose portraits are immutable shared bytes, stored and cloned exactly like
|
||||
document image assets (MinIO + `objectStorage`); scenes reference assets, they
|
||||
never duplicate them.
|
||||
|
||||
## First vertical slice: "The Glass Harbour Diversion"
|
||||
|
||||
Build the smallest end-to-end narrative loop before generalizing. Ship these in
|
||||
order; each is playable on its own.
|
||||
|
||||
- [ ] Add the **splash screen** — "PRINCIPAL INVESTIGATOR", Glitch University —
|
||||
with a single **New Game** action that creates a playthrough and launches the
|
||||
first scene (sections 1, 6, 7).
|
||||
- [ ] Create a mystery named **The Glass Harbour Diversion** as a one-chapter
|
||||
campaign wrapping the existing Glass Harbor level template (sections 1–2).
|
||||
- [ ] Add a **briefing NPC** (the Glitch University professor) and a
|
||||
`mystery_intro` cutscene that briefs the player, using at least two poses to
|
||||
prove pose-per-utterance (sections 2–3, 7).
|
||||
- [ ] Wire the briefing to play once on load and mark itself seen, then reveal the
|
||||
first level's board (sections 1, 6, 7).
|
||||
- [ ] Author **two `level_debrief` end-scenes** the player reaches by reporting
|
||||
back: one that **sends them back to the board** and one that **concludes the
|
||||
mystery**. Model these as two end-of-scene outcomes (buttons), not branching.
|
||||
- [ ] Leave the back-to-board scene as **scripted** for now, but author it as a
|
||||
`generated`-ready step (section 9) so the professor's hint can later be produced
|
||||
from the player's Case Report explanation.
|
||||
|
||||
## 1. Campaign / progression backbone
|
||||
|
||||
- [ ] Define a **cutscene** node as something a) references a custom react component.
|
||||
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)`.
|
||||
A node can be marked has_utterances which permits the admin user to add utterances in order.
|
||||
[ ] A dialogue is another type of node that invokes the standard NPC dialogue component. This
|
||||
has utterances, and 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, 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.
|
||||
- [ ] There exists "det_gate" nodes and "llm_gate" nodes. We begin with the deterministic gate only. The end result of a level is sent to a "det_gate". The det gate can inspect the output of the level and determine if the story should advance through one of its terminals. For now, the det-gate always returns the happy path terminal leading to the mystery being solved.
|
||||
|
||||
## 2. NPC and pose catalog
|
||||
|
||||
- [ ] Add an **NPC** entity (display name, short role e.g. "Glitch University
|
||||
professor", default pose) owned by the mystery/template family, so casts are
|
||||
authored rather than global magic strings.
|
||||
- [ ] Add **poses** as named portrait variants of an NPC (`pose_key` such as
|
||||
`neutral`, `concerned`, `wry`, `pointing`), each backed by one immutable image
|
||||
asset via the existing `assets` table + MinIO path.
|
||||
- [ ] Clone NPCs and pose→asset references (asset bytes reused, not copied) during
|
||||
template freeze and instantiation, mirroring document image cloning.
|
||||
- [ ] Enforce that every dialogue step names an NPC that exists in the mystery's
|
||||
cast; a **missing pose is never an error**.
|
||||
- [ ] Resolve a step's portrait at render time with graceful fallback: the
|
||||
requested `pose_key`, else the NPC's **`default`** pose, else **no artwork**
|
||||
(speaker name + text only). This lets authors add poses incrementally and keeps
|
||||
the first slice playable with zero uploaded art.
|
||||
|
||||
## 6. API contract
|
||||
|
||||
- [ ] **New Game / session:** add `POST …/playthroughs` (create for the current
|
||||
`user_id`, per section 1) and `GET …/playthroughs/current` so the splash can
|
||||
offer New Game or Resume; scope every playthrough read/write to the caller's
|
||||
`user_id` so one player cannot touch another's game state.
|
||||
- [ ] **Play mode:** return a compact `pendingCutscene` payload (slot, ordered
|
||||
steps with resolved NPC name + pose asset URL) when one is due and unseen; add
|
||||
`POST …/playthroughs/:id/cutscenes/:cutsceneId/seen` (idempotent) and
|
||||
`POST …/playthroughs/:id/advance` implementing section 1's transactional advance.
|
||||
- [ ] **Admin mode:** add authenticated CRUD for mysteries, chapter ordering,
|
||||
NPCs and poses, and dialogue scenes/steps, plus the campaign freeze operation —
|
||||
all behind the existing admin JWT and `edit=1` gate.
|
||||
- [ ] Keep authoring-only fields (raw pose keys, expected-solution data, unfrozen
|
||||
drafts) out of play-mode responses, consistent with how brief concept
|
||||
`expectedPartyKind` is already hidden in play mode.
|
||||
|
||||
|
||||
## Definition of done
|
||||
|
||||
A game designer can, in the admin panel, create a mystery, select existing levels
|
||||
as ordered chapters, create NPCs and upload named poses, craft dialogue scenes
|
||||
choosing a pose per step, and attach those scenes to chapter slots — then freeze
|
||||
it. A player lands on the "PRINCIPAL INVESTIGATOR" splash, chooses New Game to
|
||||
create a playthrough bound to their identity, watches the professor speak
|
||||
line-by-line with a
|
||||
changing portrait, investigates each board, reports back to advance a chapter that
|
||||
introduces a new document and goal, and reloads at any point without replaying
|
||||
seen scenes — with no dialogue text hard-coded in React and no cutscene mutating a
|
||||
board.
|
||||
@@ -0,0 +1,256 @@
|
||||
# Story flow graph: the mystery editor
|
||||
|
||||
Status: design proposal for discussion. Supersedes the slot/chapter progression
|
||||
model (`mystery_chapters` + `cutscenes.slot` + `selectPendingCutscene`). Builds on
|
||||
the NPC/dialogue/cutscene work in [narrative-todo.md](narrative-todo.md).
|
||||
|
||||
## Concept
|
||||
|
||||
A mystery is authored as a **directed flow graph** of nodes. The author drags
|
||||
nodes on a canvas and connects an **output terminal** of one node to another node
|
||||
with a directed, curved edge. A playthrough is a walk of that graph.
|
||||
|
||||
- It is a **graph, not a tree**: gates may route back to earlier nodes ("not
|
||||
convincing → keep investigating"), so cycles are expected and intended. The
|
||||
runtime only re-traverses a node when the player acts again, so loops never spin
|
||||
on their own.
|
||||
- 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
|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `dialogue` | The standard NPC dialogue box: a sequence of steps, optionally ending in player **choices**. | 1 (linear) or 1 per choice |
|
||||
| `level` | Instantiates a playable board from a level template version and hands control 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) |
|
||||
| `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 |
|
||||
|
||||
A **cutscene** and a **dialogue** are different because a cutscene is arbitrary
|
||||
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
|
||||
|
||||
Core graph (three tables), plus one typed subtype table per node type — no
|
||||
untyped `config` blob, consistent with the exhibit model.
|
||||
|
||||
```sql
|
||||
CREATE TABLE osint.story_nodes (
|
||||
id UUID PRIMARY KEY,
|
||||
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, kept honest by the CHECKs below).
|
||||
level_template_version_id UUID REFERENCES osint.level_template_versions(id),
|
||||
component_key TEXT, -- cutscene: frontend React component; gate: backend gate function
|
||||
CHECK (node_type <> 'level' OR level_template_version_id IS NOT NULL),
|
||||
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'))
|
||||
);
|
||||
|
||||
-- Output ports. A node has ONE implicit input (many edges may converge on it)
|
||||
-- and N explicit output terminals.
|
||||
CREATE TABLE osint.story_node_terminals (
|
||||
id UUID PRIMARY KEY,
|
||||
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 wire out of this port. NULL = unwired (authoring in progress, or a
|
||||
-- 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)
|
||||
);
|
||||
-- A deferred constraint trigger enforces that to_node_id shares parent_node_id's
|
||||
-- mystery (same pattern as the existing same-board checks).
|
||||
|
||||
-- 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
|
||||
separate edges table. Many terminals can still converge on one node.
|
||||
|
||||
### Subtype tables
|
||||
|
||||
```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 (
|
||||
id UUID PRIMARY KEY,
|
||||
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,
|
||||
-- Intra-node conversation graph (WITHIN one dialogue node):
|
||||
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 ("No")
|
||||
-- Inter-node exit: if set, choosing/finishing this utterance leaves the node.
|
||||
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
|
||||
);
|
||||
|
||||
```
|
||||
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
|
||||
of the previous node to 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
|
||||
choices** has a single default terminal, followed when the sequence ends — which
|
||||
is how today's linear intro/debrief become plain dialogue nodes.
|
||||
|
||||
### Cutscene component registry (frontend)
|
||||
|
||||
Like the exhibit registry: `component_key → React component`. Each component owns
|
||||
its own 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: nested canvases
|
||||
|
||||
The same canvas engine (drag, pan, zoom, curved wires) is reused at two levels, so
|
||||
each view stays uncluttered:
|
||||
|
||||
1. **Mystery graph** — `story_nodes` as cards, their output terminals as ports;
|
||||
wires are `terminal.to_node_id`.
|
||||
2. **Utterance graph** — opening a dialogue (or utterance-bearing cutscene) node
|
||||
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
|
||||
branch-ends to the exit sinks. A single "draw a wire" gesture maps to storage by
|
||||
what it connects:
|
||||
|
||||
- NPC line → player option ⇒ option grouping (`parent_utterance_id`)
|
||||
- utterance → next NPC line ⇒ flow (`advances_to_utterance_id`), and a wire back
|
||||
to the same card is a loop ("No" → re-ask)
|
||||
- utterance → an exit sink ⇒ leave the node (`terminal_id`)
|
||||
|
||||
A cutscene's utterance sub-canvas is the same editor, just linear (NPC-only, no
|
||||
player branches).
|
||||
|
||||
## Runtime traversal (Phase 2)
|
||||
|
||||
The playthrough tracks a **current node** instead of a chapter:
|
||||
|
||||
```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);
|
||||
```
|
||||
|
||||
Walking the graph:
|
||||
|
||||
- **cutscene** → render its component; on `onComplete(key?)` follow that terminal's edge.
|
||||
- **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
|
||||
ids, exactly as `seen_dialogue` does today.
|
||||
|
||||
## Worked example — the Glass Harbour POC
|
||||
|
||||
The exact anatomy to build:
|
||||
|
||||
```
|
||||
[cutscene: "glass-harbour-diversion"] (title card, fades in/out on black)
|
||||
└─(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) →
|
||||
one level node → one dialogue node (debrief). The entrypoint is the cutscene node.
|
||||
|
||||
## Migration from the slot model
|
||||
|
||||
A clean cutover (only Glass Harbour exists), in the spirit of migration 006:
|
||||
|
||||
- Retire `mystery_chapters`, `cutscenes.slot`, `cutscenes.chapter_index`, and the
|
||||
slot-selection logic (`selectPendingCutscene`, `getSceneBySlot`, `getPendingCutscene`).
|
||||
- What is today a `cutscene` row (a `dialogue_steps` sequence) becomes a
|
||||
**dialogue node**; `dialogue_steps` repoints to `dialogue_node_id`.
|
||||
- The reserved `dialogue_choices` becomes real and gains `terminal_id`.
|
||||
- `playthroughs.current_chapter_index` → `current_node_id`.
|
||||
- Re-author Glass Harbour as the graph above through the editor.
|
||||
|
||||
## Validation rules
|
||||
|
||||
- Exactly one `entry_node_id`; recommended (soft) that it be a `cutscene`.
|
||||
- Per-type terminal rules: `cutscene` ≥1, `dialogue` ≥1, `level` exactly 1,
|
||||
gates ≥1.
|
||||
- Every terminal's `to_node_id` belongs to the same mystery as its parent node.
|
||||
- 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.
|
||||
Reference in New Issue
Block a user