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:
@@ -4,3 +4,11 @@ CORS_ORIGIN=http://localhost:5173
|
||||
LEVEL_EDITING_ENABLED=true
|
||||
JWT_SECRET=osint-local-dev-secret
|
||||
MAX_DOCUMENT_BYTES=26214400
|
||||
|
||||
# Game asset storage (MinIO). Start it with: docker compose -f docker-compose.dev.yml up -d minio createbuckets
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_ACCESS_KEY=gupi
|
||||
S3_SECRET_KEY=gupi_secret
|
||||
S3_BUCKET=gupi
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
+41
-1
@@ -30,12 +30,52 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET:-osint-local-dev-secret}
|
||||
LEVEL_EDITING_ENABLED: "true"
|
||||
MAX_DOCUMENT_BYTES: 26214400
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
S3_REGION: us-east-1
|
||||
S3_ACCESS_KEY: gupi
|
||||
S3_SECRET_KEY: gupi_secret
|
||||
S3_BUCKET: gupi
|
||||
S3_FORCE_PATH_STYLE: "true"
|
||||
ports:
|
||||
- "8787:8787"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
command: ["sh", "-c", "npm run migrate:up && npm start"]
|
||||
createbuckets:
|
||||
condition: service_completed_successfully
|
||||
# Run directly (not `npm start`, which forces NODE_ENV=production) so the
|
||||
# NODE_ENV=development above applies and dev conveniences like the admin-session
|
||||
# route are available.
|
||||
command: ["sh", "-c", "npm run migrate:up && npx tsx server/index.ts"]
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: osint-board-minio
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: gupi
|
||||
MINIO_ROOT_PASSWORD: gupi_secret
|
||||
ports:
|
||||
- "9000:9000" # S3 API
|
||||
- "9001:9001" # web console
|
||||
volumes:
|
||||
- osint_minio_data:/data
|
||||
|
||||
# One-shot: wait for MinIO, then ensure the gupi bucket exists. Assets are
|
||||
# served through the app's /api/assets proxy, so the bucket stays private.
|
||||
createbuckets:
|
||||
image: minio/mc:latest
|
||||
container_name: osint-board-createbuckets
|
||||
depends_on:
|
||||
- minio
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
until mc alias set gupi http://minio:9000 gupi gupi_secret; do echo 'waiting for minio...'; sleep 1; done;
|
||||
mc mb --ignore-existing gupi/gupi;
|
||||
echo 'gupi bucket ready';
|
||||
"
|
||||
|
||||
volumes:
|
||||
osint_postgres_data:
|
||||
osint_minio_data:
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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.';
|
||||
@@ -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).';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -123,6 +123,44 @@
|
||||
"metadata": { "lot": "117", "seller_reference": "MV-3/771", "estimate": "45,000–52,000 NOK" }
|
||||
}
|
||||
],
|
||||
"narrative": {
|
||||
"cast": [
|
||||
{ "key": "professor", "name": "Prof. Almira Vetch", "role": "Glitch University · Investigative Method", "defaultPose": "neutral" }
|
||||
],
|
||||
"graph": {
|
||||
"entry": "intro",
|
||||
"nodes": [
|
||||
{
|
||||
"key": "intro", "type": "cutscene", "label": "The Glass Harbour Diversion",
|
||||
"componentKey": "glass-harbour-diversion", "x": 200, "y": 60,
|
||||
"terminals": [{ "key": "continue", "to": "briefing" }]
|
||||
},
|
||||
{
|
||||
"key": "briefing", "type": "dialogue", "label": "Briefing", "x": 200, "y": 220,
|
||||
"terminals": [{ "key": "continue", "label": "Begin", "to": "investigate" }],
|
||||
"utterances": [
|
||||
{ "npc": "professor", "pose": "neutral", "text": "Principal Investigator. Good — you're early. Sit. The Society has handed us a mess: a restored Fresnel lens, bought and paid for, that never reached the lighthouse it was meant for." },
|
||||
{ "npc": "professor", "pose": "concerned", "text": "Greyhaven file 87-10. Between dispatch and installation the shipment simply changed course. Someone arranged that, and someone stood to profit. Both facts are in the documents — nowhere else." },
|
||||
{ "npc": "professor", "pose": "wry", "text": "I won't tell you who did it. That is the whole exercise. Classify every name, tie each party to the evidence, and reconstruct the order of events until the account defends itself." },
|
||||
{ "npc": "professor", "pose": "neutral", "text": "The terminal will not congratulate you. A solved board is one where your conclusion is the only one the paper trail still allows. Go." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "investigate", "type": "level", "label": "Investigate the board",
|
||||
"templateSlug": "glass-harbor", "x": 200, "y": 380,
|
||||
"terminals": [{ "key": "report_back", "label": "Report back", "to": "debrief" }]
|
||||
},
|
||||
{
|
||||
"key": "debrief", "type": "dialogue", "label": "Debrief", "x": 200, "y": 540,
|
||||
"terminals": [{ "key": "continue", "label": "End", "to": null }],
|
||||
"utterances": [
|
||||
{ "npc": "professor", "pose": "wry", "text": "There it is. The lens never sailed for the lighthouse — it sailed for a saleroom, and your thread shows exactly whose hand turned it." },
|
||||
{ "npc": "professor", "pose": "neutral", "text": "A defensible account, Principal Investigator. Greyhaven file 87-10 is closed. Get some sleep — there will be another." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"folders": [
|
||||
{
|
||||
"key": "procurement-file",
|
||||
|
||||
@@ -13,6 +13,17 @@ type MysteryDocument = {
|
||||
metadata?: Record<string, string>
|
||||
asset?: string
|
||||
}
|
||||
type MysteryGraph = {
|
||||
entry: string
|
||||
nodes: { key: string; type: 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'; label?: string; x: number; y: number
|
||||
componentKey?: string; templateSlug?: string; version?: number
|
||||
terminals?: { key: string; label?: string; to?: string | null }[]
|
||||
utterances?: { npc?: string; pose?: string; text: string; utterer?: 'npc' | 'player' }[] }[]
|
||||
}
|
||||
type MysteryNarrative = {
|
||||
cast: { key: string; name: string; role?: string; defaultPose?: string; poses?: { poseKey: string; assetId: string }[] }[]
|
||||
graph?: MysteryGraph
|
||||
}
|
||||
type MysteryManifest = {
|
||||
slug: string
|
||||
name: string
|
||||
@@ -22,6 +33,7 @@ type MysteryManifest = {
|
||||
brief: { body: string; concepts: { label: string; context: string; expectedPartyKind: PartyKind }[] }
|
||||
documents: MysteryDocument[]
|
||||
folders: { key: string; title: string; content: string; x: number; y: number; width: number; members: string[] }[]
|
||||
narrative?: MysteryNarrative
|
||||
}
|
||||
|
||||
function requireOk(response: Response, action: string) {
|
||||
@@ -92,12 +104,32 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
|
||||
method: 'POST', headers, body: JSON.stringify({ slug: manifest.slug, name: manifest.name }),
|
||||
}), 'Freeze mystery template')
|
||||
const template = await templateResponse.json() as { slug: string; currentVersion: number }
|
||||
|
||||
// Author the mystery and its NPC cast; the flow lives in the story graph, seeded below.
|
||||
let mystery: { slug: string } | undefined
|
||||
if (manifest.narrative) {
|
||||
const mysteryResponse = await requireOk(await fetch(`${baseUrl}/api/mysteries?edit=1`, {
|
||||
method: 'POST', headers, body: JSON.stringify({ slug: manifest.slug, title: manifest.title, cast: manifest.narrative.cast }),
|
||||
}), 'Author narrative mystery')
|
||||
mystery = await mysteryResponse.json() as { slug: string }
|
||||
|
||||
// Seed the story flow graph (default authored content that survives re-imports).
|
||||
if (manifest.narrative.graph) {
|
||||
const listResponse = await requireOk(await fetch(`${baseUrl}/api/admin/mysteries`, { headers: authorization ? { authorization } : undefined }), 'List mysteries')
|
||||
const mysteries = await listResponse.json() as { id: string; slug: string }[]
|
||||
const mysteryId = mysteries.find(m => m.slug === manifest.slug)?.id
|
||||
if (mysteryId) await requireOk(await fetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, {
|
||||
method: 'POST', headers, body: JSON.stringify(manifest.narrative.graph),
|
||||
}), 'Seed story graph')
|
||||
}
|
||||
}
|
||||
|
||||
const playableId = `${manifest.slug}-case-${Date.now()}`
|
||||
const playableResponse = await requireOk(await fetch(`${baseUrl}/api/templates/${manifest.slug}/levels?edit=1`, {
|
||||
method: 'POST', headers, body: JSON.stringify({ id: playableId, title: manifest.title }),
|
||||
}), 'Instantiate playable mystery')
|
||||
const playable = await playableResponse.json() as CaseState
|
||||
return { manifest, template, authoringLevelId: state.id, playableLevel: playable }
|
||||
return { manifest, template, mystery, authoringLevelId: state.id, playableLevel: playable }
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ''
|
||||
@@ -107,8 +139,9 @@ if (invokedPath === fileURLToPath(import.meta.url)) {
|
||||
const result = await importMysteryTemplate(manifestPath, process.env.OSINT_BOARD_URL)
|
||||
console.log(JSON.stringify({
|
||||
template: `${result.template.slug}@v${result.template.currentVersion}`,
|
||||
mystery: result.mystery ? result.mystery.slug : undefined,
|
||||
authoringLevelId: result.authoringLevelId,
|
||||
playableLevelId: result.playableLevel.id,
|
||||
playUrl: `${process.env.OSINT_BOARD_URL || 'http://localhost:8787'}/?level=${encodeURIComponent(result.playableLevel.id)}`,
|
||||
playUrl: `${process.env.OSINT_BOARD_URL || 'http://localhost:8787'}/`,
|
||||
}, null, 2))
|
||||
}
|
||||
|
||||
@@ -26,6 +26,19 @@ export function hasAdminClaim(req: Request) {
|
||||
return req.authClaims?.role === 'admin' || req.authClaims?.isAdmin === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity for a player's game state. Real players arrive with a JWT issued by
|
||||
* glitch.university (verified through the key-exchange handoff); until that lands,
|
||||
* an absent token resolves to a single fixed development user so the game is
|
||||
* playable locally with no identity provider. Only this fallback branch changes
|
||||
* when the external handoff is wired — the `user_id` column stays the same.
|
||||
*/
|
||||
export const DEVELOPMENT_TEST_USER_ID = 'osint-test-player'
|
||||
export function resolveUserId(req: Request): string {
|
||||
const sub = req.authClaims?.sub
|
||||
return typeof sub === 'string' && sub.length > 0 ? sub : DEVELOPMENT_TEST_USER_ID
|
||||
}
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||
if (!hasAdminClaim(req)) return res.status(403).json({ error: 'Administrator claim required' })
|
||||
next()
|
||||
|
||||
+189
-1
@@ -8,8 +8,10 @@ import { fileURLToPath } from 'node:url'
|
||||
import multer from 'multer'
|
||||
import pg from 'pg'
|
||||
import type { CaseState } from '../src/types.js'
|
||||
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin } from './auth.js'
|
||||
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolveUserId } from './auth.js'
|
||||
import { createLevelRepository } from './levelRepository.js'
|
||||
import { createNarrativeRepository } from './narrativeRepository.js'
|
||||
import { createStoryGraphRepository, type StoryNodeType } from './storyGraphRepository.js'
|
||||
import { createObjectStorageFromEnv } from './objectStorage.js'
|
||||
|
||||
const { Pool } = pg
|
||||
@@ -24,6 +26,9 @@ const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
|
||||
const objectStorage = createObjectStorageFromEnv()
|
||||
await objectStorage.initialize()
|
||||
const levels = createLevelRepository(pool, editingEnabled, objectStorage)
|
||||
const narrative = createNarrativeRepository(pool, objectStorage)
|
||||
const storyGraph = createStoryGraphRepository(pool)
|
||||
const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate']
|
||||
|
||||
function wantsEdit(req: express.Request) {
|
||||
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
|
||||
@@ -131,6 +136,189 @@ app.post('/api/levels/:id/reset', async (req, res, next) => {
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
// Admin authoring panel: NPC template library and mystery listing. Reads require an
|
||||
// admin claim; writes additionally require editing to be enabled on this deployment.
|
||||
function requireEditing(res: express.Response) {
|
||||
if (!editingEnabled) { res.status(403).json({ error: 'Level editing is disabled' }); return false }
|
||||
return true
|
||||
}
|
||||
app.get('/api/admin/mysteries', requireAdmin, async (_req, res, next) => {
|
||||
try { res.json(await narrative.listMysteries()) } catch (error) { next(error) }
|
||||
})
|
||||
app.get('/api/admin/npcs', requireAdmin, async (_req, res, next) => {
|
||||
try { res.json(await narrative.listNpcs()) } catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/admin/npcs', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
if (!req.body?.key) return res.status(400).json({ error: 'An NPC key is required' })
|
||||
res.status(201).json(await narrative.createNpc({ key: String(req.body.key), name: String(req.body.name || ''), role: String(req.body.role || ''), defaultPose: req.body.defaultPose || null }))
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.patch('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const npc = await narrative.updateNpc(String(req.params.id), { name: req.body?.name, role: req.body?.role, defaultPose: req.body?.defaultPose })
|
||||
npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.delete('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const outcome = await narrative.deleteNpc(String(req.params.id))
|
||||
if (outcome === 'deleted') return res.json({ ok: true })
|
||||
res.status(outcome === 'in_use' ? 409 : 404).json({ error: outcome === 'in_use' ? 'NPC is used by a cutscene' : 'NPC not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/admin/npcs/:id/poses', requireAdmin, upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
if (!req.file) return res.status(400).json({ error: 'An image file is required' })
|
||||
if (!req.body?.poseKey) return res.status(400).json({ error: 'A pose key is required' })
|
||||
const npc = await narrative.addPose(String(req.params.id), String(req.body.poseKey), req.file)
|
||||
npc ? res.status(201).json(npc) : res.status(404).json({ error: 'NPC not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.delete('/api/admin/npcs/:id/poses/:poseKey', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const npc = await narrative.deletePose(String(req.params.id), String(req.params.poseKey))
|
||||
npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
// Story flow graph authoring (Phase 1): nodes, terminals, wiring, entrypoint.
|
||||
app.get('/api/admin/level-templates', requireAdmin, async (_req, res, next) => {
|
||||
try { res.json(await storyGraph.listLevelTemplates()) } catch (error) { next(error) }
|
||||
})
|
||||
app.get('/api/admin/mysteries/:id/graph', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const graph = await storyGraph.getGraph(String(req.params.id))
|
||||
graph ? res.json(graph) : res.status(404).json({ error: 'Mystery not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/admin/mysteries/:id/nodes', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const nodeType = String(req.body?.nodeType) as StoryNodeType
|
||||
if (!STORY_NODE_TYPES.includes(nodeType)) return res.status(400).json({ error: 'Unknown node type' })
|
||||
const node = await storyGraph.createNode(String(req.params.id), { nodeType, xpos: Number(req.body?.xpos) || 0, ypos: Number(req.body?.ypos) || 0, label: req.body?.label })
|
||||
node ? res.status(201).json(node) : res.status(404).json({ error: 'Mystery not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.patch('/api/admin/story-nodes/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const node = await storyGraph.updateNode(String(req.params.id), req.body || {})
|
||||
node ? res.json(node) : res.status(404).json({ error: 'Node not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.delete('/api/admin/story-nodes/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const ok = await storyGraph.deleteNode(String(req.params.id))
|
||||
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Node not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/admin/story-nodes/:id/terminals', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
if (!req.body?.terminalKey) return res.status(400).json({ error: 'A terminal key is required' })
|
||||
const node = await storyGraph.addTerminal(String(req.params.id), { terminalKey: String(req.body.terminalKey), label: req.body?.label })
|
||||
node ? res.status(201).json(node) : res.status(404).json({ error: 'Node not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.patch('/api/admin/story-terminals/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const result = await storyGraph.updateTerminal(String(req.params.id), req.body || {})
|
||||
result.ok ? res.json({ ok: true }) : res.status(result.error === 'Terminal not found' ? 404 : 400).json({ error: result.error })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.delete('/api/admin/story-terminals/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const ok = await storyGraph.deleteTerminal(String(req.params.id))
|
||||
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Terminal not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.put('/api/admin/mysteries/:id/entry', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const result = await storyGraph.setEntryNode(String(req.params.id), req.body?.nodeId ?? null)
|
||||
result.ok ? res.json({ ok: true }) : res.status(400).json({ error: result.error })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/admin/mysteries/:id/graph', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
if (!req.body?.entry || !Array.isArray(req.body?.nodes)) return res.status(400).json({ error: 'A graph spec needs entry and nodes' })
|
||||
const result = await storyGraph.authorGraph(String(req.params.id), req.body)
|
||||
result ? res.status(201).json(result) : res.status(404).json({ error: 'Mystery not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
// Utterance sub-graph (dialogue crafter).
|
||||
app.get('/api/admin/story-nodes/:id/utterances', requireAdmin, async (req, res, next) => {
|
||||
try { res.json(await storyGraph.listUtterances(String(req.params.id))) } catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/admin/story-nodes/:id/utterances', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const utterer = req.body?.utterer === 'player' ? 'player' : 'npc'
|
||||
const utterance = await storyGraph.createUtterance(String(req.params.id), { utterer, xpos: Number(req.body?.xpos) || 0, ypos: Number(req.body?.ypos) || 0, text: req.body?.text })
|
||||
utterance ? res.status(201).json(utterance) : res.status(404).json({ error: 'Node not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.patch('/api/admin/utterances/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const result = await storyGraph.updateUtterance(String(req.params.id), req.body || {})
|
||||
result.ok ? res.json({ ok: true }) : res.status(result.error === 'Utterance not found' ? 404 : 400).json({ error: result.error })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.delete('/api/admin/utterances/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const ok = await storyGraph.deleteUtterance(String(req.params.id))
|
||||
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Utterance not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
// Narrative authoring: create a mystery and its NPC cast. The flow (cutscenes,
|
||||
// dialogue, levels) lives in the story graph, seeded separately.
|
||||
app.post('/api/mysteries', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
|
||||
const body = req.body || {}
|
||||
if (!body.slug || !body.title) return res.status(400).json({ error: 'A mystery requires slug and title' })
|
||||
const created = await narrative.authorMystery({ slug: slug(body.slug, body.slug), title: String(body.title), cast: Array.isArray(body.cast) ? body.cast : [] })
|
||||
res.status(201).json(created)
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
// New Game creates a playthrough bound to the caller's identity.
|
||||
app.post('/api/playthroughs', async (req, res, next) => {
|
||||
try {
|
||||
const result = await narrative.createPlaythrough(resolveUserId(req), req.body?.mystery ? slug(req.body.mystery, req.body.mystery) : undefined)
|
||||
result ? res.status(201).json(result) : res.status(404).json({ error: 'No mystery available' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.get('/api/playthroughs/current', async (req, res, next) => {
|
||||
try {
|
||||
const result = await narrative.getCurrentPlaythrough(resolveUserId(req))
|
||||
result ? res.json(result) : res.status(204).end()
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
// Advance the playthrough through the story graph (follows a terminal; auto-skips
|
||||
// gates; instantiates the board when entering a level node).
|
||||
app.post('/api/playthroughs/:id/advance', async (req, res, next) => {
|
||||
try {
|
||||
const result = await narrative.advancePlaythrough(resolveUserId(req), String(req.params.id), req.body?.terminalKey)
|
||||
result.ok ? res.json(result.state ?? null) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
if (error instanceof multer.MulterError) {
|
||||
return res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ error: error.code === 'LIMIT_FILE_SIZE' ? 'Document exceeds the upload limit' : error.message })
|
||||
|
||||
@@ -33,7 +33,7 @@ suite('PostgreSQL migrations', () => {
|
||||
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
||||
const firstRun: string[] = []
|
||||
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
|
||||
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(12)
|
||||
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(21)
|
||||
|
||||
const client = new Client({ connectionString: testDatabaseUrl })
|
||||
await client.connect()
|
||||
@@ -43,11 +43,13 @@ suite('PostgreSQL migrations', () => {
|
||||
'boards', 'levels', 'level_templates', 'level_template_versions', 'exhibits', 'folder_exhibits',
|
||||
'document_exhibits', 'folder_memberships', 'exhibit_connections', 'metadata_fields', 'assets', 'schema_migrations',
|
||||
'party_exhibits', 'person_parties', 'organization_parties', 'brief_concepts', 'level_briefs',
|
||||
'board_timeline_settings',
|
||||
'board_views', 'timeline_views',
|
||||
'mysteries', 'npcs', 'npc_poses', 'playthroughs',
|
||||
'story_nodes', 'story_node_terminals', 'utterances',
|
||||
]))
|
||||
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'playthroughs']))
|
||||
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')
|
||||
expect(ledger.rows[0].count).toBe('12')
|
||||
expect(ledger.rows[0].count).toBe('21')
|
||||
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']))
|
||||
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'`)
|
||||
@@ -56,7 +58,7 @@ suite('PostgreSQL migrations', () => {
|
||||
|
||||
const secondRun: string[] = []
|
||||
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
|
||||
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(12)
|
||||
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(21)
|
||||
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createServer } from 'node:net'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import pg from 'pg'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { PlaythroughState } from './narrativeRepository.js'
|
||||
import { runMigrations } from './migrations.js'
|
||||
|
||||
const { Client } = pg
|
||||
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
|
||||
const suite = baseDatabaseUrl ? describe : describe.skip
|
||||
const databaseName = `osint_narrative_test_${process.pid}_${Date.now()}`
|
||||
let adminClient: InstanceType<typeof Client>
|
||||
let appServer: Awaited<typeof import('./index.js')>['server']
|
||||
let appPool: Awaited<typeof import('./index.js')>['pool']
|
||||
let baseUrl = ''
|
||||
let adminAuthorization = ''
|
||||
|
||||
function authFetch(url: string, authorization?: string, init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers)
|
||||
if (authorization) headers.set('authorization', authorization)
|
||||
return fetch(url, { ...init, headers })
|
||||
}
|
||||
|
||||
async function availablePort() {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const probe = createServer()
|
||||
probe.once('error', reject)
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const address = probe.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
probe.close(error => error ? reject(error) : resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
suite('narrative graph runtime', () => {
|
||||
beforeAll(async () => {
|
||||
const adminUrl = new URL(baseDatabaseUrl!)
|
||||
adminUrl.pathname = '/postgres'
|
||||
adminClient = new Client({ connectionString: adminUrl.toString() })
|
||||
await adminClient.connect()
|
||||
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
|
||||
const testUrl = new URL(baseDatabaseUrl!)
|
||||
testUrl.pathname = `/${databaseName}`
|
||||
const databaseUrl = testUrl.toString()
|
||||
await runMigrations(databaseUrl, path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations'), () => undefined)
|
||||
|
||||
const port = await availablePort()
|
||||
process.env.DATABASE_URL = databaseUrl
|
||||
process.env.LEVEL_EDITING_ENABLED = 'true'
|
||||
process.env.JWT_SECRET = 'osint-narrative-jwt-secret'
|
||||
process.env.ASSET_STORAGE_DRIVER = 'memory'
|
||||
process.env.PORT = String(port)
|
||||
const serverModule = await import('./index.js')
|
||||
appServer = serverModule.server
|
||||
appPool = serverModule.pool
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
adminAuthorization = `Bearer ${jwt.sign({ sub: 'integration-admin', role: 'admin' }, process.env.JWT_SECRET)}`
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (appServer) await new Promise<void>((resolve, reject) => appServer.close(error => error ? reject(error) : resolve()))
|
||||
if (appPool) await appPool.end()
|
||||
if (!adminClient) return
|
||||
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
|
||||
await adminClient.end()
|
||||
})
|
||||
|
||||
it('New Game walks the seeded graph: cutscene → dialogue → level → finished', async () => {
|
||||
const json = { 'content-type': 'application/json' }
|
||||
// A frozen level template to back the level node.
|
||||
await authFetch(`${baseUrl}/api/levels`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({ id: 'gr-src', title: 'Runtime Source' }) })
|
||||
await authFetch(`${baseUrl}/api/levels/gr-src/templates?edit=1`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({ name: 'Runtime Chapter', slug: 'gr-mystery-chapter' }) })
|
||||
// Mystery + cast, then seed a linear graph.
|
||||
await authFetch(`${baseUrl}/api/mysteries?edit=1`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({ slug: 'gr-mystery', title: 'Runtime Mystery', cast: [{ key: 'prof', name: 'Prof. Test', role: 'GU' }] }) })
|
||||
const mysteries = await (await authFetch(`${baseUrl}/api/admin/mysteries`, adminAuthorization)).json() as { id: string; slug: string }[]
|
||||
const mysteryId = mysteries.find(m => m.slug === 'gr-mystery')!.id
|
||||
const seed = await authFetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({
|
||||
entry: 'intro',
|
||||
nodes: [
|
||||
{ key: 'intro', type: 'cutscene', label: 'Title', componentKey: 'runtime-title', x: 0, y: 0, terminals: [{ key: 'continue', to: 'brief' }] },
|
||||
{ key: 'brief', type: 'dialogue', label: 'Briefing', x: 200, y: 0, terminals: [{ key: 'continue', to: 'level' }], utterances: [{ npc: 'prof', text: 'Welcome.' }, { npc: 'prof', text: 'Investigate.' }] },
|
||||
{ key: 'level', type: 'level', label: 'Board', templateSlug: 'gr-mystery-chapter', x: 400, y: 0, terminals: [{ key: 'report_back', to: 'debrief' }] },
|
||||
{ key: 'debrief', type: 'dialogue', label: 'Debrief', x: 600, y: 0, terminals: [{ key: 'continue', to: null }], utterances: [{ npc: 'prof', text: 'Case closed.' }] },
|
||||
],
|
||||
}) })
|
||||
expect(seed.status).toBe(201)
|
||||
|
||||
// New Game lands on the entry cutscene.
|
||||
const created = await authFetch(`${baseUrl}/api/playthroughs`, undefined, { method: 'POST', headers: json, body: '{}' })
|
||||
expect(created.status).toBe(201)
|
||||
const start = await created.json() as PlaythroughState
|
||||
expect(start.node?.kind).toBe('cutscene')
|
||||
expect(start.node?.componentKey).toBe('runtime-title')
|
||||
const id = start.playthrough.id
|
||||
|
||||
// Advance into the briefing dialogue (NPC utterances become steps).
|
||||
const brief = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState
|
||||
expect(brief.node?.kind).toBe('dialogue')
|
||||
expect(brief.node?.utterances).toHaveLength(2)
|
||||
const root = brief.node?.utterances?.find(u => u.id === brief.node?.rootId)
|
||||
expect(root).toMatchObject({ text: 'Welcome.', speaker: { name: 'Prof. Test' } })
|
||||
expect(root?.childIds).toHaveLength(1) // linear parent-chain
|
||||
|
||||
// Advance into the level (a board is instantiated and loadable).
|
||||
const level = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState
|
||||
expect(level.node?.kind).toBe('level')
|
||||
expect(level.node?.levelSlug).toMatch(/^gr-mystery-play-/)
|
||||
expect((await fetch(`${baseUrl}/api/levels/${level.node!.levelSlug}`)).status).toBe(200)
|
||||
|
||||
// Report back → debrief dialogue.
|
||||
const debrief = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState
|
||||
expect(debrief.node?.kind).toBe('dialogue')
|
||||
|
||||
// Final advance → finished; current returns nothing active.
|
||||
const done = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState
|
||||
expect(done.playthrough.status).toBe('finished')
|
||||
expect(done.node).toBeNull()
|
||||
expect((await authFetch(`${baseUrl}/api/playthroughs/current`, undefined)).status).toBe(204)
|
||||
|
||||
// Identity scoping: another user has no playthrough and cannot advance this one.
|
||||
const playerTwo = `Bearer ${jwt.sign({ sub: 'player-two' }, process.env.JWT_SECRET!)}`
|
||||
expect((await authFetch(`${baseUrl}/api/playthroughs/current`, playerTwo)).status).toBe(204)
|
||||
expect((await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, playerTwo, { method: 'POST', headers: json, body: '{}' })).status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolvePoseAssetId } from './narrativeRepository.js'
|
||||
|
||||
describe('resolvePoseAssetId', () => {
|
||||
const poses = { neutral: 'asset-neutral', concerned: 'asset-concerned', missing: null }
|
||||
|
||||
it('returns the requested pose asset when present', () => {
|
||||
expect(resolvePoseAssetId(poses, 'concerned', 'neutral')).toBe('asset-concerned')
|
||||
})
|
||||
it('falls back to the NPC default pose when the requested pose is absent', () => {
|
||||
expect(resolvePoseAssetId(poses, 'pointing', 'neutral')).toBe('asset-neutral')
|
||||
})
|
||||
it('falls back to the default when the requested pose exists but has no artwork', () => {
|
||||
expect(resolvePoseAssetId(poses, 'missing', 'neutral')).toBe('asset-neutral')
|
||||
})
|
||||
it('returns null (no artwork) when neither requested nor default resolves', () => {
|
||||
expect(resolvePoseAssetId(poses, 'pointing', 'also-missing')).toBeNull()
|
||||
expect(resolvePoseAssetId(poses, null, null)).toBeNull()
|
||||
expect(resolvePoseAssetId({}, 'neutral', 'neutral')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import type { Pool, PoolClient } from 'pg'
|
||||
import { cloneBoard } from './boardClone.js'
|
||||
import type { ObjectStorage } from './objectStorage.js'
|
||||
|
||||
export type UploadedFile = { buffer: Buffer; originalname: string; mimetype: string; size: number }
|
||||
export type PoseDto = { poseKey: string; assetId: string; url: string }
|
||||
export type NpcDto = { id: string; key: string; name: string; role: string; defaultPose: string | null; poses: PoseDto[]; inUse: boolean }
|
||||
export type MysterySummary = { id: string; slug: string; title: string; nodes: number }
|
||||
|
||||
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: 'active' | 'finished' }
|
||||
export type RuntimeUtterance = {
|
||||
id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }
|
||||
poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null
|
||||
}
|
||||
export type RuntimeNode = {
|
||||
id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string
|
||||
componentKey?: string | null; levelSlug?: string | null
|
||||
utterances?: RuntimeUtterance[]; rootId?: string | null
|
||||
}
|
||||
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
|
||||
|
||||
export type MysteryAuthoring = {
|
||||
slug: string
|
||||
title: string
|
||||
cast: { key: string; name: string; role?: string; defaultPose?: string; poses?: { poseKey: string; assetId: string }[] }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a portrait with graceful fallback: the requested pose, else the NPC's
|
||||
* default pose, else no artwork. Pure so it is unit-testable without a DB.
|
||||
*/
|
||||
export function resolvePoseAssetId(
|
||||
poseAssets: Record<string, string | null | undefined>,
|
||||
requestedPoseKey: string | null | undefined,
|
||||
defaultPoseKey: string | null | undefined,
|
||||
): string | null {
|
||||
if (requestedPoseKey && poseAssets[requestedPoseKey]) return poseAssets[requestedPoseKey]!
|
||||
if (defaultPoseKey && poseAssets[defaultPoseKey]) return poseAssets[defaultPoseKey]!
|
||||
return null
|
||||
}
|
||||
|
||||
export interface NarrativeRepository {
|
||||
authorMystery(input: MysteryAuthoring): Promise<{ slug: string }>
|
||||
createPlaythrough(userId: string, mysterySlug?: string): Promise<PlaythroughState | null>
|
||||
getCurrentPlaythrough(userId: string): Promise<PlaythroughState | null>
|
||||
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
||||
listMysteries(): Promise<MysterySummary[]>
|
||||
listNpcs(): Promise<NpcDto[]>
|
||||
createNpc(input: { key: string; name: string; role?: string; defaultPose?: string | null }): Promise<NpcDto>
|
||||
updateNpc(id: string, input: { name?: string; role?: string; defaultPose?: string | null }): Promise<NpcDto | null>
|
||||
deleteNpc(id: string): Promise<'deleted' | 'in_use' | 'not_found'>
|
||||
addPose(npcId: string, poseKey: string, file: UploadedFile): Promise<NpcDto | null>
|
||||
deletePose(npcId: string, poseKey: string): Promise<NpcDto | null>
|
||||
}
|
||||
|
||||
type GraphNodeRow = { id: string; node_type: string; label: string; component_key: string | null; level_template_version_id: string | null }
|
||||
|
||||
export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStorage): NarrativeRepository {
|
||||
// ---- Runtime: walking the story graph -------------------------------------
|
||||
|
||||
// Resolve a dialogue node's whole utterance tree for the client to walk: each
|
||||
// utterance carries its ordered children and (if it exits the node) its terminal key.
|
||||
async function resolveDialogueGraph(nodeId: string): Promise<{ utterances: RuntimeUtterance[]; rootId: string | null }> {
|
||||
const [utterances, poses, terminals] = await Promise.all([
|
||||
pool.query<{ id: string; utterer: 'npc' | 'player'; npc_id: string | null; pose_key: string | null; text: string; parent_utterance_id: string | null; terminal_id: string | null; name: string | null; role: string | null; default_pose_key: string | null }>(
|
||||
`SELECT u.id,u.utterer,u.npc_id,u.pose_key,u.text,u.parent_utterance_id,u.terminal_id,n.name,n.role,n.default_pose_key
|
||||
FROM osint.utterances u LEFT JOIN osint.npcs n ON n.id=u.npc_id WHERE u.node_id=$1 ORDER BY u.sort_order`, [nodeId]),
|
||||
pool.query<{ npc_id: string; pose_key: string; asset_id: string | null }>(
|
||||
`SELECT p.npc_id,p.pose_key,p.asset_id FROM osint.npc_poses p
|
||||
WHERE p.npc_id IN (SELECT DISTINCT npc_id FROM osint.utterances WHERE node_id=$1 AND npc_id IS NOT NULL)`, [nodeId]),
|
||||
pool.query<{ id: string; terminal_key: string }>('SELECT id,terminal_key FROM osint.story_node_terminals WHERE parent_node_id=$1', [nodeId]),
|
||||
])
|
||||
const poseAssets = new Map<string, Record<string, string | null>>()
|
||||
for (const row of poses.rows) { const map = poseAssets.get(row.npc_id) || {}; map[row.pose_key] = row.asset_id; poseAssets.set(row.npc_id, map) }
|
||||
const terminalKey = new Map(terminals.rows.map(row => [row.id, row.terminal_key]))
|
||||
const children = new Map<string, string[]>()
|
||||
for (const row of utterances.rows) if (row.parent_utterance_id) children.set(row.parent_utterance_id, [...(children.get(row.parent_utterance_id) || []), row.id])
|
||||
const root = utterances.rows.find(row => !row.parent_utterance_id)
|
||||
return {
|
||||
rootId: root?.id ?? null,
|
||||
utterances: utterances.rows.map(row => {
|
||||
const assetId = row.npc_id ? resolvePoseAssetId(poseAssets.get(row.npc_id) || {}, row.pose_key, row.default_pose_key) : null
|
||||
return {
|
||||
id: row.id, utterer: row.utterer, speaker: { name: row.name || '', role: row.role || '' },
|
||||
poseUrl: assetId ? `/api/assets/${assetId}` : null, text: row.text,
|
||||
childIds: children.get(row.id) || [], terminalKey: row.terminal_id ? (terminalKey.get(row.terminal_id) ?? null) : null,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveNodeForPlay(nodeId: string, levelSlug: string | null): Promise<RuntimeNode | null> {
|
||||
const node = (await pool.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0]
|
||||
if (!node) return null
|
||||
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key }
|
||||
if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug }
|
||||
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, ...(await resolveDialogueGraph(node.id)) }
|
||||
return null // gates are auto-resolved during advance and never surfaced
|
||||
}
|
||||
|
||||
// Skip through gate nodes (deterministic gate is dumb: it follows its first terminal).
|
||||
async function resolveThroughGates(client: PoolClient, nodeId: string | null): Promise<GraphNodeRow | null> {
|
||||
let current = nodeId
|
||||
for (let guard = 0; guard < 50 && current; guard++) {
|
||||
const node = (await client.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id FROM osint.story_nodes WHERE id=$1', [current])).rows[0]
|
||||
if (!node) return null
|
||||
if (node.node_type !== 'det_gate' && node.node_type !== 'llm_gate') return node
|
||||
const next = await client.query<{ to_node_id: string | null }>('SELECT to_node_id FROM osint.story_node_terminals WHERE parent_node_id=$1 ORDER BY sort_order LIMIT 1', [current])
|
||||
current = next.rows[0]?.to_node_id ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function instantiateLevel(client: PoolClient, versionId: string, mysterySlug: string): Promise<string> {
|
||||
const source = (await client.query<{ board_id: string; title: string; subtitle: string }>(
|
||||
'SELECT board_id,title,subtitle FROM osint.level_template_versions WHERE id=$1 FOR SHARE', [versionId])).rows[0]
|
||||
if (!source) throw new Error('Level template version not found')
|
||||
const boardId = randomUUID(); const levelId = randomUUID()
|
||||
const levelSlug = `${mysterySlug}-play-${randomUUID().slice(0, 8)}`
|
||||
await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'level')`, [boardId])
|
||||
await client.query('INSERT INTO osint.levels (id,slug,board_id,source_template_version_id,title,subtitle) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||
[levelId, levelSlug, boardId, versionId, source.title, source.subtitle])
|
||||
await cloneBoard(client, source.board_id, boardId)
|
||||
return levelId
|
||||
}
|
||||
|
||||
async function stateForPlaythrough(playthroughId: string): Promise<PlaythroughState | null> {
|
||||
const row = (await pool.query<{ id: string; mystery_slug: string; current_node_id: string | null; level_slug: string | null; status: 'active' | 'finished' }>(
|
||||
`SELECT p.id,m.slug AS mystery_slug,p.current_node_id,l.slug AS level_slug,p.status
|
||||
FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id LEFT JOIN osint.levels l ON l.id=p.current_level_id
|
||||
WHERE p.id=$1`, [playthroughId])).rows[0]
|
||||
if (!row) return null
|
||||
const node = row.current_node_id ? await resolveNodeForPlay(row.current_node_id, row.level_slug) : null
|
||||
return { playthrough: { id: row.id, mysterySlug: row.mystery_slug, levelSlug: row.level_slug, status: row.status }, node }
|
||||
}
|
||||
|
||||
// ---- Assets & NPC catalog -------------------------------------------------
|
||||
|
||||
async function storeAsset(file: UploadedFile): Promise<string> {
|
||||
const checksum = createHash('sha256').update(file.buffer).digest('hex')
|
||||
const existing = await pool.query<{ id: string }>('SELECT id FROM osint.assets WHERE checksum_sha256=$1 AND byte_size=$2', [checksum, file.size])
|
||||
if (existing.rows[0]) return existing.rows[0].id
|
||||
const objectKey = `assets/${checksum.slice(0, 2)}/${checksum}`
|
||||
const stored = await objectStorage.putObject(objectKey, file.buffer, file.mimetype || 'application/octet-stream')
|
||||
const asset = await pool.query<{ id: string }>(`INSERT INTO osint.assets
|
||||
(id,original_name,mime_type,byte_size,content,checksum_sha256,storage_provider,storage_bucket,object_key,etag)
|
||||
VALUES ($1,$2,$3,$4,NULL,$5,'s3',$6,$7,$8)
|
||||
ON CONFLICT (checksum_sha256,byte_size) DO UPDATE SET checksum_sha256=EXCLUDED.checksum_sha256 RETURNING id`,
|
||||
[randomUUID(), file.originalname, file.mimetype || 'application/octet-stream', file.size, checksum, objectStorage.bucket, objectKey, stored.etag || null])
|
||||
return asset.rows[0].id
|
||||
}
|
||||
|
||||
async function loadNpc(id: string): Promise<NpcDto | null> {
|
||||
const npc = (await pool.query<{ id: string; npc_key: string; name: string; role: string; default_pose_key: string | null }>(
|
||||
'SELECT id,npc_key,name,role,default_pose_key FROM osint.npcs WHERE id=$1 AND mystery_id IS NULL', [id])).rows[0]
|
||||
if (!npc) return null
|
||||
const [poses, usage] = await Promise.all([
|
||||
pool.query<{ pose_key: string; asset_id: string }>('SELECT pose_key,asset_id FROM osint.npc_poses WHERE npc_id=$1 AND asset_id IS NOT NULL ORDER BY pose_key', [id]),
|
||||
pool.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.utterances WHERE npc_id=$1', [id]),
|
||||
])
|
||||
return {
|
||||
id: npc.id, key: npc.npc_key, name: npc.name, role: npc.role, defaultPose: npc.default_pose_key,
|
||||
poses: poses.rows.map(pose => ({ poseKey: pose.pose_key, assetId: pose.asset_id, url: `/api/assets/${pose.asset_id}` })),
|
||||
inUse: Number(usage.rows[0].count) > 0,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async authorMystery(input) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
// Dev-friendly replace: re-authoring the same slug supersedes the previous
|
||||
// mystery (cascades to its graph, cast links, and playthroughs).
|
||||
await client.query('DELETE FROM osint.mysteries WHERE slug=$1', [input.slug])
|
||||
const mysteryId = randomUUID()
|
||||
await client.query('INSERT INTO osint.mysteries (id,slug,title) VALUES ($1,$2,$3)', [mysteryId, input.slug, input.title])
|
||||
// NPCs are global templates referenced by key; create the first time a key is
|
||||
// seen and never clobber an existing one (admin edits persist).
|
||||
for (const npc of input.cast) {
|
||||
const existing = await client.query('SELECT 1 FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [npc.key])
|
||||
if (existing.rows[0]) continue
|
||||
const npcId = randomUUID()
|
||||
await client.query('INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key) VALUES ($1,NULL,$2,$3,$4,$5)',
|
||||
[npcId, npc.key, npc.name, npc.role || '', npc.defaultPose || null])
|
||||
for (const pose of npc.poses || []) await client.query(
|
||||
'INSERT INTO osint.npc_poses (id,npc_id,pose_key,asset_id) VALUES ($1,$2,$3,$4)', [randomUUID(), npcId, pose.poseKey, pose.assetId])
|
||||
}
|
||||
await client.query('COMMIT')
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
return { slug: input.slug }
|
||||
},
|
||||
|
||||
async createPlaythrough(userId, mysterySlug) {
|
||||
const client = await pool.connect()
|
||||
let playthroughId: string
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const mystery = (await client.query<{ id: string; slug: string; entry_node_id: string | null }>(
|
||||
mysterySlug
|
||||
? 'SELECT id,slug,entry_node_id FROM osint.mysteries WHERE slug=$1'
|
||||
: 'SELECT id,slug,entry_node_id FROM osint.mysteries WHERE entry_node_id IS NOT NULL ORDER BY created_at DESC LIMIT 1',
|
||||
mysterySlug ? [mysterySlug] : [])).rows[0]
|
||||
if (!mystery?.entry_node_id) { await client.query('ROLLBACK'); return null }
|
||||
const entry = await resolveThroughGates(client, mystery.entry_node_id)
|
||||
if (!entry) { await client.query('ROLLBACK'); return null }
|
||||
const levelId = entry.node_type === 'level' && entry.level_template_version_id
|
||||
? await instantiateLevel(client, entry.level_template_version_id, mystery.slug) : null
|
||||
playthroughId = randomUUID()
|
||||
await client.query('INSERT INTO osint.playthroughs (id,user_id,mystery_id,current_node_id,current_level_id) VALUES ($1,$2,$3,$4,$5)',
|
||||
[playthroughId, userId, mystery.id, entry.id, levelId])
|
||||
await client.query('COMMIT')
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
return stateForPlaythrough(playthroughId)
|
||||
},
|
||||
|
||||
async getCurrentPlaythrough(userId) {
|
||||
const row = (await pool.query<{ id: string }>(
|
||||
`SELECT id FROM osint.playthroughs WHERE user_id=$1 AND status='active' ORDER BY updated_at DESC LIMIT 1`, [userId])).rows[0]
|
||||
return row ? stateForPlaythrough(row.id) : null
|
||||
},
|
||||
|
||||
async advancePlaythrough(userId, playthroughId, terminalKey) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const playthrough = (await client.query<{ current_node_id: string | null; mystery_slug: string }>(
|
||||
`SELECT p.current_node_id,m.slug AS mystery_slug FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id
|
||||
WHERE p.id=$1 AND p.user_id=$2 AND p.status='active' FOR UPDATE OF p`, [playthroughId, userId])).rows[0]
|
||||
if (!playthrough) { await client.query('ROLLBACK'); return { ok: false, error: 'Playthrough not found' } }
|
||||
if (!playthrough.current_node_id) { await client.query('ROLLBACK'); return { ok: false, error: 'Playthrough already finished' } }
|
||||
const terminals = (await client.query<{ terminal_key: string; to_node_id: string | null }>(
|
||||
'SELECT terminal_key,to_node_id FROM osint.story_node_terminals WHERE parent_node_id=$1 ORDER BY sort_order', [playthrough.current_node_id])).rows
|
||||
const wired = terminals.filter(t => t.to_node_id)
|
||||
const chosen = terminalKey ? terminals.find(t => t.terminal_key === terminalKey)
|
||||
: wired.length === 1 ? wired[0] : terminals.length === 1 ? terminals[0] : undefined
|
||||
if (!chosen) { await client.query('ROLLBACK'); return { ok: false, error: 'Ambiguous or unknown terminal — specify one' } }
|
||||
|
||||
const target = await resolveThroughGates(client, chosen.to_node_id)
|
||||
if (!target) {
|
||||
await client.query(`UPDATE osint.playthroughs SET status='finished',current_node_id=NULL,current_level_id=NULL,updated_at=NOW() WHERE id=$1`, [playthroughId])
|
||||
} else {
|
||||
const levelId = target.node_type === 'level' && target.level_template_version_id
|
||||
? await instantiateLevel(client, target.level_template_version_id, playthrough.mystery_slug) : null
|
||||
await client.query('UPDATE osint.playthroughs SET current_node_id=$2,current_level_id=$3,updated_at=NOW() WHERE id=$1', [playthroughId, target.id, levelId])
|
||||
}
|
||||
await client.query('COMMIT')
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
const state = await stateForPlaythrough(playthroughId)
|
||||
return { ok: true, state: state ?? undefined }
|
||||
},
|
||||
|
||||
async listMysteries() {
|
||||
const result = await pool.query<{ id: string; slug: string; title: string; nodes: string }>(
|
||||
`SELECT m.id,m.slug,m.title,COUNT(n.id)::text AS nodes
|
||||
FROM osint.mysteries m LEFT JOIN osint.story_nodes n ON n.mystery_id=m.id
|
||||
GROUP BY m.id ORDER BY m.created_at DESC`)
|
||||
return result.rows.map(row => ({ id: row.id, slug: row.slug, title: row.title, nodes: Number(row.nodes) }))
|
||||
},
|
||||
|
||||
async listNpcs() {
|
||||
const npcs = await pool.query<{ id: string }>('SELECT id FROM osint.npcs WHERE mystery_id IS NULL ORDER BY name')
|
||||
return (await Promise.all(npcs.rows.map(row => loadNpc(row.id)))).filter((npc): npc is NpcDto => npc !== null)
|
||||
},
|
||||
|
||||
async createNpc(input) {
|
||||
const key = input.key.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
if (!key) throw new Error('An NPC key is required')
|
||||
const id = randomUUID()
|
||||
await pool.query('INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key) VALUES ($1,NULL,$2,$3,$4,$5)',
|
||||
[id, key, input.name.trim() || key, input.role?.trim() || '', input.defaultPose || null])
|
||||
return (await loadNpc(id))!
|
||||
},
|
||||
|
||||
async updateNpc(id, input) {
|
||||
const existing = await loadNpc(id)
|
||||
if (!existing) return null
|
||||
await pool.query('UPDATE osint.npcs SET name=$2,role=$3,default_pose_key=$4 WHERE id=$1 AND mystery_id IS NULL', [
|
||||
id, input.name?.trim() ?? existing.name, input.role?.trim() ?? existing.role,
|
||||
input.defaultPose === undefined ? existing.defaultPose : (input.defaultPose || null),
|
||||
])
|
||||
return loadNpc(id)
|
||||
},
|
||||
|
||||
async deleteNpc(id) {
|
||||
const existing = await loadNpc(id)
|
||||
if (!existing) return 'not_found'
|
||||
if (existing.inUse) return 'in_use'
|
||||
await pool.query('DELETE FROM osint.npcs WHERE id=$1 AND mystery_id IS NULL', [id])
|
||||
return 'deleted'
|
||||
},
|
||||
|
||||
async addPose(npcId, poseKey, file) {
|
||||
const npc = await loadNpc(npcId)
|
||||
if (!npc) return null
|
||||
const key = poseKey.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'default'
|
||||
const assetId = await storeAsset(file)
|
||||
await pool.query(`INSERT INTO osint.npc_poses (id,npc_id,pose_key,asset_id) VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (npc_id,pose_key) DO UPDATE SET asset_id=EXCLUDED.asset_id`, [randomUUID(), npcId, key, assetId])
|
||||
return loadNpc(npcId)
|
||||
},
|
||||
|
||||
async deletePose(npcId, poseKey) {
|
||||
const npc = await loadNpc(npcId)
|
||||
if (!npc) return null
|
||||
await pool.query('DELETE FROM osint.npc_poses WHERE npc_id=$1 AND pose_key=$2', [npcId, poseKey])
|
||||
return loadNpc(npcId)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { createServer } from 'node:net'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import pg from 'pg'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { StoryGraphDto, StoryNodeDto, LevelTemplateOption, UtteranceDto } from './storyGraphRepository.js'
|
||||
import { runMigrations } from './migrations.js'
|
||||
|
||||
const { Client } = pg
|
||||
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
|
||||
const suite = baseDatabaseUrl ? describe : describe.skip
|
||||
const databaseName = `osint_storygraph_test_${process.pid}_${Date.now()}`
|
||||
let adminClient: InstanceType<typeof Client>
|
||||
let appServer: Awaited<typeof import('./index.js')>['server']
|
||||
let appPool: Awaited<typeof import('./index.js')>['pool']
|
||||
let baseUrl = ''
|
||||
let auth = ''
|
||||
|
||||
async function availablePort() {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const probe = createServer()
|
||||
probe.once('error', reject)
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const address = probe.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
probe.close(error => error ? reject(error) : resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
const json = { 'content-type': 'application/json' }
|
||||
function admin(url: string, init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers); headers.set('authorization', auth)
|
||||
return fetch(url, { ...init, headers })
|
||||
}
|
||||
|
||||
suite('story graph authoring API', () => {
|
||||
beforeAll(async () => {
|
||||
const adminUrl = new URL(baseDatabaseUrl!); adminUrl.pathname = '/postgres'
|
||||
adminClient = new Client({ connectionString: adminUrl.toString() }); await adminClient.connect()
|
||||
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
|
||||
const testUrl = new URL(baseDatabaseUrl!); testUrl.pathname = `/${databaseName}`
|
||||
const databaseUrl = testUrl.toString()
|
||||
await runMigrations(databaseUrl, path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations'), () => undefined)
|
||||
const port = await availablePort()
|
||||
process.env.DATABASE_URL = databaseUrl
|
||||
process.env.LEVEL_EDITING_ENABLED = 'true'
|
||||
process.env.JWT_SECRET = 'osint-storygraph-jwt'
|
||||
process.env.ASSET_STORAGE_DRIVER = 'memory'
|
||||
process.env.PORT = String(port)
|
||||
const serverModule = await import('./index.js')
|
||||
appServer = serverModule.server; appPool = serverModule.pool
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
auth = `Bearer ${jwt.sign({ sub: 'sg-admin', role: 'admin' }, process.env.JWT_SECRET)}`
|
||||
})
|
||||
afterAll(async () => {
|
||||
if (appServer) await new Promise<void>((resolve, reject) => appServer.close(error => error ? reject(error) : resolve()))
|
||||
if (appPool) await appPool.end()
|
||||
if (!adminClient) return
|
||||
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
|
||||
await adminClient.end()
|
||||
})
|
||||
|
||||
async function makeMystery(slug: string) {
|
||||
await admin(`${baseUrl}/api/levels`, { method: 'POST', headers: json, body: JSON.stringify({ id: `${slug}-src`, title: 'SG Source' }) })
|
||||
await admin(`${baseUrl}/api/levels/${slug}-src/templates?edit=1`, { method: 'POST', headers: json, body: JSON.stringify({ name: `${slug} chapter` }) })
|
||||
await admin(`${baseUrl}/api/mysteries?edit=1`, { method: 'POST', headers: json, body: JSON.stringify({ slug, title: slug, chapters: [{ templateSlug: `${slug}-chapter` }], cast: [], cutscenes: [] }) })
|
||||
const list = await (await admin(`${baseUrl}/api/admin/mysteries`)).json() as { id: string; slug: string }[]
|
||||
return list.find(m => m.slug === slug)!.id
|
||||
}
|
||||
|
||||
it('builds a node graph: create, wire, configure, set entry', async () => {
|
||||
const mysteryId = await makeMystery('sg-mystery')
|
||||
const templates = await (await admin(`${baseUrl}/api/admin/level-templates`)).json() as LevelTemplateOption[]
|
||||
const chapter = templates.find(t => t.slug === 'sg-mystery-chapter')!
|
||||
|
||||
const cutscene = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 40, ypos: 40 }) })).json() as StoryNodeDto
|
||||
const level = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'level', xpos: 320, ypos: 40 }) })).json() as StoryNodeDto
|
||||
expect(cutscene.terminals).toHaveLength(1)
|
||||
expect(cutscene.terminals[0].terminalKey).toBe('continue')
|
||||
expect(level.terminals[0].terminalKey).toBe('report_back')
|
||||
|
||||
// Wire cutscene → level; configure level template; set entrypoint.
|
||||
expect((await admin(`${baseUrl}/api/admin/story-terminals/${cutscene.terminals[0].id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ toNodeId: level.id }) })).status).toBe(200)
|
||||
await admin(`${baseUrl}/api/admin/story-nodes/${level.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ levelTemplateVersionId: chapter.versionId }) })
|
||||
await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/entry`, { method: 'PUT', headers: json, body: JSON.stringify({ nodeId: cutscene.id }) })
|
||||
|
||||
const graph = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`)).json() as StoryGraphDto
|
||||
expect(graph.nodes).toHaveLength(2)
|
||||
expect(graph.entryNodeId).toBe(cutscene.id)
|
||||
expect(graph.nodes.find(n => n.id === cutscene.id)!.terminals[0].toNodeId).toBe(level.id)
|
||||
expect(graph.nodes.find(n => n.id === level.id)!.levelTemplateVersionId).toBe(chapter.versionId)
|
||||
|
||||
// Deleting the target node unwires (SET NULL) rather than deleting the source's port.
|
||||
await admin(`${baseUrl}/api/admin/story-nodes/${level.id}`, { method: 'DELETE' })
|
||||
const after = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`)).json() as StoryGraphDto
|
||||
expect(after.nodes).toHaveLength(1)
|
||||
expect(after.nodes[0].terminals[0].toNodeId).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects wiring a terminal across mysteries', async () => {
|
||||
const mysteryA = await makeMystery('sg-a')
|
||||
const mysteryB = await makeMystery('sg-b')
|
||||
const nodeA = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryA}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 0, ypos: 0 }) })).json() as StoryNodeDto
|
||||
const nodeB = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryB}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 0, ypos: 0 }) })).json() as StoryNodeDto
|
||||
const cross = await admin(`${baseUrl}/api/admin/story-terminals/${nodeA.terminals[0].id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ toNodeId: nodeB.id }) })
|
||||
expect(cross.status).toBe(400)
|
||||
})
|
||||
|
||||
it('crafts utterances: NPC prompt, player option, wiring and same-node validation', async () => {
|
||||
const mysteryId = await makeMystery('sg-utt')
|
||||
const dialogue = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'dialogue', xpos: 0, ypos: 0 }) })).json() as StoryNodeDto
|
||||
const terminalId = dialogue.terminals[0].id
|
||||
const otherNode = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'level', xpos: 400, ypos: 0 }) })).json() as StoryNodeDto
|
||||
|
||||
const prompt = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`, { method: 'POST', headers: json, body: JSON.stringify({ utterer: 'npc', xpos: 40, ypos: 40, text: 'Are you ready?' }) })).json() as UtteranceDto
|
||||
const yes = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`, { method: 'POST', headers: json, body: JSON.stringify({ utterer: 'player', xpos: 300, ypos: 40, text: 'Yes' }) })).json() as UtteranceDto
|
||||
|
||||
// 'Yes' hangs under the prompt (parent); and exits via the node's terminal.
|
||||
expect((await admin(`${baseUrl}/api/admin/utterances/${yes.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ parentUtteranceId: prompt.id }) })).status).toBe(200)
|
||||
expect((await admin(`${baseUrl}/api/admin/utterances/${yes.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ terminalId }) })).status).toBe(200)
|
||||
|
||||
const list = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`)).json() as UtteranceDto[]
|
||||
const savedYes = list.find(u => u.id === yes.id)!
|
||||
expect(savedYes.parentUtteranceId).toBe(prompt.id)
|
||||
expect(savedYes.terminalId).toBe(terminalId)
|
||||
|
||||
// A terminal from another node cannot be used as this utterance's exit.
|
||||
const foreign = await admin(`${baseUrl}/api/admin/utterances/${yes.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ terminalId: otherNode.terminals[0].id }) })
|
||||
expect(foreign.status).toBe(400)
|
||||
|
||||
// Deleting the NPC prompt cascades its player options.
|
||||
await admin(`${baseUrl}/api/admin/utterances/${prompt.id}`, { method: 'DELETE' })
|
||||
const after = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`)).json() as UtteranceDto[]
|
||||
expect(after).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses graph writes without an admin claim', async () => {
|
||||
const mysteryId = await makeMystery('sg-guard')
|
||||
expect((await fetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 0, ypos: 0 }) })).status).toBe(403)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Pool, PoolClient } from 'pg'
|
||||
|
||||
export type GraphSpecNode = {
|
||||
key: string; type: StoryNodeType; label?: string; x: number; y: number
|
||||
componentKey?: string; templateSlug?: string; version?: number
|
||||
terminals?: { key: string; label?: string; to?: string | null }[]
|
||||
utterances?: { npc?: string; pose?: string; text: string; utterer?: Utterer }[]
|
||||
}
|
||||
export type GraphSpec = { entry: string; nodes: GraphSpecNode[] }
|
||||
|
||||
export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
|
||||
export type TerminalDto = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number }
|
||||
export type StoryNodeDto = {
|
||||
id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean
|
||||
xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null
|
||||
terminals: TerminalDto[]
|
||||
}
|
||||
export type StoryGraphDto = { mysteryId: string; entryNodeId: string | null; nodes: StoryNodeDto[] }
|
||||
export type LevelTemplateOption = { versionId: string; slug: string; name: string; version: number }
|
||||
export type Utterer = 'npc' | 'player'
|
||||
export type UtteranceDto = {
|
||||
id: string; nodeId: string; utterer: Utterer; npcId: string | null; poseKey: string | null; text: string
|
||||
parentUtteranceId: string | null; advancesToUtteranceId: string | null; terminalId: string | null
|
||||
effect: string | null; xpos: number; ypos: number; sortOrder: number
|
||||
}
|
||||
|
||||
// A sensible starter terminal set so a freshly dropped node is immediately wireable.
|
||||
const DEFAULT_TERMINALS: Record<StoryNodeType, { key: string; label: string }[]> = {
|
||||
cutscene: [{ key: 'continue', label: 'Continue' }],
|
||||
dialogue: [{ key: 'continue', label: 'Continue' }],
|
||||
level: [{ key: 'report_back', label: 'Report back' }],
|
||||
det_gate: [{ key: 'pass', label: 'Pass' }],
|
||||
llm_gate: [{ key: 'pass', label: 'Pass' }],
|
||||
}
|
||||
|
||||
export interface StoryGraphRepository {
|
||||
getGraph(mysteryId: string): Promise<StoryGraphDto | null>
|
||||
createNode(mysteryId: string, input: { nodeType: StoryNodeType; xpos: number; ypos: number; label?: string }): Promise<StoryNodeDto | null>
|
||||
updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null }>): Promise<StoryNodeDto | null>
|
||||
deleteNode(nodeId: string): Promise<boolean>
|
||||
addTerminal(nodeId: string, input: { terminalKey: string; label?: string }): Promise<StoryNodeDto | null>
|
||||
updateTerminal(terminalId: string, input: Partial<{ label: string; sortOrder: number; toNodeId: string | null }>): Promise<{ ok: boolean; error?: string }>
|
||||
deleteTerminal(terminalId: string): Promise<boolean>
|
||||
setEntryNode(mysteryId: string, nodeId: string | null): Promise<{ ok: boolean; error?: string }>
|
||||
listLevelTemplates(): Promise<LevelTemplateOption[]>
|
||||
listUtterances(nodeId: string): Promise<UtteranceDto[]>
|
||||
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 }>
|
||||
deleteUtterance(id: string): Promise<boolean>
|
||||
authorGraph(mysteryId: string, spec: GraphSpec): Promise<{ nodes: number } | null>
|
||||
}
|
||||
|
||||
export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
||||
async function mysteryOfNode(nodeId: string): Promise<string | null> {
|
||||
const result = await pool.query<{ mystery_id: string }>('SELECT mystery_id FROM osint.story_nodes WHERE id=$1', [nodeId])
|
||||
return result.rows[0]?.mystery_id ?? null
|
||||
}
|
||||
|
||||
async function loadGraph(mysteryId: string): Promise<StoryGraphDto | null> {
|
||||
const mystery = await pool.query<{ id: string; entry_node_id: string | null }>('SELECT id,entry_node_id FROM osint.mysteries WHERE id=$1', [mysteryId])
|
||||
if (!mystery.rows[0]) return null
|
||||
const [nodes, terminals] = await Promise.all([
|
||||
pool.query<{ id: string; node_type: StoryNodeType; label: string; has_utterances: boolean; xpos: number; ypos: number; level_template_version_id: string | null; component_key: string | null }>(
|
||||
'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key FROM osint.story_nodes WHERE mystery_id=$1 ORDER BY created_at', [mysteryId]),
|
||||
pool.query<{ id: string; parent_node_id: string; terminal_key: string; label: string; to_node_id: string | null; sort_order: number }>(
|
||||
`SELECT t.id,t.parent_node_id,t.terminal_key,t.label,t.to_node_id,t.sort_order FROM osint.story_node_terminals t
|
||||
JOIN osint.story_nodes n ON n.id=t.parent_node_id WHERE n.mystery_id=$1 ORDER BY t.sort_order,t.terminal_key`, [mysteryId]),
|
||||
])
|
||||
const byNode = new Map<string, TerminalDto[]>()
|
||||
for (const row of terminals.rows) {
|
||||
const list = byNode.get(row.parent_node_id) || []
|
||||
list.push({ id: row.id, terminalKey: row.terminal_key, label: row.label, toNodeId: row.to_node_id, sortOrder: row.sort_order })
|
||||
byNode.set(row.parent_node_id, list)
|
||||
}
|
||||
return {
|
||||
mysteryId, entryNodeId: mystery.rows[0].entry_node_id,
|
||||
nodes: nodes.rows.map(row => ({
|
||||
id: row.id, nodeType: row.node_type, label: row.label, hasUtterances: row.has_utterances,
|
||||
xpos: row.xpos, ypos: row.ypos, levelTemplateVersionId: row.level_template_version_id, componentKey: row.component_key,
|
||||
terminals: byNode.get(row.id) || [],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getGraph: loadGraph,
|
||||
|
||||
async createNode(mysteryId, input) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const mystery = await client.query('SELECT 1 FROM osint.mysteries WHERE id=$1', [mysteryId])
|
||||
if (!mystery.rows[0]) { await client.query('ROLLBACK'); return null }
|
||||
const nodeId = randomUUID()
|
||||
const label = input.label?.trim() || input.nodeType
|
||||
await client.query('INSERT INTO osint.story_nodes (id,mystery_id,node_type,label,xpos,ypos,has_utterances) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||
[nodeId, mysteryId, input.nodeType, label, input.xpos, input.ypos, input.nodeType === 'dialogue' || input.nodeType === 'cutscene'])
|
||||
for (const [index, terminal] of DEFAULT_TERMINALS[input.nodeType].entries())
|
||||
await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5)',
|
||||
[randomUUID(), nodeId, terminal.key, terminal.label, index])
|
||||
await client.query('COMMIT')
|
||||
const graph = await loadGraph(mysteryId)
|
||||
return graph?.nodes.find(node => node.id === nodeId) ?? null
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
},
|
||||
|
||||
async updateNode(nodeId, input) {
|
||||
const mysteryId = await mysteryOfNode(nodeId)
|
||||
if (!mysteryId) return null
|
||||
const sets: string[] = []
|
||||
const values: unknown[] = [nodeId]
|
||||
const set = (column: string, value: unknown) => { values.push(value); sets.push(`${column}=$${values.length}`) }
|
||||
if (input.label !== undefined) set('label', input.label.trim())
|
||||
if (input.xpos !== undefined) set('xpos', input.xpos)
|
||||
if (input.ypos !== undefined) set('ypos', input.ypos)
|
||||
if (input.hasUtterances !== undefined) set('has_utterances', input.hasUtterances)
|
||||
if (input.componentKey !== undefined) set('component_key', input.componentKey || null)
|
||||
if (input.levelTemplateVersionId !== undefined) set('level_template_version_id', input.levelTemplateVersionId || null)
|
||||
if (sets.length) await pool.query(`UPDATE osint.story_nodes SET ${sets.join(',')} WHERE id=$1`, values)
|
||||
const graph = await loadGraph(mysteryId)
|
||||
return graph?.nodes.find(node => node.id === nodeId) ?? null
|
||||
},
|
||||
|
||||
async deleteNode(nodeId) {
|
||||
const result = await pool.query('DELETE FROM osint.story_nodes WHERE id=$1', [nodeId])
|
||||
return (result.rowCount ?? 0) > 0
|
||||
},
|
||||
|
||||
async addTerminal(nodeId, input) {
|
||||
const mysteryId = await mysteryOfNode(nodeId)
|
||||
if (!mysteryId) return null
|
||||
const key = input.terminalKey.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'out'
|
||||
const order = await pool.query<{ next: number }>('SELECT COALESCE(MAX(sort_order),-1)+1 AS next FROM osint.story_node_terminals WHERE parent_node_id=$1', [nodeId])
|
||||
await pool.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (parent_node_id,terminal_key) DO NOTHING',
|
||||
[randomUUID(), nodeId, key, input.label?.trim() || key, order.rows[0].next])
|
||||
const graph = await loadGraph(mysteryId)
|
||||
return graph?.nodes.find(node => node.id === nodeId) ?? null
|
||||
},
|
||||
|
||||
async updateTerminal(terminalId, input) {
|
||||
const owner = await pool.query<{ parent_node_id: string; mystery_id: string }>(
|
||||
`SELECT t.parent_node_id, n.mystery_id FROM osint.story_node_terminals t JOIN osint.story_nodes n ON n.id=t.parent_node_id WHERE t.id=$1`, [terminalId])
|
||||
if (!owner.rows[0]) return { ok: false, error: 'Terminal not found' }
|
||||
if (input.toNodeId !== undefined && input.toNodeId !== null) {
|
||||
const target = await mysteryOfNode(input.toNodeId)
|
||||
if (target !== owner.rows[0].mystery_id) return { ok: false, error: 'A wire must stay within the same mystery' }
|
||||
}
|
||||
const sets: string[] = []
|
||||
const values: unknown[] = [terminalId]
|
||||
const set = (column: string, value: unknown) => { values.push(value); sets.push(`${column}=$${values.length}`) }
|
||||
if (input.label !== undefined) set('label', input.label.trim())
|
||||
if (input.sortOrder !== undefined) set('sort_order', input.sortOrder)
|
||||
if (input.toNodeId !== undefined) set('to_node_id', input.toNodeId)
|
||||
if (sets.length) await pool.query(`UPDATE osint.story_node_terminals SET ${sets.join(',')} WHERE id=$1`, values)
|
||||
return { ok: true }
|
||||
},
|
||||
|
||||
async deleteTerminal(terminalId) {
|
||||
const result = await pool.query('DELETE FROM osint.story_node_terminals WHERE id=$1', [terminalId])
|
||||
return (result.rowCount ?? 0) > 0
|
||||
},
|
||||
|
||||
async setEntryNode(mysteryId, nodeId) {
|
||||
if (nodeId !== null) {
|
||||
const target = await mysteryOfNode(nodeId)
|
||||
if (target !== mysteryId) return { ok: false, error: 'Entry node must belong to the mystery' }
|
||||
}
|
||||
const result = await pool.query('UPDATE osint.mysteries SET entry_node_id=$2 WHERE id=$1', [mysteryId, nodeId])
|
||||
return (result.rowCount ?? 0) > 0 ? { ok: true } : { ok: false, error: 'Mystery not found' }
|
||||
},
|
||||
|
||||
async listLevelTemplates() {
|
||||
const result = await pool.query<{ version_id: string; slug: string; name: string; version: number }>(
|
||||
`SELECT v.id AS version_id,t.slug,t.name,v.version FROM osint.level_templates t
|
||||
JOIN osint.level_template_versions v ON v.id=t.current_version_id ORDER BY t.name`)
|
||||
return result.rows.map(row => ({ versionId: row.version_id, slug: row.slug, name: row.name, version: row.version }))
|
||||
},
|
||||
|
||||
async listUtterances(nodeId) {
|
||||
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
|
||||
FROM osint.utterances WHERE node_id=$1 ORDER BY sort_order,id`, [nodeId])
|
||||
return result.rows.map(mapUtterance)
|
||||
},
|
||||
|
||||
async createUtterance(nodeId, input) {
|
||||
const node = await pool.query('SELECT 1 FROM osint.story_nodes WHERE id=$1', [nodeId])
|
||||
if (!node.rows[0]) return null
|
||||
const id = randomUUID()
|
||||
const order = await pool.query<{ next: number }>('SELECT COALESCE(MAX(sort_order),-1)+1 AS next FROM osint.utterances WHERE node_id=$1', [nodeId])
|
||||
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])
|
||||
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])
|
||||
return mapUtterance(created.rows[0])
|
||||
},
|
||||
|
||||
async updateUtterance(id, input) {
|
||||
const owner = await pool.query<{ node_id: string }>('SELECT node_id FROM osint.utterances WHERE id=$1', [id])
|
||||
if (!owner.rows[0]) return { ok: false, error: 'Utterance not found' }
|
||||
const nodeId = owner.rows[0].node_id
|
||||
// Same-node integrity for the three links.
|
||||
for (const link of ['parentUtteranceId', 'advancesToUtteranceId'] as const) {
|
||||
const value = input[link]
|
||||
if (value) {
|
||||
const target = await pool.query<{ node_id: string }>('SELECT node_id FROM osint.utterances WHERE id=$1', [value])
|
||||
if (target.rows[0]?.node_id !== nodeId) return { ok: false, error: 'Linked utterance must be in the same node' }
|
||||
}
|
||||
}
|
||||
if (input.terminalId) {
|
||||
const terminal = await pool.query<{ parent_node_id: string }>('SELECT parent_node_id FROM osint.story_node_terminals WHERE id=$1', [input.terminalId])
|
||||
if (terminal.rows[0]?.parent_node_id !== nodeId) return { ok: false, error: 'Terminal must belong to this node' }
|
||||
}
|
||||
const columns: Record<string, string> = {
|
||||
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',
|
||||
}
|
||||
const sets: string[] = []
|
||||
const values: unknown[] = [id]
|
||||
for (const [key, column] of Object.entries(columns)) {
|
||||
if ((input as Record<string, unknown>)[key] !== undefined) { values.push((input as Record<string, unknown>)[key]); sets.push(`${column}=$${values.length}`) }
|
||||
}
|
||||
if (sets.length) await pool.query(`UPDATE osint.utterances SET ${sets.join(',')} WHERE id=$1`, values)
|
||||
return { ok: true }
|
||||
},
|
||||
|
||||
async deleteUtterance(id) {
|
||||
const result = await pool.query('DELETE FROM osint.utterances WHERE id=$1', [id])
|
||||
return (result.rowCount ?? 0) > 0
|
||||
},
|
||||
|
||||
// Seed/replace a mystery's whole graph from a spec (used by the manifest importer),
|
||||
// so a default flow is authored content that survives re-imports.
|
||||
async authorGraph(mysteryId, spec) {
|
||||
const client: PoolClient = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const mystery = await client.query('SELECT 1 FROM osint.mysteries WHERE id=$1', [mysteryId])
|
||||
if (!mystery.rows[0]) { await client.query('ROLLBACK'); return null }
|
||||
await client.query('UPDATE osint.mysteries SET entry_node_id=NULL WHERE id=$1', [mysteryId])
|
||||
await client.query('DELETE FROM osint.story_nodes WHERE mystery_id=$1', [mysteryId])
|
||||
|
||||
const nodeIds = new Map<string, string>()
|
||||
const terminalIds = new Map<string, string>() // `${nodeKey}:${terminalKey}` -> id
|
||||
for (const node of spec.nodes) {
|
||||
const id = randomUUID(); nodeIds.set(node.key, id)
|
||||
let versionId: string | null = null
|
||||
if (node.type === 'level' && node.templateSlug) {
|
||||
const version = await client.query<{ id: string }>(
|
||||
`SELECT v.id FROM osint.level_templates t JOIN osint.level_template_versions v ON v.template_id=t.id
|
||||
WHERE t.slug=$1 AND (($2::int IS NULL AND v.id=t.current_version_id) OR v.version=$2)`, [node.templateSlug, node.version ?? null])
|
||||
versionId = version.rows[0]?.id ?? null
|
||||
if (!versionId) throw new Error(`Graph node ${node.key}: unknown level template ${node.templateSlug}`)
|
||||
}
|
||||
await client.query('INSERT INTO osint.story_nodes (id,mystery_id,node_type,label,xpos,ypos,has_utterances,level_template_version_id,component_key) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
|
||||
[id, mysteryId, node.type, node.label || node.type, node.x, node.y, Boolean(node.utterances?.length), versionId, node.componentKey || null])
|
||||
for (const [index, terminal] of (node.terminals || []).entries()) {
|
||||
const terminalId = randomUUID(); terminalIds.set(`${node.key}:${terminal.key}`, terminalId)
|
||||
await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5)',
|
||||
[terminalId, id, terminal.key, terminal.label || terminal.key, index])
|
||||
}
|
||||
}
|
||||
// Wire terminals now that all nodes exist.
|
||||
for (const node of spec.nodes) for (const terminal of node.terminals || []) {
|
||||
if (!terminal.to) continue
|
||||
const toId = nodeIds.get(terminal.to)
|
||||
if (!toId) throw new Error(`Graph node ${node.key}: terminal ${terminal.key} points at unknown node ${terminal.to}`)
|
||||
await client.query('UPDATE osint.story_node_terminals SET to_node_id=$2 WHERE id=$1', [terminalIds.get(`${node.key}:${terminal.key}`), toId])
|
||||
}
|
||||
// Utterances (linear seed): create, then chain them and exit the last one via
|
||||
// the node's first terminal, so the crafter shows a connected flow.
|
||||
for (const node of spec.nodes) {
|
||||
const created: string[] = []
|
||||
for (const [index, utterance] of (node.utterances || []).entries()) {
|
||||
let npcId: string | null = null
|
||||
if (utterance.npc) {
|
||||
const npc = await client.query<{ id: string }>('SELECT id FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [utterance.npc])
|
||||
npcId = npc.rows[0]?.id ?? null
|
||||
}
|
||||
const utteranceId = randomUUID(); created.push(utteranceId)
|
||||
await client.query('INSERT INTO osint.utterances (id,node_id,utterer,npc_id,pose_key,text,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
|
||||
[utteranceId, nodeIds.get(node.key), utterance.utterer || 'npc', npcId, utterance.pose || null, utterance.text, 40 + index * 250, 60, index])
|
||||
}
|
||||
// Chain via parent: each line follows the previous one (one child = linear).
|
||||
for (let i = 1; i < created.length; i++)
|
||||
await client.query('UPDATE osint.utterances SET parent_utterance_id=$2 WHERE id=$1', [created[i], created[i - 1]])
|
||||
const firstTerminalKey = node.terminals?.[0]?.key
|
||||
const exitTerminalId = firstTerminalKey ? terminalIds.get(`${node.key}:${firstTerminalKey}`) : undefined
|
||||
if (created.length && exitTerminalId)
|
||||
await client.query('UPDATE osint.utterances SET terminal_id=$2 WHERE id=$1', [created[created.length - 1], exitTerminalId])
|
||||
}
|
||||
const entryId = nodeIds.get(spec.entry)
|
||||
if (!entryId) throw new Error(`Graph entry node ${spec.entry} not found`)
|
||||
await client.query('UPDATE osint.mysteries SET entry_node_id=$2 WHERE id=$1', [mysteryId, entryId])
|
||||
await client.query('COMMIT')
|
||||
return { nodes: spec.nodes.length }
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type UtteranceRow = {
|
||||
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
|
||||
effect: string | null; xpos: number; ypos: number; sort_order: number
|
||||
}
|
||||
function mapUtterance(row: UtteranceRow): UtteranceDto {
|
||||
return {
|
||||
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,
|
||||
effect: row.effect, xpos: row.xpos, ypos: row.ypos, sortOrder: row.sort_order,
|
||||
}
|
||||
}
|
||||
+117
-22
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView } from './types'
|
||||
import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState, type PlaythroughSummary, type RuntimeNode } from './narrative'
|
||||
import { AdminPanel } from './admin'
|
||||
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
||||
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
||||
|
||||
@@ -60,38 +62,94 @@ export function App() {
|
||||
const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
|
||||
const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null)
|
||||
const [threadDraft, setThreadDraft] = useState<Connection | null>(null)
|
||||
const [splashOpen, setSplashOpen] = useState(false)
|
||||
const [splashBusy, setSplashBusy] = useState(false)
|
||||
const [playthrough, setPlaythrough] = useState<PlaythroughSummary | null>(null)
|
||||
const [runtimeNode, setRuntimeNode] = useState<RuntimeNode | null>(null)
|
||||
const saveTimer = useRef<number | undefined>(undefined)
|
||||
const boardRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const adminMenuRef = useRef<HTMLDivElement>(null)
|
||||
const requestedEditMode = new URLSearchParams(window.location.search).get('edit') === '1'
|
||||
const adminRoute = window.location.pathname === '/admin'
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
|
||||
fetch('/api/levels').then(r => {
|
||||
if (!r.ok) throw new Error('Server unavailable')
|
||||
return r.json()
|
||||
}).then(async (levels: { id: string }[]) => {
|
||||
const levelId = params.get('level') || levels[0]?.id
|
||||
if (!levelId) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return }
|
||||
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
|
||||
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}${editQuery}`)
|
||||
const loadLevelBySlug = useCallback(async (slug: string, editQuery = '') => {
|
||||
const response = await fetch(`/api/levels/${encodeURIComponent(slug)}${editQuery}`)
|
||||
if (!response.ok) throw new Error('Level unavailable')
|
||||
const data = normalizeCase(await response.json())
|
||||
setCaseState(data)
|
||||
return data
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (adminRoute) return
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
|
||||
|
||||
const deepLinkLevel = params.get('level')
|
||||
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
|
||||
const openLevel = async (slug: string) => {
|
||||
const data = await loadLevelBySlug(slug, editQuery)
|
||||
if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
|
||||
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
|
||||
})
|
||||
.catch(() => {
|
||||
}
|
||||
// Player campaign entry: a live playthrough resumes silently; none shows the splash.
|
||||
// An explicit ?level= deep link (admin/authoring) bypasses the campaign entirely.
|
||||
const boot = async () => {
|
||||
if (deepLinkLevel) { await openLevel(deepLinkLevel); return }
|
||||
const current = await fetch('/api/playthroughs/current')
|
||||
if (current.status === 204) { setSplashOpen(true); setStatus('AWAITING PRINCIPAL INVESTIGATOR'); return }
|
||||
if (!current.ok) throw new Error('Playthrough unavailable')
|
||||
const state: PlaythroughState = await current.json()
|
||||
setPlaythrough(state.playthrough)
|
||||
setRuntimeNode(state.node)
|
||||
if (state.node?.kind === 'level' && state.node.levelSlug) await loadLevelBySlug(state.node.levelSlug)
|
||||
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
|
||||
}
|
||||
boot().catch(async () => {
|
||||
try {
|
||||
const levels = await (await fetch('/api/levels')).json() as { id: string }[]
|
||||
if (!levels[0]?.id) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return }
|
||||
await openLevel(levels[0].id)
|
||||
} catch {
|
||||
const cached = localStorage.getItem('gupi-osint-board:last')
|
||||
if (cached) setCaseState(normalizeCase(JSON.parse(cached)))
|
||||
setStatus(cached ? 'OFFLINE · LOCAL COPY' : 'SERVER UNAVAILABLE')
|
||||
}
|
||||
})
|
||||
|
||||
const tick = () => setClock(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))
|
||||
tick(); const timer = window.setInterval(tick, 30000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
}, [loadLevelBySlug, adminRoute])
|
||||
|
||||
const applyState = useCallback(async (state: PlaythroughState) => {
|
||||
setPlaythrough(state.playthrough)
|
||||
setRuntimeNode(state.node)
|
||||
if (state.node?.kind === 'level' && state.node.levelSlug) await loadLevelBySlug(state.node.levelSlug)
|
||||
if (!state.node && state.playthrough.status === 'finished') { setSplashOpen(true); setStatus('CASE CLOSED · GREYHAVEN FILE 87-10') }
|
||||
}, [loadLevelBySlug])
|
||||
|
||||
const startNewGame = useCallback(async () => {
|
||||
setSplashBusy(true)
|
||||
try {
|
||||
const response = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
||||
if (!response.ok) throw new Error('Could not start')
|
||||
await applyState(await response.json())
|
||||
setSplashOpen(false)
|
||||
} catch { setStatus('COULD NOT OPEN CASE FILE') } finally { setSplashBusy(false) }
|
||||
}, [applyState])
|
||||
|
||||
// Advance the story graph through a terminal (a dialogue supplies the chosen exit;
|
||||
// cutscene/level advance through the node's single terminal).
|
||||
const advance = useCallback(async (terminalKey?: string) => {
|
||||
if (!playthrough) return
|
||||
try {
|
||||
const response = await fetch(`/api/playthroughs/${encodeURIComponent(playthrough.id)}/advance`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(terminalKey ? { terminalKey } : {}) })
|
||||
if (!response.ok) throw new Error()
|
||||
await applyState(await response.json())
|
||||
} catch { setStatus('COULD NOT ADVANCE') }
|
||||
}, [playthrough, applyState])
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminMenuOpen) return
|
||||
@@ -325,6 +383,11 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
if (adminRoute) return <AdminPanel />
|
||||
if (splashOpen) return <SplashScreen hasResume={false} busy={splashBusy} status={status} onNewGame={startNewGame} onResume={() => setSplashOpen(false)} />
|
||||
// Story-graph runtime: cutscene and dialogue nodes play full-screen (no board).
|
||||
if (runtimeNode?.kind === 'cutscene') return <CutsceneHost componentKey={runtimeNode.componentKey} label={runtimeNode.label} onComplete={() => advance()} />
|
||||
if (runtimeNode?.kind === 'dialogue') return <DialoguePlayer node={{ utterances: runtimeNode.utterances || [], rootId: runtimeNode.rootId ?? null }} onExit={advance} />
|
||||
if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} />
|
||||
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
|
||||
|
||||
@@ -355,9 +418,11 @@ export function App() {
|
||||
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button>
|
||||
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
|
||||
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||
{runtimeNode?.kind === 'level' && <button className="report-back" onClick={() => advance()} title="Finish investigating and continue the story">REPORT BACK ▸</button>}
|
||||
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
||||
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
|
||||
{adminMenuOpen && <div className="admin-menu-items" role="menu">
|
||||
<button role="menuitem" onClick={() => window.location.assign('/admin')}>NPC & MYSTERY ADMIN</button>
|
||||
{!canAuthor ? <button role="menuitem" onClick={enterLevelEditor}>ENTER LEVEL EDITOR</button> : <>
|
||||
<button role="menuitem" onClick={() => { setEditingBrief(true); setAdminMenuOpen(false) }}>EDIT BRIEF & CONCEPTS</button>
|
||||
<button role="menuitem" onClick={() => { fileInputRef.current?.click(); setAdminMenuOpen(false) }}>IMPORT DOCUMENTS</button>
|
||||
@@ -387,7 +452,7 @@ export function App() {
|
||||
|
||||
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && canAuthor) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (canAuthor) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
|
||||
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
||||
<Board state={caseState} selected={selected} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
||||
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
||||
{briefOpen && <BriefPanel
|
||||
brief={caseState.brief}
|
||||
parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
|
||||
@@ -420,7 +485,7 @@ export function App() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DocumentLocatorBeam documentId={documents.some(document => document.id === selected) ? selected : null} layoutKey={`${docsOpen}:${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${caseState.exhibits.map(item => `${item.id}:${item.x}:${item.y}:${item.type === 'folder' ? item.isOpen : ''}`).join('|')}`} />
|
||||
<DocumentLocatorBeam documentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} layoutKey={`${docsOpen}:${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${caseState.exhibits.map(item => `${item.id}:${item.x}:${item.y}:${item.type === 'folder' ? item.isOpen : ''}`).join('|')}`} />
|
||||
<TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.exhibits.map(e => `${e.id}:${e.x}:${e.y}:${e.type === 'folder' ? e.isOpen : ''}`).join('|')}`}/>
|
||||
{timelineView?.visible !== false && <Timeline items={temporalItems} range={timelineView?.rangeMode === 'fixed' ? timelineView.range : undefined} selected={selected} onEdit={() => setEditingTimeline(true)} onSelect={item => { const exhibit = caseState.exhibits.find(candidate => candidate.id === item.exhibitId); if (exhibit?.type === 'document') setOpenDoc(exhibit); else focusEvidence(item.exhibitId) }}/>
|
||||
}
|
||||
@@ -544,11 +609,12 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le
|
||||
return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main>
|
||||
}
|
||||
|
||||
function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
|
||||
function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; locatorDocumentId: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
|
||||
const drag = useRef<{ kind: 'pan' | 'widget' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
|
||||
const suppressClick = useRef(false)
|
||||
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
|
||||
const pinchDistance = useRef<number | null>(null)
|
||||
const folderLongPress = useRef<{ pointerId: number; id: string; startX: number; startY: number; timer: number } | null>(null)
|
||||
const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null)
|
||||
const [expandedThreadTagId, setExpandedThreadTagId] = useState<string | null>(null)
|
||||
const [draggingThreadTagId, setDraggingThreadTagId] = useState<string | null>(null)
|
||||
@@ -584,6 +650,8 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
||||
if (event.pointerType === 'touch') {
|
||||
touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
||||
if (touchPoints.current.size >= 2) {
|
||||
if (folderLongPress.current) window.clearTimeout(folderLongPress.current.timer)
|
||||
folderLongPress.current = null
|
||||
const points = [...touchPoints.current.values()]
|
||||
pinchDistance.current = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y)
|
||||
drag.current = null
|
||||
@@ -612,6 +680,11 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
||||
}
|
||||
const pointerMove = (event: React.PointerEvent) => {
|
||||
trackThreadPointer(event)
|
||||
const pendingFolderPress = folderLongPress.current
|
||||
if (pendingFolderPress?.pointerId === event.pointerId && Math.hypot(event.clientX - pendingFolderPress.startX, event.clientY - pendingFolderPress.startY) > 8) {
|
||||
window.clearTimeout(pendingFolderPress.timer)
|
||||
folderLongPress.current = null
|
||||
}
|
||||
if (event.pointerType === 'touch' && touchPoints.current.has(event.pointerId)) {
|
||||
touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
||||
if (touchPoints.current.size >= 2) {
|
||||
@@ -653,6 +726,10 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
||||
else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) }))
|
||||
}
|
||||
const finishDrag = (event: React.PointerEvent) => {
|
||||
if (folderLongPress.current?.pointerId === event.pointerId) {
|
||||
window.clearTimeout(folderLongPress.current.timer)
|
||||
folderLongPress.current = null
|
||||
}
|
||||
if (event.pointerType === 'touch') {
|
||||
touchPoints.current.delete(event.pointerId)
|
||||
if (touchPoints.current.size < 2) pinchDistance.current = null
|
||||
@@ -667,9 +744,27 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
||||
trashTarget.current = false
|
||||
}
|
||||
const toggleFolder = (id: string) => update(s => ({ ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'folder' ? { ...exhibit, isOpen: !exhibit.isOpen } : exhibit) }))
|
||||
const startFolderLongPress = (event: React.PointerEvent, id: string) => {
|
||||
if (event.pointerType !== 'touch' || tool !== 'move' || linkFrom || (event.target as HTMLElement).closest('button')) return
|
||||
if (folderLongPress.current) window.clearTimeout(folderLongPress.current.timer)
|
||||
const pointerId = event.pointerId
|
||||
const timer = window.setTimeout(() => {
|
||||
if (touchPoints.current.size !== 1 || folderLongPress.current?.pointerId !== pointerId) return
|
||||
folderLongPress.current = null
|
||||
drag.current = null
|
||||
trashTarget.current = false
|
||||
setDraggingWidget(false)
|
||||
setTrashActive(false)
|
||||
suppressClick.current = true
|
||||
toggleFolder(id)
|
||||
}, 520)
|
||||
folderLongPress.current = { pointerId, id, startX: event.clientX, startY: event.clientY, timer }
|
||||
}
|
||||
useEffect(() => () => {
|
||||
if (folderLongPress.current) window.clearTimeout(folderLongPress.current.timer)
|
||||
}, [])
|
||||
const widgetContext: ExhibitWidgetContext = { exhibits: state.exhibits, relations: state.relations, dispatch: (command: WidgetCommand) => {
|
||||
if (command.type === 'open-document') onOpenSource(command.documentId)
|
||||
else if (command.type === 'toggle-folder') toggleFolder(command.folderId)
|
||||
else if (command.type === 'edit-folder') onEditFolder(command.folderId)
|
||||
else if (command.type === 'edit-event') onEditEvent(command.eventId)
|
||||
else if (command.type === 'edit-party') onEditParty(command.partyId)
|
||||
@@ -702,14 +797,14 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
||||
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
|
||||
{containmentRelations.map(relation => { const folder = byId.get(relation.fromExhibitId), document = byId.get(relation.toExhibitId); if (folder?.type !== 'folder' || document?.type !== 'document') return null; const origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={folder.isOpen ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={folder.isOpen ? document.x + document.width / 2 : origin.x} y2={folder.isOpen ? document.y + document.height / 2 : origin.y}/> })}
|
||||
</svg>
|
||||
{evidenceExhibits(state.exhibits).filter(exhibit => !exhibit.hidden).map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = byId.get(id); return document?.type === 'document' ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !ev.isOpen && containedDocuments.some(document => document.id === selected) ? selected : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }}
|
||||
onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
|
||||
{evidenceExhibits(state.exhibits).filter(exhibit => !exhibit.hidden).map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = byId.get(id); return document?.type === 'document' ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !ev.isOpen && containedDocuments.some(document => document.id === locatorDocumentId) ? locatorDocumentId : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} tabIndex={ev.type === 'folder' ? 0 : undefined} aria-expanded={ev.type === 'folder' ? ev.isOpen : undefined} title={ev.type === 'folder' ? 'Double-click or hold to open or close this folder' : undefined} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }}
|
||||
onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (ev.type === 'folder') startFolderLongPress(e, ev.id); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
|
||||
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
|
||||
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}>
|
||||
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (ev.type === 'folder' && e.detail > 1) return; if (tool === 'move') onCardClick(ev.id) }} onDoubleClick={e => { e.stopPropagation(); if (ev.type === 'folder' && tool === 'move' && !linkFrom && !(e.target as HTMLElement).closest('button')) toggleFolder(ev.id) }} onKeyDown={e => { if (ev.type === 'folder' && e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); toggleFolder(ev.id) } }}>
|
||||
<header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
|
||||
<Widget exhibit={ev} context={widgetContext}/>
|
||||
</article>})}
|
||||
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && selected === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
|
||||
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
|
||||
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
|
||||
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
|
||||
<header><span>{definition.label.toUpperCase()}</span><i>{String((membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { MysteryGraphEditor } from './mysteryGraph'
|
||||
|
||||
type Pose = { poseKey: string; assetId: string; url: string }
|
||||
type Npc = { id: string; key: string; name: string; role: string; defaultPose: string | null; poses: Pose[]; inUse: boolean }
|
||||
type Mystery = { id: string; slug: string; title: string; nodes: number }
|
||||
|
||||
async function json<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, init)
|
||||
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || `Request failed (${response.status})`)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export function AdminPanel() {
|
||||
const [isAdmin, setIsAdmin] = useState<boolean | null>(null)
|
||||
const [tab, setTab] = useState<'npcs' | 'mysteries'>('npcs')
|
||||
const [npcs, setNpcs] = useState<Npc[]>([])
|
||||
const [mysteries, setMysteries] = useState<Mystery[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [editingMystery, setEditingMystery] = useState<{ id: string; title: string } | null>(null)
|
||||
const [status, setStatus] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/session').then(r => r.json()).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
|
||||
}, [])
|
||||
|
||||
const reloadNpcs = useCallback(async (selectKey?: string) => {
|
||||
const list = await json<Npc[]>('/api/admin/npcs')
|
||||
setNpcs(list)
|
||||
setSelectedId(current => selectKey ? (list.find(npc => npc.key === selectKey)?.id ?? current) : (current && list.some(npc => npc.id === current) ? current : list[0]?.id ?? null))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return
|
||||
reloadNpcs().catch(error => setStatus(String(error.message || error)))
|
||||
json<Mystery[]>('/api/admin/mysteries').then(setMysteries).catch(() => {})
|
||||
}, [isAdmin, reloadNpcs])
|
||||
|
||||
if (isAdmin === null) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY · ADMIN</p><small>AUTHENTICATING…</small></div>
|
||||
if (!isAdmin) return <div className="boot"><div className="seal">GU</div><p>ADMINISTRATOR ACCESS REQUIRED</p><small><a className="admin-link" href="/">← RETURN TO TERMINAL</a></small></div>
|
||||
|
||||
const selected = npcs.find(npc => npc.id === selectedId) || null
|
||||
return <div className="admin">
|
||||
<header className="admin-head">
|
||||
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ AUTHORING</em></span></div>
|
||||
<nav className="admin-tabs">
|
||||
<button className={tab === 'npcs' ? 'active' : ''} onClick={() => setTab('npcs')}>NPCS</button>
|
||||
<button className={tab === 'mysteries' ? 'active' : ''} onClick={() => setTab('mysteries')}>MYSTERIES</button>
|
||||
</nav>
|
||||
<a className="admin-link" href="/">← TERMINAL</a>
|
||||
</header>
|
||||
|
||||
{tab === 'npcs' && <div className="admin-body">
|
||||
<aside className="npc-list">
|
||||
<div className="npc-list-head"><span>NPC TEMPLATES</span><button onClick={() => createNpc(setStatus, reloadNpcs)}>+ NEW</button></div>
|
||||
{npcs.length === 0 && <p className="admin-empty">No NPC templates yet.</p>}
|
||||
{npcs.map(npc => <button key={npc.id} className={`npc-row${npc.id === selectedId ? ' selected' : ''}`} onClick={() => setSelectedId(npc.id)}>
|
||||
<span className="npc-avatar">{npc.poses[0] ? <img src={npc.poses[0].url} alt="" /> : npc.name.slice(0, 1).toUpperCase()}</span>
|
||||
<span className="npc-row-text"><strong>{npc.name || npc.key}</strong><small>{npc.role || npc.key}</small></span>
|
||||
</button>)}
|
||||
</aside>
|
||||
{selected
|
||||
? <NpcEditor key={selected.id} npc={selected} onChanged={reloadNpcs} setStatus={setStatus} />
|
||||
: <div className="npc-editor empty">Select or create an NPC.</div>}
|
||||
</div>}
|
||||
|
||||
{tab === 'mysteries' && (editingMystery
|
||||
? <MysteryGraphEditor mysteryId={editingMystery.id} title={editingMystery.title} onClose={() => setEditingMystery(null)} setStatus={setStatus} />
|
||||
: <div className="admin-body">
|
||||
<div className="mystery-list">
|
||||
{mysteries.length === 0 && <p className="admin-empty">No mysteries authored yet.</p>}
|
||||
{mysteries.map(mystery => <button key={mystery.id} className="mystery-row" onClick={() => setEditingMystery({ id: mystery.id, title: mystery.title })}>
|
||||
<strong>{mystery.title}</strong>
|
||||
<span>{mystery.slug} · {mystery.nodes} node{mystery.nodes === 1 ? '' : 's'} · edit graph →</span>
|
||||
</button>)}
|
||||
<p className="admin-note">Click a mystery to open its story-flow graph editor.</p>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
<footer className="admin-foot">{status || 'READY'}</footer>
|
||||
</div>
|
||||
}
|
||||
|
||||
async function createNpc(setStatus: (message: string) => void, reload: (key?: string) => Promise<void>) {
|
||||
const name = window.prompt('NPC display name (e.g. Prof. Almira Vetch)')?.trim()
|
||||
if (!name) return
|
||||
const key = window.prompt('Short key (e.g. professor)', name.toLowerCase().split(/\s+/).pop() || '')?.trim()
|
||||
if (!key) return
|
||||
try {
|
||||
await json('/api/admin/npcs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key, name }) })
|
||||
await reload(key.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, ''))
|
||||
setStatus(`Created ${name}`)
|
||||
} catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}
|
||||
|
||||
function NpcEditor({ npc, onChanged, setStatus }: { npc: Npc; onChanged: (key?: string) => Promise<void>; setStatus: (message: string) => void }) {
|
||||
const [name, setName] = useState(npc.name)
|
||||
const [role, setRole] = useState(npc.role)
|
||||
const [defaultPose, setDefaultPose] = useState(npc.defaultPose || '')
|
||||
const [poseKey, setPoseKey] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const dirty = name !== npc.name || role !== npc.role || (defaultPose || null) !== npc.defaultPose
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await json(`/api/admin/npcs/${npc.id}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, role, defaultPose: defaultPose || null }) })
|
||||
await onChanged(npc.key); setStatus(`Saved ${name}`)
|
||||
} catch (error) { setStatus(String((error as Error).message || error)) } finally { setBusy(false) }
|
||||
}
|
||||
const uploadPose = async (file: File) => {
|
||||
const key = (poseKey || file.name.replace(/\.[^.]+$/, '')).trim()
|
||||
setBusy(true)
|
||||
try {
|
||||
const form = new FormData(); form.append('file', file); form.append('poseKey', key)
|
||||
await json(`/api/admin/npcs/${npc.id}/poses`, { method: 'POST', body: form })
|
||||
setPoseKey(''); if (fileRef.current) fileRef.current.value = ''
|
||||
await onChanged(npc.key); setStatus(`Uploaded pose “${key}”`)
|
||||
} catch (error) { setStatus(String((error as Error).message || error)) } finally { setBusy(false) }
|
||||
}
|
||||
const removePose = async (key: string) => {
|
||||
if (!window.confirm(`Remove pose “${key}”?`)) return
|
||||
try { await json(`/api/admin/npcs/${npc.id}/poses/${encodeURIComponent(key)}`, { method: 'DELETE' }); await onChanged(npc.key) }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}
|
||||
const remove = async () => {
|
||||
if (!window.confirm(`Delete NPC ${npc.name}? This cannot be undone.`)) return
|
||||
try { await json(`/api/admin/npcs/${npc.id}`, { method: 'DELETE' }); await onChanged() }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}
|
||||
|
||||
return <section className="npc-editor">
|
||||
<div className="npc-editor-head">
|
||||
<h2>{npc.name || npc.key}</h2>
|
||||
<code>{npc.key}</code>
|
||||
<button className="danger" disabled={npc.inUse} title={npc.inUse ? 'Used by a cutscene' : 'Delete NPC'} onClick={remove}>DELETE</button>
|
||||
</div>
|
||||
<div className="admin-field"><label>Display name</label><input value={name} onChange={event => setName(event.target.value)} /></div>
|
||||
<div className="admin-field"><label>Role / affiliation</label><input value={role} onChange={event => setRole(event.target.value)} placeholder="Glitch University · Investigative Method" /></div>
|
||||
<div className="admin-field"><label>Default pose</label>
|
||||
<select value={defaultPose} onChange={event => setDefaultPose(event.target.value)}>
|
||||
<option value="">— none —</option>
|
||||
{npc.poses.map(pose => <option key={pose.poseKey} value={pose.poseKey}>{pose.poseKey}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button className="admin-save" disabled={!dirty || busy} onClick={save}>{busy ? 'SAVING…' : dirty ? 'SAVE CHANGES' : 'SAVED'}</button>
|
||||
|
||||
<h3>Poses</h3>
|
||||
<div className="pose-grid">
|
||||
{npc.poses.map(pose => <figure key={pose.poseKey} className={`pose-card${pose.poseKey === defaultPose ? ' is-default' : ''}`}>
|
||||
<img src={pose.url} alt={pose.poseKey} />
|
||||
<figcaption>{pose.poseKey}</figcaption>
|
||||
<button className="pose-remove" title="Remove pose" onClick={() => removePose(pose.poseKey)}>×</button>
|
||||
</figure>)}
|
||||
{npc.poses.length === 0 && <p className="admin-empty">No poses yet. Upload a portrait below.</p>}
|
||||
</div>
|
||||
<div className="pose-upload">
|
||||
<input className="pose-key" value={poseKey} onChange={event => setPoseKey(event.target.value)} placeholder="pose key (e.g. neutral)" />
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={event => { const file = event.target.files?.[0]; if (file) void uploadPose(file) }} />
|
||||
</div>
|
||||
<small className="admin-hint">Poses referenced in dialogue fall back to the default pose, then to no artwork. Upload big portraits — they fill the screen in cutscenes.</small>
|
||||
</section>
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { BookOpen, Building2, CalendarClock, FileText, Folder, FolderOpen, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
|
||||
import { BookOpen, Building2, CalendarClock, FileText, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
|
||||
import type { CaseDocument, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, SourceFileType, TemporalFact } from './types'
|
||||
|
||||
export type WidgetCommand =
|
||||
| { type: 'open-document'; documentId: string }
|
||||
| { type: 'toggle-folder'; folderId: string }
|
||||
| { type: 'edit-folder'; folderId: string }
|
||||
| { type: 'edit-event'; eventId: string }
|
||||
| { type: 'edit-party'; partyId: string }
|
||||
@@ -40,13 +39,8 @@ const relationsFrom = (context: ExhibitWidgetContext, exhibitId: string, type: E
|
||||
|
||||
function FolderWidget({ exhibit, context }: ExhibitWidgetProps) {
|
||||
if (exhibit.type !== 'folder') return null
|
||||
const documents = relationsFrom(context, exhibit.id, 'contains').flatMap(relation => {
|
||||
const document = context.exhibits.find(candidate => candidate.id === relation.toExhibitId)
|
||||
return document?.type === 'document' ? [document] : []
|
||||
})
|
||||
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
|
||||
{!exhibit.isOpen && <div className="folder-documents">{documents.slice(0,3).map(document => <button key={document.id} onClick={() => context.dispatch({ type:'open-document',documentId:document.id })} title={document.title}><FileText size={12}/><span>{document.title}</span>{document.publishedAt && <time>{document.publishedAt.slice(0,10)}</time>}</button>)}{documents.length > 3 && <small>+ {documents.length - 3} MORE FILES</small>}</div>}
|
||||
<div className="folder-actions"><button onClick={() => context.dispatch({ type:'toggle-folder',folderId:exhibit.id })}>{exhibit.isOpen ? <Folder size={12}/> : <FolderOpen size={12}/>} {exhibit.isOpen ? 'CLOSE' : 'OPEN'}</button><button onClick={() => context.dispatch({ type:'edit-folder',folderId:exhibit.id })}><Pencil size={12}/> EDIT</button></div>
|
||||
<div className="folder-actions"><button onClick={() => context.dispatch({ type:'edit-folder',folderId:exhibit.id })}><Pencil size={15}/> EDIT</button></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { UtteranceCanvas } from './utteranceCanvas'
|
||||
|
||||
type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
|
||||
type Terminal = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number }
|
||||
type StoryNode = { id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean; xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; terminals: Terminal[] }
|
||||
type Graph = { mysteryId: string; entryNodeId: string | null; nodes: StoryNode[] }
|
||||
type LevelTemplate = { versionId: string; slug: string; name: string; version: number }
|
||||
|
||||
// Vertical layout: input on top, output terminals along the bottom; flow runs downward.
|
||||
const NODE_W = 200, NODE_H = 88
|
||||
const TYPES: { type: StoryNodeType; label: string }[] = [
|
||||
{ type: 'cutscene', label: 'Cutscene' }, { type: 'dialogue', label: 'Dialogue' }, { type: 'level', label: 'Level' },
|
||||
{ type: 'det_gate', label: 'Det gate' }, { type: 'llm_gate', label: 'LLM gate' },
|
||||
]
|
||||
const outPort = (node: StoryNode, index: number) => ({ x: node.xpos + NODE_W * (index + 0.5) / Math.max(1, node.terminals.length), y: node.ypos + NODE_H })
|
||||
const inPort = (node: StoryNode) => ({ x: node.xpos + NODE_W / 2, y: node.ypos })
|
||||
|
||||
async function api<T>(url: string, method: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(url, { method, headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined })
|
||||
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || `Request failed (${response.status})`)
|
||||
return response.json().catch(() => ({} as T))
|
||||
}
|
||||
|
||||
export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { mysteryId: string; title: string; onClose: () => void; setStatus: (message: string) => void }) {
|
||||
const [graph, setGraph] = useState<Graph | null>(null)
|
||||
const [templates, setTemplates] = useState<LevelTemplate[]>([])
|
||||
const [view, setView] = useState({ x: 60, y: 60, zoom: 1 })
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [wiringFrom, setWiringFrom] = useState<string | null>(null)
|
||||
const [utterancesNode, setUtterancesNode] = useState<StoryNode | null>(null)
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const drag = useRef<{ kind: 'pan' | 'node'; id?: string; startX: number; startY: number; origX: number; origY: number } | null>(null)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try { setGraph(await api<Graph>(`/api/admin/mysteries/${mysteryId}/graph`, 'GET')) }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}, [mysteryId, setStatus])
|
||||
useEffect(() => { void reload(); api<LevelTemplate[]>('/api/admin/level-templates', 'GET').then(setTemplates).catch(() => {}) }, [reload])
|
||||
|
||||
const centerInBoard = () => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect()
|
||||
const cx = rect ? rect.width / 2 : 300, cy = rect ? rect.height / 2 : 200
|
||||
return { x: (cx - view.x) / view.zoom, y: (cy - view.y) / view.zoom }
|
||||
}
|
||||
|
||||
const addNode = async (nodeType: StoryNodeType) => {
|
||||
const at = centerInBoard()
|
||||
try { const node = await api<StoryNode>(`/api/admin/mysteries/${mysteryId}/nodes`, 'POST', { nodeType, xpos: Math.round(at.x), ypos: Math.round(at.y) }); await reload(); setSelectedId(node.id) }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}
|
||||
const patchNode = async (id: string, body: Record<string, unknown>) => { try { await api(`/api/admin/story-nodes/${id}`, 'PATCH', body); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }
|
||||
const wire = async (terminalId: string, toNodeId: string | null) => { try { await api(`/api/admin/story-terminals/${terminalId}`, 'PATCH', { toNodeId }); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }
|
||||
|
||||
// Pointer: pan on background, drag on node header.
|
||||
const onPointerDown = (event: React.PointerEvent) => {
|
||||
if (wiringFrom) { setWiringFrom(null); return }
|
||||
drag.current = { kind: 'pan', startX: event.clientX, startY: event.clientY, origX: view.x, origY: view.y }
|
||||
setSelectedId(null)
|
||||
}
|
||||
const onNodePointerDown = (event: React.PointerEvent, node: StoryNode) => {
|
||||
event.stopPropagation()
|
||||
;(event.target as HTMLElement).setPointerCapture?.(event.pointerId)
|
||||
drag.current = { kind: 'node', id: node.id, startX: event.clientX, startY: event.clientY, origX: node.xpos, origY: node.ypos }
|
||||
setSelectedId(node.id)
|
||||
}
|
||||
const onPointerMove = (event: React.PointerEvent) => {
|
||||
const state = drag.current
|
||||
if (!state) return
|
||||
const dx = event.clientX - state.startX, dy = event.clientY - state.startY
|
||||
if (state.kind === 'pan') setView(v => ({ ...v, x: state.origX + dx, y: state.origY + dy }))
|
||||
else setGraph(g => g && { ...g, nodes: g.nodes.map(n => n.id === state.id ? { ...n, xpos: state.origX + dx / view.zoom, ypos: state.origY + dy / view.zoom } : n) })
|
||||
}
|
||||
const onPointerUp = async () => {
|
||||
const state = drag.current; drag.current = null
|
||||
if (state?.kind === 'node' && state.id) {
|
||||
const node = graph?.nodes.find(n => n.id === state.id)
|
||||
if (node) { try { await api(`/api/admin/story-nodes/${state.id}`, 'PATCH', { xpos: Math.round(node.xpos), ypos: Math.round(node.ypos) }) } catch { /* position persists next reload */ } }
|
||||
}
|
||||
}
|
||||
const onWheel = (event: React.WheelEvent) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return
|
||||
const px = event.clientX - rect.left, py = event.clientY - rect.top
|
||||
const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1
|
||||
setView(v => { const zoom = Math.min(2, Math.max(0.35, v.zoom * factor)); return { zoom, x: px - (px - v.x) * (zoom / v.zoom), y: py - (py - v.y) * (zoom / v.zoom) } })
|
||||
}
|
||||
|
||||
const onNodeClick = (event: React.MouseEvent, node: StoryNode) => {
|
||||
event.stopPropagation()
|
||||
if (wiringFrom) { void wire(wiringFrom, node.id); setWiringFrom(null); return }
|
||||
setSelectedId(node.id)
|
||||
}
|
||||
|
||||
if (!graph) return <div className="graph-loading">Loading graph…</div>
|
||||
const selected = graph.nodes.find(n => n.id === selectedId) || null
|
||||
const nodeById = new Map(graph.nodes.map(n => [n.id, n]))
|
||||
|
||||
return <div className="graph-editor">
|
||||
{utterancesNode && <UtteranceCanvas nodeId={utterancesNode.id} nodeLabel={utterancesNode.label} terminals={utterancesNode.terminals}
|
||||
onClose={() => { setUtterancesNode(null); void reload() }} setStatus={setStatus} />}
|
||||
<div className="graph-toolbar">
|
||||
<button className="graph-back" onClick={onClose}>← Mysteries</button>
|
||||
<strong>{title}</strong>
|
||||
<span className="graph-add-label">Add:</span>
|
||||
{TYPES.map(t => <button key={t.type} className="graph-add" onClick={() => addNode(t.type)}>{t.label}</button>)}
|
||||
{wiringFrom && <span className="graph-wiring">Click a target node to wire · click empty to cancel</span>}
|
||||
<span className="graph-zoom">{Math.round(view.zoom * 100)}%</span>
|
||||
</div>
|
||||
<div className="graph-main">
|
||||
<div ref={canvasRef} className={`graph-canvas${wiringFrom ? ' wiring' : ''}`} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onWheel={onWheel}>
|
||||
<div className="graph-world" style={{ transform: `translate(${view.x}px,${view.y}px) scale(${view.zoom})` }}>
|
||||
<svg className="graph-wires" width="6000" height="6000">
|
||||
{graph.nodes.flatMap(node => node.terminals.filter(t => t.toNodeId && nodeById.has(t.toNodeId)).map(t => {
|
||||
const target = nodeById.get(t.toNodeId!)!
|
||||
const from = outPort(node, node.terminals.indexOf(t)), to = inPort(target)
|
||||
const dy = Math.max(40, Math.abs(to.y - from.y) / 2)
|
||||
const d = `M${from.x},${from.y} C${from.x},${from.y + dy} ${to.x},${to.y - dy} ${to.x},${to.y}`
|
||||
return [
|
||||
<path key={t.id + 'hit'} className="wire-hit" d={d} onClick={event => { event.stopPropagation(); void wire(t.id, null) }} />,
|
||||
<path key={t.id} className="graph-wire" d={d} />,
|
||||
]
|
||||
}))}
|
||||
</svg>
|
||||
{graph.nodes.map(node => <div key={node.id} className={`gnode type-${node.nodeType}${node.id === selectedId ? ' selected' : ''}${graph.entryNodeId === node.id ? ' entry' : ''}`}
|
||||
style={{ left: node.xpos, top: node.ypos, width: NODE_W, height: NODE_H }} onPointerDown={event => event.stopPropagation()} onClick={event => onNodeClick(event, node)}
|
||||
onDoubleClick={event => { event.stopPropagation(); if (node.nodeType === 'dialogue' || node.hasUtterances) setUtterancesNode(node) }}>
|
||||
<div className="ginput" />
|
||||
<div className="gnode-head" onPointerDown={event => onNodePointerDown(event, node)}>
|
||||
<span className="gnode-type">{node.nodeType}</span>
|
||||
<span className="gnode-label">{node.label}</span>
|
||||
{graph.entryNodeId === node.id && <span className="gnode-entry">▶</span>}
|
||||
</div>
|
||||
<div className="gnode-sub">{nodeSummary(node, templates)}</div>
|
||||
<div className="gnode-outs">
|
||||
{node.terminals.map(t => <div key={t.id} className="gout">
|
||||
<span className="gout-label">{t.label || t.terminalKey}</span>
|
||||
<button className={`gport${t.toNodeId ? ' wired' : ''}${wiringFrom === t.id ? ' active' : ''}`} title="Click to start a wire"
|
||||
onClick={event => { event.stopPropagation(); setWiringFrom(from => from === t.id ? null : t.id) }} />
|
||||
</div>)}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
{graph.nodes.length === 0 && <div className="graph-empty">Empty graph — add a node to begin.</div>}
|
||||
</div>
|
||||
|
||||
{selected && <aside className="graph-inspector">
|
||||
<NodeInspector key={selected.id} node={selected} graph={graph} templates={templates}
|
||||
onEditUtterances={() => setUtterancesNode(selected)}
|
||||
onPatch={body => patchNode(selected.id, body)}
|
||||
onSetEntry={async () => { try { await api(`/api/admin/mysteries/${mysteryId}/entry`, 'PUT', { nodeId: selected.id }); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
|
||||
onDelete={async () => { if (!window.confirm('Delete this node?')) return; try { await api(`/api/admin/story-nodes/${selected.id}`, 'DELETE'); setSelectedId(null); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
|
||||
onAddTerminal={async () => { const key = window.prompt('Terminal key (e.g. proceed)'); if (!key) return; try { await api(`/api/admin/story-nodes/${selected.id}/terminals`, 'POST', { terminalKey: key, label: key }); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
|
||||
onTerminalPatch={async (id, body) => { try { await api(`/api/admin/story-terminals/${id}`, 'PATCH', body); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
|
||||
onTerminalDelete={async id => { try { await api(`/api/admin/story-terminals/${id}`, 'DELETE'); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }} />
|
||||
</aside>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function nodeSummary(node: StoryNode, templates: LevelTemplate[]) {
|
||||
if (node.nodeType === 'level') return templates.find(t => t.versionId === node.levelTemplateVersionId)?.name || '⚠ no level chosen'
|
||||
if (node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate') return node.componentKey || '⚠ no component'
|
||||
if (node.nodeType === 'dialogue') return node.hasUtterances ? 'utterances' : 'no utterances'
|
||||
return ''
|
||||
}
|
||||
|
||||
function NodeInspector({ node, graph, templates, onPatch, onSetEntry, onDelete, onAddTerminal, onTerminalPatch, onTerminalDelete, onEditUtterances }: {
|
||||
node: StoryNode; graph: Graph; templates: LevelTemplate[]
|
||||
onPatch: (body: Record<string, unknown>) => void; onSetEntry: () => void; onDelete: () => void
|
||||
onAddTerminal: () => void; onTerminalPatch: (id: string, body: Record<string, unknown>) => void; onTerminalDelete: (id: string) => void; onEditUtterances: () => void
|
||||
}) {
|
||||
const [label, setLabel] = useState(node.label)
|
||||
const [componentKey, setComponentKey] = useState(node.componentKey || '')
|
||||
const nodeName = (id: string | null) => id ? (graph.nodes.find(n => n.id === id)?.label || '—') : '— unwired —'
|
||||
const usesComponent = node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate'
|
||||
return <div className="inspector-body">
|
||||
<div className="inspector-head"><span className="gnode-type">{node.nodeType}</span>{graph.entryNodeId !== node.id && <button className="ins-entry" onClick={onSetEntry}>Set entrypoint</button>}</div>
|
||||
<label className="ins-field"><span>Label</span><input value={label} onChange={e => setLabel(e.target.value)} onBlur={() => label !== node.label && onPatch({ label })} /></label>
|
||||
{node.nodeType === 'level' && <label className="ins-field"><span>Level template</span>
|
||||
<select value={node.levelTemplateVersionId || ''} onChange={e => onPatch({ levelTemplateVersionId: e.target.value || null })}>
|
||||
<option value="">— choose —</option>
|
||||
{templates.map(t => <option key={t.versionId} value={t.versionId}>{t.name} (v{t.version})</option>)}
|
||||
</select></label>}
|
||||
{usesComponent && <label className="ins-field"><span>Component key</span><input value={componentKey} placeholder={node.nodeType === 'cutscene' ? 'glass-harbour-diversion' : 'det_gate_lvl_1'} onChange={e => setComponentKey(e.target.value)} onBlur={() => componentKey !== (node.componentKey || '') && onPatch({ componentKey })} /></label>}
|
||||
{(node.nodeType === 'dialogue' || node.nodeType === 'cutscene') && <label className="ins-check"><input type="checkbox" checked={node.hasUtterances} onChange={e => onPatch({ hasUtterances: e.target.checked })} /> Has utterances</label>}
|
||||
{(node.nodeType === 'dialogue' || node.hasUtterances) && <button className="ins-utterances" onClick={onEditUtterances}>Edit utterances →</button>}
|
||||
|
||||
<div className="ins-terminals-head"><span>Output terminals</span><button onClick={onAddTerminal}>+ Add</button></div>
|
||||
{node.terminals.map(t => <div key={t.id} className="ins-terminal">
|
||||
<input defaultValue={t.label} onBlur={e => e.target.value !== t.label && onTerminalPatch(t.id, { label: e.target.value })} />
|
||||
<span className="ins-terminal-to">→ {nodeName(t.toNodeId)}</span>
|
||||
{t.toNodeId && <button className="ins-unwire" title="Unwire" onClick={() => onTerminalPatch(t.id, { toNodeId: null })}>⊘</button>}
|
||||
<button className="ins-term-del" title="Delete terminal" onClick={() => onTerminalDelete(t.id)}>×</button>
|
||||
</div>)}
|
||||
|
||||
<button className="ins-delete" onClick={onDelete}>Delete node</button>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useMemo, useRef, useState, type FC } from 'react'
|
||||
|
||||
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null }
|
||||
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string; componentKey?: string | null; levelSlug?: string | null; utterances?: RuntimeUtterance[]; rootId?: string | null }
|
||||
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
|
||||
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
|
||||
|
||||
function usePrefersReducedMotion() {
|
||||
const [reduced, setReduced] = useState(() => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia?.('(prefers-reduced-motion: reduce)')
|
||||
if (!query) return
|
||||
const listener = (event: MediaQueryListEvent) => setReduced(event.matches)
|
||||
query.addEventListener('change', listener)
|
||||
return () => query.removeEventListener('change', listener)
|
||||
}, [])
|
||||
return reduced
|
||||
}
|
||||
|
||||
export function SplashScreen({ hasResume, busy, status, onNewGame, onResume }: {
|
||||
hasResume: boolean; busy: boolean; status?: string; onNewGame: () => void; onResume: () => void
|
||||
}) {
|
||||
return <div className="splash">
|
||||
<div className="splash-plate">
|
||||
<div className="seal">GU</div>
|
||||
<h1 className="splash-title">PRINCIPAL INVESTIGATOR</h1>
|
||||
<p className="splash-sub">Glitch University</p>
|
||||
<div className="splash-actions">
|
||||
{hasResume && <button className="splash-button" disabled={busy} onClick={onResume}>RESUME</button>}
|
||||
<button className="splash-button primary" disabled={busy} onClick={onNewGame}>NEW GAME</button>
|
||||
</div>
|
||||
<small className="splash-status">{busy ? 'OPENING CASE FILE…' : status || 'GLITCH UNIVERSITY NETWORK TERMINAL'}</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Bespoke cutscene components, keyed by a node's component_key (mirrors the exhibit registry).
|
||||
const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) => (
|
||||
<div className="cutscene-card title-card" onClick={onComplete}>
|
||||
<div className="title-card-inner">
|
||||
<small>Greyhaven file 87-10</small>
|
||||
<h1>The Glass Harbour Diversion</h1>
|
||||
<button className="cutscene-begin" onClick={event => { event.stopPropagation(); onComplete() }}>Begin ▸</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const CUTSCENE_REGISTRY: Record<string, FC<{ onComplete: () => void }>> = { 'glass-harbour-diversion': GlassHarbourDiversion }
|
||||
|
||||
export function CutsceneHost({ componentKey, label, onComplete }: { componentKey: string | null | undefined; label: string; onComplete: () => void }) {
|
||||
const Component = componentKey ? CUTSCENE_REGISTRY[componentKey] : undefined
|
||||
if (Component) return <Component onComplete={onComplete} />
|
||||
return <div className="cutscene-card title-card" onClick={onComplete}>
|
||||
<div className="title-card-inner">
|
||||
<h1>{label}</h1>
|
||||
<small className="cutscene-missing">{componentKey ? `component "${componentKey}" not registered` : 'no component set'}</small>
|
||||
<button className="cutscene-begin" onClick={event => { event.stopPropagation(); onComplete() }}>Continue ▸</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Walk a dialogue node's utterance tree: play NPC lines, present player options at a
|
||||
// branch, follow a chosen option to the next line or out through its exit terminal.
|
||||
export function DialoguePlayer({ node, onExit }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void }) {
|
||||
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
|
||||
const [currentId, setCurrentId] = useState<string | null>(node.rootId)
|
||||
const [charCount, setCharCount] = useState(0)
|
||||
const reduced = usePrefersReducedMotion()
|
||||
const current = currentId ? byId.get(currentId) ?? null : null
|
||||
const fullText = current?.text ?? ''
|
||||
const done = charCount >= fullText.length
|
||||
const children = current ? current.childIds.map(id => byId.get(id)).filter((c): c is RuntimeUtterance => Boolean(c)) : []
|
||||
const options = children.filter(c => c.utterer === 'player')
|
||||
const showChoices = done && options.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!current) { onExit(); return }
|
||||
if (reduced) { setCharCount(fullText.length); return }
|
||||
setCharCount(0)
|
||||
const id = window.setInterval(() => setCharCount(count => (count >= fullText.length ? count : count + 1)), 18)
|
||||
return () => window.clearInterval(id)
|
||||
}, [currentId, fullText, reduced]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const pick = (choice: RuntimeUtterance) => {
|
||||
if (choice.childIds.length > 0) setCurrentId(choice.childIds[0])
|
||||
else onExit(choice.terminalKey ?? undefined)
|
||||
}
|
||||
const proceedRef = useRef(() => {})
|
||||
proceedRef.current = () => {
|
||||
if (!current) { onExit(); return }
|
||||
if (!done) { setCharCount(fullText.length); return }
|
||||
if (children.length === 0) { onExit(current.terminalKey ?? undefined); return }
|
||||
if (options.length > 0) return // a branch — wait for a choice
|
||||
setCurrentId(children[0].id) // linear next line
|
||||
}
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (!showChoices && (event.key === ' ' || event.key === 'Enter' || event.key === 'ArrowRight')) { event.preventDefault(); proceedRef.current() }
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [showChoices])
|
||||
|
||||
if (!current) return null
|
||||
return <div className="dialogue" role="dialog" aria-label="Dialogue" onClick={() => { if (!showChoices) proceedRef.current() }}>
|
||||
<div className="dialogue-portrait">{current.poseUrl && <img src={current.poseUrl} alt={current.speaker.name} />}</div>
|
||||
<div className="dialogue-scrim" aria-hidden />
|
||||
<div className="dialogue-box">
|
||||
<div className="dialogue-panel">
|
||||
<div className="dialogue-speaker"><strong>{current.speaker.name}</strong>{current.speaker.role && <em>{current.speaker.role}</em>}</div>
|
||||
<p className="dialogue-text">{fullText.slice(0, charCount)}<span className="dialogue-caret" aria-hidden>{done ? '' : '▍'}</span></p>
|
||||
{showChoices
|
||||
? <div className="dialogue-choices">{options.map(option => <button key={option.id} onClick={event => { event.stopPropagation(); pick(option) }}>{option.text || '(choice)'}</button>)}</div>
|
||||
: <div className="dialogue-advance">{done ? 'CONTINUE ▸' : ''}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
+274
-15
@@ -122,6 +122,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.evidence-card.folder::after { background: #80643e; }
|
||||
.evidence-card.folder header { border-color: #846e49; color: #594a33; }
|
||||
.evidence-card.folder h3 { color: #66401f; }
|
||||
.evidence-card.folder p { max-height: 58px; padding-right: 2px; overflow: hidden; }
|
||||
.evidence-card.event { background: linear-gradient(112deg, #d6d1bd, #c8c5b6); border-left: 5px solid #9a6332; min-height: 174px; }
|
||||
.evidence-card.event h3 { color: #70401e; }
|
||||
.evidence-card.event p { font-size: 15px; }
|
||||
@@ -135,13 +136,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.party-identity small { color: #6a746e; font: 7px IBM Plex Mono; }
|
||||
.evidence-card.party p { margin-top: 9px; font-size: 13px; }
|
||||
.party-aliases { padding: 5px 0; color: #76552f; font: 7px IBM Plex Mono; }
|
||||
.folder-documents { clear: both; margin-top: 9px; border-top: 1px dashed #826c49; padding-top: 6px; }
|
||||
.folder-documents button { float: none; width: 100%; height: 23px; padding: 2px 0; display: grid; grid-template-columns: 14px 1fr auto; text-align: left; color: #493d2c; }
|
||||
.folder-documents button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.folder-documents button time { border: 0; padding: 0; font-size: 7px; }
|
||||
.folder-documents small { display: block; padding: 4px 0 1px 17px; color: #69573d; font: 7px IBM Plex Mono; }
|
||||
.folder-actions { clear: both; display: flex; justify-content: flex-end; gap: 12px; margin-top: 7px; border-top: 1px solid #9a7d50; padding-top: 5px; }
|
||||
.folder-actions button { float: none; color: #60401f; }
|
||||
.folder-actions { position: absolute; right: 13px; bottom: 10px; }
|
||||
.folder-actions button { float: none; min-height: 29px; padding: 6px 9px; gap: 6px; border: 1px solid #81633d; background: #d9bb82; color: #563617; font-size: 9px; box-shadow: 2px 2px #75552f66; }
|
||||
.folder-actions button:hover { background: #e5ca96; color: #321f0f; }
|
||||
.source-file-widget { position: absolute; z-index: 4; width: 174px; min-height: 145px; padding: 8px; color: #1a2421; background: #d9d8cc; border: 1px solid #f1efe2; box-shadow: 5px 7px 0 #020b0980, 0 0 0 1px #53615c; cursor: move; user-select: none; transition: left .42s cubic-bezier(.2,.75,.2,1), top .42s cubic-bezier(.2,.75,.2,1), opacity .28s ease, transform .42s cubic-bezier(.2,.75,.2,1); }
|
||||
.source-file-widget.closed { opacity: 0; transform: scale(.18) rotate(-8deg); pointer-events: none; }
|
||||
.source-file-widget.open { opacity: 1; transform: scale(1) rotate(.6deg); }
|
||||
@@ -190,7 +187,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.story-strip b { color: #d9ddd8; font: 9px IBM Plex Mono; }
|
||||
.story-strip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #849b93; font: 8px Special Elite; }
|
||||
.brief-panel { position: absolute; z-index: 12; right: 18px; top: 18px; width: min(410px, 42vw); max-height: calc(100% - 36px); overflow: auto; background: #c6c9c1; color: #17231f; border: 2px solid #d8dbd4; box-shadow: 7px 9px 0 #020a08, 0 0 0 1px #46554f; }
|
||||
.brief-panel > header { height: 44px; padding: 0 7px 0 13px; display: flex; align-items: center; background: #173d34; color: #e0e8e4; cursor: default; }
|
||||
.brief-panel > header { position: sticky; z-index: 3; top: 0; height: 44px; padding: 0 7px 0 13px; display: flex; align-items: center; background: #173d34; color: #e0e8e4; cursor: default; }
|
||||
.brief-panel > header div { display: grid; gap: 2px; }.brief-panel > header > span { flex: 1; }.brief-panel > header small { color: #9bb0a9; font: 7px IBM Plex Mono; letter-spacing: .14em; }.brief-panel > header b { font: 10px IBM Plex Mono; }.brief-panel > header button { flex: 0 0 auto; width: 25px; height: 24px; margin-left: 4px; display: grid; place-items: center; padding: 0; border: 1px outset #e8ece8; background: #c9cec8; color: #17312b; cursor: pointer; }.brief-panel > header button:hover { background: #eef0eb; color: #070d0b; }
|
||||
.brief-panel.minimized { width: min(330px, 42vw); overflow: hidden; }.brief-panel.minimized > :not(header) { display: none; }
|
||||
.brief-panel > p { margin: 16px; padding: 13px; background: #e2dfd2; border-left: 3px solid #a66d37; font: 13px/1.55 Special Elite; }
|
||||
@@ -220,11 +217,11 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.marker.document i { border-radius: 50%; rotate: 0deg; width: 9px; height: 9px; border-color: #89948f; background: #263a34; }
|
||||
.marker.selected i { border-color: #eea458; background: #eea458; }
|
||||
.timeline-key { border-left: 1px solid #314a43; padding-left: 24px; font: 8px IBM Plex Mono; color: #759087; display: flex; gap: 18px; }.timeline-key span { display: flex; gap: 5px; }.timeline-key i { width: 7px; height: 7px; background: #8eb3a7; rotate: 45deg; }.timeline-key .amber i { background: #eea458; }
|
||||
.window { position: fixed; z-index: 30; background: #bfc4bc; color: #14201d; border: 2px solid #cfd3cc; box-shadow: 5px 6px 0 #020a08, 0 0 0 1px #45534e; }
|
||||
.window { position: fixed; z-index: 30; max-width: calc(100vw - 24px); max-height: calc(100vh - 24px); max-height: calc(100dvh - 24px); overflow-y: auto; overscroll-behavior: contain; background: #bfc4bc; color: #14201d; border: 2px solid #cfd3cc; box-shadow: 5px 6px 0 #020a08, 0 0 0 1px #45534e; }
|
||||
.timeline-editor { width: min(520px, 88vw); }.timeline-editor > div { padding: 24px 27px; }.timeline-editor p { margin: 12px 0 18px; font: 12px/1.5 Special Elite; }.timeline-range-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; }
|
||||
.thread-editor { width: min(520px, 88vw); }.thread-editor > div { padding: 24px 27px; }.thread-editor > div > small { color: #8a4b32; font: 600 8px IBM Plex Mono; letter-spacing: .14em; }.thread-editor p { margin: 14px 0; font: 12px/1.5 Special Elite; }.thread-endpoints { margin-top: 15px; display: grid; grid-template-columns: minmax(0,1fr) 70px minmax(0,1fr); align-items: center; gap: 9px; }.thread-endpoints b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }.thread-endpoints b:last-child { text-align: right; }.thread-endpoints i { height: 3px; background: #982e2b; box-shadow: 0 1px #5b1d1b; }.thread-tightness { margin: 20px 0; }.thread-tightness output { margin-left: auto; color: #9a332e; }.thread-tightness input { accent-color: #9b302d; padding: 0; }.thread-tightness > small { display: flex; justify-content: space-between; color: #68736d; font: 7px IBM Plex Mono; }.thread-position-control { margin-top: 17px; }.thread-position-control output { margin-left: auto; color: #9a5c2f; }.thread-position-control input { accent-color: #9a5c2f; padding: 0; }.thread-position-control > small { color: #68736d; font: 7px/1.4 IBM Plex Mono; }.thread-editor .folder-editor-actions > span { flex: 1; }.folder-editor-actions button.danger { color: #7c2925; border-color: #a25b55; }
|
||||
.tag-style-picker { margin: 16px 0 4px; padding: 0; border: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }.tag-style-picker legend { margin-bottom: 6px; color: #44504c; font: 600 8px IBM Plex Mono; letter-spacing: .1em; }.tag-style-picker label { position: relative; display: grid; gap: 5px; padding: 10px; border: 1px solid #909991; background: #d6d7cf; cursor: pointer; }.tag-style-picker label.selected { border-color: #8f3833; background: #e1d4bd; box-shadow: inset 3px 0 #9c3631; }.tag-style-picker input { position: absolute; opacity: 0; }.tag-style-picker label > span { display: flex; align-items: center; gap: 7px; color: #344b44; font: 600 8px IBM Plex Mono; }.tag-style-picker label > small { color: #69746e; font: 7px IBM Plex Mono; }.tag-style-luggage i { width: 15px; height: 21px; background: #b99562; border: 1px solid #7b6040; clip-path: polygon(3px 0,12px 0,15px 3px,15px 21px,0 21px,0 3px); }.tag-style-compact i { width: 25px; height: 8px; border-left: 7px solid #a63531; background: #d7c9a9; box-shadow: 1px 1px #6e6250; }
|
||||
.window > header { height: 31px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 9px; color: #dfe9e4; background: #183f36; font: 500 11px IBM Plex Mono; cursor: move; touch-action: none; }
|
||||
.window > header { position: sticky; z-index: 3; top: 0; height: 31px; min-height: 31px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 9px; color: #dfe9e4; background: #183f36; font: 500 11px IBM Plex Mono; cursor: move; touch-action: none; }
|
||||
.window > header span { flex: 1; }.window > header button { width: 22px; height: 21px; display: grid; place-items: center; padding: 0; background: #b7bcb4; border: 1px outset white; color: #17221f; cursor: pointer; }
|
||||
.document-window { width: min(610px, 60vw); }.document-window.minimized { width: min(380px, 60vw); }.document-window > nav { height: 28px; padding: 7px 10px; background: #aeb4ac; border-bottom: 1px solid #727c76; font: 9px IBM Plex Mono; }
|
||||
.paper { margin: 17px; padding: 34px 43px; height: min(500px, 58vh); overflow: auto; background: #e8e5d8; box-shadow: inset 0 0 24px #9a968566; font-family: IBM Plex Mono; }
|
||||
@@ -277,11 +274,273 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.metadata-row button { display: grid; place-items: center; border: 0; background: #b8bcb5; color: #6c3a2c; cursor: pointer; }
|
||||
.boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; }
|
||||
.empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; }
|
||||
@media (max-width: 900px) { .menubar { grid-template-columns: 1fr auto; }.menubar nav { display: none; }.terminal-status { font-size: 0; }.documents-panel { width: 275px; }.documents-panel.closed { margin-left: -275px; }.timeline { grid-template-columns: 115px 1fr; padding: 0 12px; }.timeline-key { display: none; }.timeline-track { margin: 0 23px; }.document-window { width: 80vw; }.case-heading { left: 18px; }.case-number { display: none; }.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; } }
|
||||
@media (max-width: 900px) {
|
||||
.menubar { grid-template-columns: 39px minmax(0, 1fr) 10px; gap: 5px; padding: 0 8px; }
|
||||
.brand { gap: 0; }
|
||||
.brand > span:not(.brand-mark) { display: none; }
|
||||
.brand-mark { width: 33px; height: 33px; }
|
||||
.menubar nav { min-width: 0; display: flex; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; overscroll-behavior-inline: contain; -webkit-overflow-scrolling: touch; }
|
||||
.menubar nav::-webkit-scrollbar { display: none; }
|
||||
.menubar nav button { flex: 0 0 auto; padding: 0 10px; font-size: 8px; letter-spacing: .05em; white-space: nowrap; }
|
||||
.admin-menu { flex: 0 0 auto; }
|
||||
.menubar nav .admin-menu-items { position: fixed; top: 68px; right: 8px; width: min(235px, calc(100vw - 16px)); height: auto; }
|
||||
.terminal-status { justify-content: center; gap: 0; font-size: 0; }
|
||||
.terminal-status span { display: none; }
|
||||
.documents-panel { width: 275px; }
|
||||
.documents-panel.closed { margin-left: -275px; }
|
||||
.timeline { grid-template-columns: 115px 1fr; padding: 0 12px; }
|
||||
.timeline-key { display: none; }
|
||||
.timeline-track { margin: 0 23px; }
|
||||
.document-window { width: 80vw; }
|
||||
.case-heading { left: 18px; }
|
||||
.case-number { display: none; }
|
||||
.brief-panel { position: fixed; inset: 0; width: 100vw; height: 100dvh; max-height: none; border-width: 0; box-shadow: none; }
|
||||
.brief-panel > header { position: sticky; z-index: 2; top: 0; min-height: 48px; padding-left: max(13px, env(safe-area-inset-left)); padding-right: max(7px, env(safe-area-inset-right)); }
|
||||
.brief-panel.minimized { inset: 82px 8px auto; width: auto; height: 48px; max-height: 48px; border: 2px solid #d8dbd4; box-shadow: 5px 6px 0 #020a08; }
|
||||
.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; }
|
||||
.board-actions button { width: 38px; padding: 0; justify-content: center; gap: 0; font-size: 0; }
|
||||
.board-actions > b { display: none; }
|
||||
.board-actions > span { margin: 0 2px; }
|
||||
}
|
||||
@media (max-width: 900px) and (orientation: portrait) {
|
||||
.board-actions { top: 50%; right: 8px; bottom: auto; left: auto; width: 96px; height: auto; max-height: calc(100% - 20px); padding: 5px; transform: translateY(-50%); flex-direction: column; align-items: stretch; overflow-y: auto; }
|
||||
.board-actions button { flex: 0 0 36px; width: 100%; padding: 0 7px; justify-content: flex-start; }
|
||||
.board-actions > span { flex: 0 0 1px; width: 100%; height: 1px; margin: 3px 0; }
|
||||
.board-actions > b { padding: 2px 0; text-align: center; }
|
||||
.board-actions { top: 50%; right: 8px; bottom: auto; left: auto; width: 48px; height: auto; max-height: calc(100% - 20px); padding: 5px; transform: translateY(-50%); flex-direction: column; align-items: center; overflow-y: auto; }
|
||||
.board-actions button { flex: 0 0 36px; width: 36px; justify-content: center; }
|
||||
.board-actions > span { flex: 0 0 1px; width: 30px; height: 1px; margin: 3px 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
|
||||
|
||||
/* Narrative layer: splash + NPC dialogue */
|
||||
.splash { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle at 50% 40%, #123029 0, #071916 68%); color: #9bb0a9; }
|
||||
.splash-plate { display: grid; justify-items: center; padding: 20px; }
|
||||
.splash .seal { width: 78px; height: 78px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 15px IBM Plex Mono; margin-bottom: 30px; }
|
||||
.splash-title { margin: 0; color: #e9ede8; font: 40px Special Elite; letter-spacing: .06em; }
|
||||
.splash-sub { margin: 6px 0 0; font: 11px IBM Plex Mono; letter-spacing: .32em; color: #7f9a92; text-transform: uppercase; }
|
||||
.splash-actions { display: flex; gap: 14px; margin: 40px 0 22px; }
|
||||
.splash-button { background: #10362d; border: 1px solid #6f8f85; color: #d8ded9; padding: 13px 26px; font: 11px IBM Plex Mono; letter-spacing: .16em; cursor: pointer; }
|
||||
.splash-button:hover:not(:disabled) { background: #1a493d; color: #fff; }
|
||||
.splash-button.primary { background: #a2551f; border-color: #d58a46; color: #f6ead9; }
|
||||
.splash-button.primary:hover:not(:disabled) { background: #b8622a; }
|
||||
.splash-button:disabled { opacity: .5; cursor: progress; }
|
||||
.splash-status { font: 9px IBM Plex Mono; letter-spacing: .2em; color: #5f7b73; }
|
||||
|
||||
/* Full-screen visual-novel cutscene: NPC art fills the viewport, a translucent
|
||||
panel floats at the bottom. One fluid layout for mobile and desktop. */
|
||||
.dialogue { position: fixed; inset: 0; z-index: 200; height: 100vh; height: 100dvh; overflow: hidden;
|
||||
display: flex; flex-direction: column; justify-content: flex-end; background: #06140f; cursor: pointer; animation: dialogue-in .25s ease; }
|
||||
@keyframes dialogue-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
/* The portrait is a full-bleed, top-cropped, centered cover image. Reserved (empty
|
||||
dark) when no art has been uploaded yet, so layout does not shift when it arrives. */
|
||||
.dialogue-portrait { position: absolute; inset: 0; }
|
||||
.dialogue-portrait img { width: 100%; height: 100%; object-fit: cover; object-position: top center; }
|
||||
/* Legibility gradient behind the panel, present with or without art. */
|
||||
.dialogue-scrim { position: absolute; inset: 0; pointer-events: none;
|
||||
background: linear-gradient(to top, #04110ef2 0%, #04110ed0 20%, #04110e55 42%, #04110e00 68%); }
|
||||
.dialogue-skip { position: absolute; top: max(16px, env(safe-area-inset-top)); right: max(16px, env(safe-area-inset-right));
|
||||
background: #04110e99; border: 1px solid #3c5a52; color: #cfe0d9; padding: 8px 14px; font: 9px IBM Plex Mono; letter-spacing: .18em; cursor: pointer; z-index: 2; }
|
||||
.dialogue-skip:hover { border-color: #6f8f85; color: #fff; }
|
||||
.dialogue-box { position: relative; z-index: 1; width: 100%; padding: 0 clamp(14px, 4vw, 40px) max(20px, env(safe-area-inset-bottom)); }
|
||||
.dialogue-panel { width: min(860px, 100%); margin: 0 auto; border: 1px solid #40655b8c; border-bottom: 0;
|
||||
background: #0a211de6; backdrop-filter: blur(3px); box-shadow: 0 -8px 34px #000b; padding: clamp(16px, 3.4vw, 26px) clamp(18px, 4vw, 34px) clamp(20px, 4vw, 30px); }
|
||||
.dialogue-speaker { display: flex; align-items: baseline; gap: 12px; margin-bottom: 11px; flex-wrap: wrap; }
|
||||
.dialogue-speaker strong { color: #e7b57e; font: 600 clamp(13px, 1.1vw + .5rem, 16px) IBM Plex Mono; letter-spacing: .06em; }
|
||||
.dialogue-speaker em { color: #7f9a92; font: clamp(8px, .5vw + .3rem, 9px) IBM Plex Mono; letter-spacing: .16em; font-style: normal; text-transform: uppercase; }
|
||||
.dialogue-text { margin: 0; max-width: 62ch; min-height: 4.8em; color: #eef2ec; font: clamp(16px, 1vw + .7rem, 21px)/1.6 Special Elite; text-shadow: 0 1px 6px #0007; }
|
||||
.dialogue-caret { color: #d58a46; }
|
||||
.dialogue-advance { margin-top: 15px; text-align: right; min-height: 12px; color: #a9c7bd; font: 9px IBM Plex Mono; letter-spacing: .2em; animation: advance-pulse 1.4s ease-in-out infinite; }
|
||||
@keyframes advance-pulse { 0%, 100% { opacity: .4; } 50% { opacity: 1; } }
|
||||
/* Landscape phones: tiny height — keep the panel compact and never taller than the screen allows. */
|
||||
@media (orientation: landscape) and (max-height: 520px) {
|
||||
.dialogue-panel { padding: 12px 20px 14px; }
|
||||
.dialogue-text { min-height: 3.4em; max-height: 40vh; overflow-y: auto; }
|
||||
.dialogue-advance { margin-top: 8px; }
|
||||
}
|
||||
@media (max-width: 900px) { .splash-title { font-size: 30px; } .splash-actions { flex-direction: column; } }
|
||||
@media (prefers-reduced-motion: reduce) { .dialogue { animation: none; } .dialogue-advance { animation: none; } }
|
||||
|
||||
/* Admin authoring panel */
|
||||
.admin { height: 100vh; height: 100dvh; display: grid; grid-template-rows: 56px 1fr 30px; background: #071916; color: #d8ded9; }
|
||||
.admin-head { display: flex; align-items: center; gap: 26px; padding: 0 22px; border-bottom: 1px solid #315049; background: #0a211d; }
|
||||
.admin-tabs { display: flex; gap: 4px; margin-left: 14px; }
|
||||
.admin-tabs button { background: none; border: 0; padding: 8px 16px; font: 500 11px IBM Plex Mono; letter-spacing: .12em; color: #94aaa4; cursor: pointer; border-bottom: 2px solid transparent; }
|
||||
.admin-tabs button:hover { color: #e4e9e4; }
|
||||
.admin-tabs button.active { color: #fff; border-bottom-color: #d58a46; }
|
||||
.admin-link { margin-left: auto; color: #8fb0a6; text-decoration: none; font: 10px IBM Plex Mono; letter-spacing: .12em; }
|
||||
.admin-link:hover { color: #e7b57e; }
|
||||
.admin-body { display: grid; grid-template-columns: 300px 1fr; min-height: 0; }
|
||||
.npc-list { border-right: 1px solid #243d36; background: #0b201b; overflow: auto; display: flex; flex-direction: column; }
|
||||
.npc-list-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 16px 10px; font: 600 9px IBM Plex Mono; letter-spacing: .18em; color: #78958d; }
|
||||
.npc-list-head button { background: #143229; border: 1px solid #3c5a52; color: #d79754; font: 9px IBM Plex Mono; padding: 5px 10px; cursor: pointer; }
|
||||
.npc-list-head button:hover { background: #1c463a; }
|
||||
.npc-row { display: flex; align-items: center; gap: 12px; padding: 11px 16px; border: 0; border-bottom: 1px solid #17302a; background: none; text-align: left; cursor: pointer; color: inherit; }
|
||||
.npc-row:hover { background: #12312a; }
|
||||
.npc-row.selected { background: #1b3d33; box-shadow: inset 3px 0 #d58a46; }
|
||||
.npc-avatar { width: 40px; height: 40px; flex: 0 0 auto; display: grid; place-items: center; overflow: hidden; border: 1px solid #3c5a52; background: #0e2a24; color: #6f9084; font: 600 15px Special Elite; }
|
||||
.npc-avatar img { width: 100%; height: 100%; object-fit: cover; object-position: top center; }
|
||||
.npc-row-text strong { display: block; font: 13px IBM Plex Mono; color: #dfe5e0; }
|
||||
.npc-row-text small { color: #7f9a92; font: 9px IBM Plex Mono; letter-spacing: .04em; }
|
||||
.npc-editor { padding: 26px 32px; overflow: auto; max-width: 720px; }
|
||||
.npc-editor.empty { display: grid; place-items: center; color: #607d75; font: 11px IBM Plex Mono; }
|
||||
.npc-editor-head { display: flex; align-items: baseline; gap: 14px; margin-bottom: 22px; }
|
||||
.npc-editor-head h2 { margin: 0; font: 500 24px Special Elite; color: #e9ede8; }
|
||||
.npc-editor-head code { color: #8fb0a6; font: 10px IBM Plex Mono; background: #0e2a24; padding: 3px 8px; border: 1px solid #2c473f; }
|
||||
.npc-editor-head .danger { margin-left: auto; background: none; border: 1px solid #7a3b34; color: #d78a7f; font: 9px IBM Plex Mono; padding: 6px 12px; cursor: pointer; }
|
||||
.npc-editor-head .danger:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.npc-editor-head .danger:not(:disabled):hover { background: #4a221d; }
|
||||
.admin-field { display: grid; gap: 5px; margin-bottom: 15px; }
|
||||
.admin-field label { font: 600 9px IBM Plex Mono; letter-spacing: .14em; color: #86a199; }
|
||||
.admin-field input, .admin-field select, .pose-key { background: #0c231e; border: 1px solid #35544c; color: #e4e9e4; padding: 9px 11px; font: 12px IBM Plex Mono; }
|
||||
.admin-field input:focus, .admin-field select:focus, .pose-key:focus { outline: 0; border-color: #9a683d; box-shadow: inset 0 0 0 1px #6f4b2f; }
|
||||
.admin-save { margin-top: 4px; background: #a2551f; border: 1px solid #d58a46; color: #f6ead9; padding: 10px 20px; font: 10px IBM Plex Mono; letter-spacing: .12em; cursor: pointer; }
|
||||
.admin-save:disabled { opacity: .45; cursor: default; background: #143229; border-color: #35544c; color: #8fb0a6; }
|
||||
.npc-editor h3 { margin: 30px 0 12px; font: 600 10px IBM Plex Mono; letter-spacing: .18em; color: #86a199; }
|
||||
.pose-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 14px; }
|
||||
.pose-card { position: relative; margin: 0; border: 1px solid #35544c; background: #0e2a24; }
|
||||
.pose-card.is-default { border-color: #d58a46; box-shadow: 0 0 0 1px #d58a4655; }
|
||||
.pose-card img { width: 100%; aspect-ratio: 3/4; object-fit: cover; object-position: top center; display: block; }
|
||||
.pose-card figcaption { padding: 7px 9px; font: 10px IBM Plex Mono; color: #cfe0d9; letter-spacing: .04em; }
|
||||
.pose-card.is-default figcaption::after { content: ' · default'; color: #d58a46; }
|
||||
.pose-remove { position: absolute; top: 5px; right: 5px; width: 22px; height: 22px; border: 0; border-radius: 2px; background: #04110ecc; color: #e5b3ab; font-size: 15px; line-height: 1; cursor: pointer; }
|
||||
.pose-remove:hover { background: #4a221d; color: #fff; }
|
||||
.pose-upload { display: flex; gap: 12px; align-items: center; margin-top: 16px; flex-wrap: wrap; }
|
||||
.pose-upload .pose-key { flex: 0 0 220px; }
|
||||
.pose-upload input[type=file] { font: 10px IBM Plex Mono; color: #9bb0a9; }
|
||||
.admin-hint, .admin-note { color: #6f8b83; font: 9px/1.5 IBM Plex Mono; }
|
||||
.admin-hint { display: block; margin-top: 16px; }
|
||||
.admin-note { margin-top: 20px; }
|
||||
.admin-empty { color: #607d75; font: 10px IBM Plex Mono; padding: 16px; }
|
||||
.mystery-list { padding: 26px 32px; overflow: auto; }
|
||||
.mystery-row { padding: 12px 0; border-bottom: 1px solid #1c352e; display: grid; gap: 3px; }
|
||||
.mystery-row strong { color: #dfe5e0; font: 14px IBM Plex Mono; }
|
||||
.mystery-row span { color: #7f9a92; font: 9px IBM Plex Mono; letter-spacing: .06em; }
|
||||
.admin-foot { display: flex; align-items: center; padding: 0 22px; border-top: 1px solid #243d36; background: #0a211d; font: 9px IBM Plex Mono; letter-spacing: .08em; color: #718d84; }
|
||||
.boot .admin-link { color: #8fb0a6; }
|
||||
@media (max-width: 760px) { .admin-body { grid-template-columns: 1fr; } .npc-list { max-height: 34vh; } }
|
||||
|
||||
/* Mystery story-flow graph editor */
|
||||
.mystery-row { width: 100%; border: 0; border-bottom: 1px solid #1c352e; background: none; text-align: left; cursor: pointer; }
|
||||
.mystery-row:hover { background: #12312a; }
|
||||
.graph-loading, .graph-empty { display: grid; place-items: center; height: 100%; color: #607d75; font: 11px IBM Plex Mono; }
|
||||
.graph-editor { display: flex; flex-direction: column; min-height: 0; background: #08191500; }
|
||||
.graph-toolbar { display: flex; align-items: center; gap: 10px; padding: 8px 16px; border-bottom: 1px solid #243d36; background: #0a211d; flex-wrap: wrap; }
|
||||
.graph-toolbar strong { color: #e9ede8; font: 13px IBM Plex Mono; margin-right: 8px; }
|
||||
.graph-back { background: none; border: 1px solid #3c5a52; color: #9bb0a9; font: 9px IBM Plex Mono; padding: 6px 11px; cursor: pointer; }
|
||||
.graph-add-label { color: #78958d; font: 9px IBM Plex Mono; letter-spacing: .12em; margin-left: 8px; }
|
||||
.graph-add { background: #143229; border: 1px solid #3c5a52; color: #d79754; font: 9px IBM Plex Mono; padding: 6px 10px; cursor: pointer; }
|
||||
.graph-add:hover { background: #1c463a; }
|
||||
.graph-wiring { color: #e7b57e; font: 9px IBM Plex Mono; letter-spacing: .06em; }
|
||||
.graph-zoom { margin-left: auto; color: #6f8b83; font: 9px IBM Plex Mono; }
|
||||
.graph-main { flex: 1; display: flex; min-height: 0; }
|
||||
.graph-canvas { position: relative; flex: 1; overflow: hidden; background:
|
||||
radial-gradient(#49615a33 1px, transparent 1px), #0b1d19; background-size: 22px 22px; touch-action: none; cursor: grab; }
|
||||
.graph-canvas.wiring { cursor: crosshair; }
|
||||
.graph-world { position: absolute; top: 0; left: 0; transform-origin: 0 0; }
|
||||
.graph-wires { position: absolute; top: 0; left: 0; overflow: visible; pointer-events: none; }
|
||||
.graph-wire { fill: none; stroke: #c98a4c; stroke-width: 2; opacity: .8; }
|
||||
.gnode { position: absolute; background: #0e2a24; border: 1px solid #40655b; box-shadow: 4px 5px 0 #04110e66; user-select: none; }
|
||||
.gnode.selected { border-color: #e7b57e; box-shadow: 0 0 0 1px #e7b57e, 4px 5px 0 #04110e66; }
|
||||
.gnode.entry { box-shadow: -4px 0 0 #6fbf8b, 4px 5px 0 #04110e66; }
|
||||
.gnode.entry.selected { box-shadow: -4px 0 0 #6fbf8b, 0 0 0 1px #e7b57e; }
|
||||
.gnode-head { height: 30px; display: flex; align-items: center; gap: 7px; padding: 0 10px; cursor: grab; border-bottom: 1px solid #24413a; }
|
||||
.gnode-head:active { cursor: grabbing; }
|
||||
.gnode-type { font: 600 8px IBM Plex Mono; letter-spacing: .1em; text-transform: uppercase; padding: 2px 5px; border-radius: 2px; background: #24413a; color: #9bd; }
|
||||
.type-cutscene .gnode-type { color: #d7a0e0; } .type-dialogue .gnode-type { color: #7fc7b6; }
|
||||
.type-level .gnode-type { color: #e7b57e; } .type-det_gate .gnode-type { color: #d89a9a; } .type-llm_gate .gnode-type { color: #c9b06e; }
|
||||
.gnode-label { flex: 1; font: 11px IBM Plex Mono; color: #e4e9e4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.gnode-entry { color: #6fbf8b; font-size: 10px; }
|
||||
.gnode-sub { height: 18px; padding: 0 10px; font: 8px IBM Plex Mono; color: #7f9a92; display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.gnode-terminals { padding-bottom: 4px; }
|
||||
.gterm { position: relative; display: flex; align-items: center; padding: 0 12px; }
|
||||
.gterm-label { font: 9px IBM Plex Mono; color: #b6c6c0; margin-left: auto; }
|
||||
.gport { position: absolute; right: -7px; top: 50%; transform: translateY(-50%); width: 12px; height: 12px; border-radius: 50%; border: 2px solid #6f8f85; background: #0b1d19; cursor: pointer; padding: 0; }
|
||||
.gport:hover { border-color: #e7b57e; }
|
||||
.gport.wired { background: #c98a4c; border-color: #c98a4c; }
|
||||
.gport.active { border-color: #e7b57e; box-shadow: 0 0 0 3px #e7b57e55; }
|
||||
.ginput { position: absolute; left: -6px; top: 20px; width: 11px; height: 11px; border-radius: 50%; background: #2c473f; border: 2px solid #6f8f85; }
|
||||
.graph-inspector { width: 280px; flex: 0 0 auto; border-left: 1px solid #243d36; background: #0b201b; overflow: auto; }
|
||||
.inspector-body { padding: 16px; display: grid; gap: 12px; }
|
||||
.inspector-head { display: flex; align-items: center; gap: 10px; }
|
||||
.ins-entry { margin-left: auto; background: #143229; border: 1px solid #3c5a52; color: #6fbf8b; font: 8px IBM Plex Mono; padding: 5px 9px; cursor: pointer; }
|
||||
.ins-field { display: grid; gap: 4px; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #86a199; }
|
||||
.ins-field input, .ins-field select { background: #0c231e; border: 1px solid #35544c; color: #e4e9e4; padding: 7px 9px; font: 11px IBM Plex Mono; }
|
||||
.ins-check { display: flex; align-items: center; gap: 8px; font: 10px IBM Plex Mono; color: #b6c6c0; }
|
||||
.ins-terminals-head { display: flex; justify-content: space-between; align-items: center; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #86a199; margin-top: 6px; }
|
||||
.ins-terminals-head button { background: #143229; border: 1px solid #3c5a52; color: #d79754; font: 8px IBM Plex Mono; padding: 4px 8px; cursor: pointer; }
|
||||
.ins-terminal { display: flex; align-items: center; gap: 6px; }
|
||||
.ins-terminal input { flex: 0 0 84px; background: #0c231e; border: 1px solid #35544c; color: #e4e9e4; padding: 5px 7px; font: 10px IBM Plex Mono; }
|
||||
.ins-terminal-to { flex: 1; font: 9px IBM Plex Mono; color: #7f9a92; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ins-unwire, .ins-term-del { background: none; border: 1px solid #3c5a52; color: #9bb0a9; width: 22px; height: 24px; cursor: pointer; font-size: 12px; }
|
||||
.ins-unwire:hover, .ins-term-del:hover { border-color: #d78a7f; color: #e5b3ab; }
|
||||
.ins-delete { margin-top: 8px; background: none; border: 1px solid #7a3b34; color: #d78a7f; font: 9px IBM Plex Mono; padding: 8px; cursor: pointer; }
|
||||
.ins-delete:hover { background: #4a221d; }
|
||||
|
||||
/* Utterance sub-canvas (dialogue crafter) */
|
||||
.graph-editor { position: relative; }
|
||||
.utterance-overlay { position: absolute; inset: 0; z-index: 20; display: flex; flex-direction: column; background: #071916; }
|
||||
.ins-utterances { background: #143229; border: 1px solid #3c5a52; color: #7fc7b6; font: 9px IBM Plex Mono; padding: 8px; cursor: pointer; }
|
||||
.ins-utterances:hover { background: #1c463a; }
|
||||
.ucard { position: absolute; background: #10241f; border: 1px solid #40655b; box-shadow: 3px 4px 0 #04110e66; user-select: none; display: flex; flex-direction: column; }
|
||||
.ucard.u-npc { border-left: 3px solid #7fc7b6; }
|
||||
.ucard.u-player { border-left: 3px solid #e7b57e; background: #18231d; }
|
||||
.ucard.selected { border-color: #e7b57e; box-shadow: 0 0 0 1px #e7b57e, 3px 4px 0 #04110e66; }
|
||||
.ucard-head { height: 24px; display: flex; align-items: center; gap: 6px; padding: 0 9px; cursor: grab; border-bottom: 1px solid #24413a; }
|
||||
.ucard-head:active { cursor: grabbing; }
|
||||
.ucard-badge { font: 600 8px IBM Plex Mono; letter-spacing: .08em; color: #cfe0d9; text-transform: uppercase; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ucard-pose { margin-left: auto; font: 8px IBM Plex Mono; color: #86a199; }
|
||||
.ucard-text { flex: 1; padding: 6px 9px; font: 12px/1.3 Special Elite; color: #dfe5e0; overflow: hidden; }
|
||||
.ucard-text em { color: #5f7b73; }
|
||||
.uinput { position: absolute; left: -6px; top: 50%; transform: translateY(-50%); width: 11px; height: 11px; border-radius: 50%; background: #2c473f; border: 2px solid #6f8f85; }
|
||||
.uport { position: absolute; width: 13px; height: 13px; border-radius: 50%; border: 2px solid #6f8f85; background: #0b1d19; cursor: pointer; padding: 0; }
|
||||
.uport.flow { right: -7px; top: 50%; transform: translateY(-50%); }
|
||||
.uport.options { bottom: -7px; left: 50%; transform: translateX(-50%); border-color: #c98a4c; }
|
||||
.uport:hover { border-color: #e7b57e; }
|
||||
.uport.wired { background: #c98a4c; border-color: #c98a4c; }
|
||||
.uport.active { box-shadow: 0 0 0 3px #e7b57e55; border-color: #e7b57e; }
|
||||
.usink { position: absolute; height: 40px; display: flex; align-items: center; padding: 0 12px; background: #241a12; border: 1px dashed #c98a4c; color: #e7b57e; cursor: pointer; }
|
||||
.usink:hover { background: #30241a; }
|
||||
.usink-label { font: 9px IBM Plex Mono; letter-spacing: .06em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.graph-wire.exit { stroke: #e7b57e; }
|
||||
.graph-wire.option { stroke: #7fc7b6; stroke-dasharray: 5 4; opacity: .7; }
|
||||
.ins-text { min-height: 70px; resize: vertical; background: #0c231e; border: 1px solid #35544c; color: #e4e9e4; padding: 8px; font: 13px/1.4 Special Elite; }
|
||||
.ins-links { font: 9px IBM Plex Mono; color: #9bb0a9; display: grid; gap: 6px; }
|
||||
.ins-links > div { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* Story-graph runtime: cutscene title card + report-back */
|
||||
.cutscene-card { position: fixed; inset: 0; z-index: 200; display: grid; place-items: center; background: #04110e; cursor: pointer; animation: dialogue-in .3s ease; }
|
||||
.title-card-inner { display: grid; justify-items: center; text-align: center; gap: 18px; animation: title-rise 1.1s cubic-bezier(.2,.7,.2,1); }
|
||||
@keyframes title-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
|
||||
.title-card-inner small { font: 10px IBM Plex Mono; letter-spacing: .3em; color: #7f9a92; text-transform: uppercase; }
|
||||
.title-card-inner h1 { margin: 0; font: 44px Special Elite; letter-spacing: .04em; color: #eef2ec; max-width: 16ch; }
|
||||
.cutscene-missing { color: #b56d30 !important; letter-spacing: .08em !important; }
|
||||
.cutscene-begin { margin-top: 10px; background: #a2551f; border: 1px solid #d58a46; color: #f6ead9; padding: 11px 24px; font: 11px IBM Plex Mono; letter-spacing: .16em; cursor: pointer; }
|
||||
.cutscene-begin:hover { background: #b8622a; }
|
||||
.menubar nav button.report-back { color: #f6ead9; background: #a2551f; }
|
||||
.menubar nav button.report-back:hover { background: #b8622a; }
|
||||
@media (max-width: 900px) { .title-card-inner h1 { font-size: 30px; } }
|
||||
|
||||
/* Utterance cards: expand to full content, ports anchored to top, colour by speaker */
|
||||
.ucard { height: auto; min-height: 58px; }
|
||||
.ucard.u-npc { background: #0f2a24; border-left: 3px solid #6fbfa9; }
|
||||
.ucard.u-player { background: #2b2113; border-left: 3px solid #e0a253; }
|
||||
.u-npc .ucard-badge { color: #9fe0cd; }
|
||||
.u-player .ucard-badge { color: #e8bd80; }
|
||||
.ucard-text { flex: none; overflow: visible; white-space: pre-wrap; min-height: 20px; }
|
||||
.uinput { top: 18px; transform: none; }
|
||||
.uport.flow { top: 18px; transform: none; }
|
||||
.uport.options { top: 44px; bottom: auto; left: auto; right: -7px; transform: none; }
|
||||
|
||||
/* Clickable wires (delete a connection by clicking it) */
|
||||
.wire-hit { fill: none; stroke: transparent; stroke-width: 16; pointer-events: stroke; cursor: pointer; }
|
||||
.wire-hit:hover + .graph-wire { stroke-width: 4; filter: drop-shadow(0 0 3px #e7b57e); }
|
||||
|
||||
/* Branching dialogue: player choice buttons */
|
||||
.dialogue-choices { display: grid; gap: 8px; margin-top: 14px; }
|
||||
.dialogue-choices button { text-align: left; background: #0e2a24e6; border: 1px solid #6f8f85; color: #eef2ec; padding: 12px 16px; font: 15px/1.4 Special Elite; cursor: pointer; min-height: 46px; }
|
||||
.dialogue-choices button:hover { background: #1a493d; border-color: #e7b57e; }
|
||||
|
||||
/* Vertical mystery-graph nodes: input top, output terminals along the bottom */
|
||||
.gnode { display: flex; flex-direction: column; }
|
||||
.ginput { top: -6px; left: 50%; right: auto; bottom: auto; transform: translateX(-50%); }
|
||||
.gnode-outs { display: flex; justify-content: space-around; align-items: flex-end; gap: 4px; margin-top: auto; padding: 2px 6px 0; }
|
||||
.gout { position: relative; flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 3px; padding-bottom: 9px; }
|
||||
.gout-label { font: 9px IBM Plex Mono; color: #b6c6c0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
||||
.gout .gport { position: absolute; bottom: -7px; left: 50%; top: auto; right: auto; transform: translateX(-50%); }
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react'
|
||||
|
||||
type Utterer = 'npc' | 'player'
|
||||
type Utterance = {
|
||||
id: string; nodeId: string; utterer: Utterer; npcId: string | null; poseKey: string | null; text: string
|
||||
parentUtteranceId: string | null; advancesToUtteranceId: string | null; terminalId: string | null
|
||||
effect: string | null; xpos: number; ypos: number; sortOrder: number
|
||||
}
|
||||
type Terminal = { id: string; terminalKey: string; label: string }
|
||||
type Npc = { id: string; name: string; poses: { poseKey: string; url: string }[] }
|
||||
|
||||
const UW = 220, PORT_Y = 24, SINK_W = 150
|
||||
const sinkY = (index: number) => 30 + index * 74
|
||||
|
||||
async function api<T>(url: string, method: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(url, { method, headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined })
|
||||
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || `Request failed (${response.status})`)
|
||||
return response.json().catch(() => ({} as T))
|
||||
}
|
||||
|
||||
export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStatus }: {
|
||||
nodeId: string; nodeLabel: string; terminals: Terminal[]; onClose: () => void; setStatus: (message: string) => void
|
||||
}) {
|
||||
const [utterances, setUtterances] = useState<Utterance[]>([])
|
||||
const [npcs, setNpcs] = useState<Npc[]>([])
|
||||
const [view, setView] = useState({ x: 40, y: 40, zoom: 1 })
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [wiringFrom, setWiringFrom] = useState<{ id: string } | null>(null)
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const drag = useRef<{ id: string; startX: number; startY: number; origX: number; origY: number } | 'pan' | null>(null)
|
||||
const panRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try { setUtterances(await api<Utterance[]>(`/api/admin/story-nodes/${nodeId}/utterances`, 'GET')) }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}, [nodeId, setStatus])
|
||||
useEffect(() => { void reload(); api<Npc[]>('/api/admin/npcs', 'GET').then(setNpcs).catch(() => {}) }, [reload])
|
||||
|
||||
// Undo stack of inverse operations (connection edits, Tab creation).
|
||||
const undoRef = useRef<Array<() => Promise<void>>>([])
|
||||
const pushUndo = (fn: () => Promise<void>) => { undoRef.current.push(fn); if (undoRef.current.length > 40) undoRef.current.shift() }
|
||||
const doUndo = async () => {
|
||||
const fn = undoRef.current.pop()
|
||||
if (!fn) { setStatus('Nothing to undo'); return }
|
||||
try { await fn(); await reload() } catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}
|
||||
|
||||
// Keyboard (ignored while typing in a field): Ctrl/Cmd+Z undoes; Tab adds a child under
|
||||
// the selected utterance (one child = linear, a second makes them player options);
|
||||
// 1/2 set the selected utterance's speaker.
|
||||
const keyActionRef = useRef((_event: KeyboardEvent) => {})
|
||||
keyActionRef.current = (event: KeyboardEvent) => {
|
||||
const tag = (document.activeElement?.tagName || '').toLowerCase()
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') { event.preventDefault(); void doUndo(); return }
|
||||
if (!selectedId) return
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
const parent = utterances.find(u => u.id === selectedId)
|
||||
if (!parent) return
|
||||
const siblings = utterances.filter(u => u.parentUtteranceId === parent.id)
|
||||
void (async () => {
|
||||
try {
|
||||
const created = await api<Utterance>(`/api/admin/story-nodes/${nodeId}/utterances`, 'POST',
|
||||
{ utterer: 'player', xpos: Math.round(parent.xpos + 270), ypos: Math.round(parent.ypos + siblings.length * 92), text: '' })
|
||||
await api(`/api/admin/utterances/${created.id}`, 'PATCH', { parentUtteranceId: parent.id })
|
||||
// 2+ children ⇒ player options; a lone child stays a linear NPC next line.
|
||||
if (siblings.length + 1 >= 2) for (const child of [...siblings, created]) await api(`/api/admin/utterances/${child.id}`, 'PATCH', { utterer: 'player', npcId: null })
|
||||
else await api(`/api/admin/utterances/${created.id}`, 'PATCH', { utterer: 'npc' })
|
||||
pushUndo(() => api(`/api/admin/utterances/${created.id}`, 'DELETE'))
|
||||
await reload(); setSelectedId(parent.id) // keep the parent selected to add more options
|
||||
} catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
})()
|
||||
} else if (event.key === '1') void patch(selectedId, { utterer: 'npc' })
|
||||
else if (event.key === '2') void patch(selectedId, { utterer: 'player', npcId: null })
|
||||
}
|
||||
useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => keyActionRef.current(event)
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
|
||||
const patch = async (id: string, body: Record<string, unknown>, silent = false) => {
|
||||
try { await api(`/api/admin/utterances/${id}`, 'PATCH', body); if (!silent) await reload() }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}
|
||||
const add = async (utterer: Utterer) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect()
|
||||
const cx = ((rect ? rect.width / 2 : 250) - view.x) / view.zoom, cy = ((rect ? rect.height / 2 : 200) - view.y) / view.zoom
|
||||
try { const u = await api<Utterance>(`/api/admin/story-nodes/${nodeId}/utterances`, 'POST', { utterer, xpos: Math.round(cx), ypos: Math.round(cy) }); await reload(); setSelectedId(u.id) }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}
|
||||
|
||||
const onBgPointerDown = (event: React.PointerEvent) => {
|
||||
if (wiringFrom) { setWiringFrom(null); return }
|
||||
panRef.current = { startX: event.clientX, startY: event.clientY, origX: view.x, origY: view.y }; drag.current = 'pan'; setSelectedId(null)
|
||||
}
|
||||
const onCardPointerDown = (event: React.PointerEvent, u: Utterance) => {
|
||||
;(event.target as HTMLElement).setPointerCapture?.(event.pointerId)
|
||||
drag.current = { id: u.id, startX: event.clientX, startY: event.clientY, origX: u.xpos, origY: u.ypos }; setSelectedId(u.id)
|
||||
}
|
||||
const onPointerMove = (event: React.PointerEvent) => {
|
||||
if (drag.current === 'pan' && panRef.current) { const p = panRef.current; setView(v => ({ ...v, x: p.origX + event.clientX - p.startX, y: p.origY + event.clientY - p.startY })); return }
|
||||
if (drag.current && drag.current !== 'pan') { const d = drag.current; setUtterances(list => list.map(u => u.id === d.id ? { ...u, xpos: d.origX + (event.clientX - d.startX) / view.zoom, ypos: d.origY + (event.clientY - d.startY) / view.zoom } : u)) }
|
||||
}
|
||||
const onPointerUp = async () => {
|
||||
const state = drag.current; drag.current = null; panRef.current = null
|
||||
if (state && state !== 'pan') { const u = utterances.find(x => x.id === state.id); if (u) await patch(u.id, { xpos: Math.round(u.xpos), ypos: Math.round(u.ypos) }, true) }
|
||||
}
|
||||
const onWheel = (event: React.WheelEvent) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return
|
||||
const px = event.clientX - rect.left, py = event.clientY - rect.top, factor = event.deltaY < 0 ? 1.1 : 1 / 1.1
|
||||
setView(v => { const zoom = Math.min(2, Math.max(0.35, v.zoom * factor)); return { zoom, x: px - (px - v.x) * (zoom / v.zoom), y: py - (py - v.y) * (zoom / v.zoom) } })
|
||||
}
|
||||
|
||||
// A card's children are what come after it: dragging its port to another card makes
|
||||
// that card a child. One child ⇒ solid (linear next line); two or more ⇒ dotted
|
||||
// (player options). A card can instead exit the node by wiring to a terminal sink.
|
||||
const link = (id: string, body: Record<string, unknown>, undoBody: Record<string, unknown>) => {
|
||||
pushUndo(() => api(`/api/admin/utterances/${id}`, 'PATCH', undoBody)); void patch(id, body)
|
||||
}
|
||||
const targetCard = (u: Utterance) => {
|
||||
if (!wiringFrom) { setSelectedId(u.id); return }
|
||||
const source = wiringFrom.id; setWiringFrom(null)
|
||||
if (source === u.id) return
|
||||
link(u.id, { parentUtteranceId: source }, { parentUtteranceId: u.parentUtteranceId })
|
||||
}
|
||||
const targetSink = (terminalId: string) => {
|
||||
const source = wiringFrom ? utterances.find(x => x.id === wiringFrom.id) : null; setWiringFrom(null)
|
||||
if (source) link(source.id, { terminalId }, { terminalId: source.terminalId })
|
||||
}
|
||||
|
||||
const byId = new Map(utterances.map(u => [u.id, u]))
|
||||
const childCount = new Map<string, number>()
|
||||
for (const u of utterances) if (u.parentUtteranceId) childCount.set(u.parentUtteranceId, (childCount.get(u.parentUtteranceId) || 0) + 1)
|
||||
// Dock the exit sinks to the right of the utterances so flow reads left-to-right.
|
||||
const sinkX = Math.max(560, ...utterances.map(u => u.xpos + UW + 90))
|
||||
const npcName = (id: string | null) => npcs.find(n => n.id === id)?.name || 'NPC'
|
||||
const selected = utterances.find(u => u.id === selectedId) || null
|
||||
const flowPort = (u: Utterance) => ({ x: u.xpos + UW, y: u.ypos + PORT_Y })
|
||||
const cardInput = (u: Utterance) => ({ x: u.xpos, y: u.ypos + PORT_Y })
|
||||
const sinkInput = (i: number) => ({ x: sinkX, y: sinkY(i) + 20 })
|
||||
const curve = (a: { x: number; y: number }, b: { x: number; y: number }) => { const dx = Math.max(30, Math.abs(b.x - a.x) / 2); return `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}` }
|
||||
|
||||
return <div className="utterance-overlay">
|
||||
<div className="graph-toolbar">
|
||||
<button className="graph-back" onClick={onClose}>← Graph</button>
|
||||
<strong>{nodeLabel || 'Dialogue'} · utterances</strong>
|
||||
<span className="graph-add-label">Add:</span>
|
||||
<button className="graph-add" onClick={() => add('npc')}>NPC line</button>
|
||||
<button className="graph-add" onClick={() => add('player')}>Player choice</button>
|
||||
{wiringFrom && <span className="graph-wiring">Click the next card (2+ ⇒ options) or an exit ⇥ · click empty to cancel</span>}
|
||||
<span className="graph-zoom">{Math.round(view.zoom * 100)}%</span>
|
||||
</div>
|
||||
<div className="graph-main">
|
||||
<div ref={canvasRef} className={`graph-canvas${wiringFrom ? ' wiring' : ''}`} onPointerDown={onBgPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onWheel={onWheel}>
|
||||
<div className="graph-world" style={{ transform: `translate(${view.x}px,${view.y}px) scale(${view.zoom})` }}>
|
||||
<svg className="graph-wires" width="6000" height="6000">
|
||||
{utterances.flatMap(u => {
|
||||
const wires: ReactElement[] = []
|
||||
const wire = (key: string, d: string, cls: string, onDelete: () => void) => {
|
||||
wires.push(<path key={key + 'hit'} className="wire-hit" d={d} onClick={event => { event.stopPropagation(); onDelete() }} />)
|
||||
wires.push(<path key={key} className={`graph-wire ${cls}`} d={d} />)
|
||||
}
|
||||
if (u.parentUtteranceId && byId.has(u.parentUtteranceId)) {
|
||||
const parent = byId.get(u.parentUtteranceId)!
|
||||
const cls = (childCount.get(parent.id) || 0) >= 2 ? 'option' : ''
|
||||
wire(u.id + 'p', curve(flowPort(parent), cardInput(u)), cls, () => link(u.id, { parentUtteranceId: null }, { parentUtteranceId: parent.id }))
|
||||
}
|
||||
if (u.terminalId) { const idx = terminals.findIndex(t => t.id === u.terminalId); if (idx >= 0) wire(u.id + 't', curve(flowPort(u), sinkInput(idx)), 'exit', () => link(u.id, { terminalId: null }, { terminalId: u.terminalId })) }
|
||||
return wires
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{terminals.map((t, i) => <div key={t.id} className="usink" style={{ left: sinkX, top: sinkY(i), width: SINK_W }} onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); targetSink(t.id) }}>
|
||||
<div className="uinput" />
|
||||
<span className="usink-label">⇥ {t.label || t.terminalKey}</span>
|
||||
</div>)}
|
||||
|
||||
{utterances.map(u => <div key={u.id} className={`ucard u-${u.utterer}${u.id === selectedId ? ' selected' : ''}`} style={{ left: u.xpos, top: u.ypos, width: UW }}
|
||||
onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); targetCard(u) }}>
|
||||
<div className="uinput" />
|
||||
<div className="ucard-head" onPointerDown={e => onCardPointerDown(e, u)}>
|
||||
<span className="ucard-badge">{u.utterer === 'npc' ? npcName(u.npcId) : 'PLAYER'}</span>
|
||||
{u.utterer === 'npc' && u.poseKey && <span className="ucard-pose">{u.poseKey}</span>}
|
||||
</div>
|
||||
<div className="ucard-text">{u.text || <em>(empty)</em>}</div>
|
||||
<button className={`uport flow${(childCount.get(u.id) || 0) > 0 || u.terminalId ? ' wired' : ''}${wiringFrom?.id === u.id ? ' active' : ''}`}
|
||||
title="Connect to the next line(s) or an exit ⇥ · 1 = linear, 2+ = options"
|
||||
onClick={e => { e.stopPropagation(); setWiringFrom(w => w?.id === u.id ? null : { id: u.id }) }} />
|
||||
</div>)}
|
||||
</div>
|
||||
{utterances.length === 0 && <div className="graph-empty">No utterances yet — add an NPC line or player choice.</div>}
|
||||
</div>
|
||||
|
||||
{selected && <aside className="graph-inspector">
|
||||
<div className="inspector-body">
|
||||
<div className="inspector-head"><span className={`gnode-type type-${selected.utterer === 'npc' ? 'dialogue' : 'level'}`}>{selected.utterer}</span></div>
|
||||
{selected.utterer === 'npc' && <>
|
||||
<label className="ins-field"><span>Speaker</span>
|
||||
<select value={selected.npcId || ''} onChange={e => patch(selected.id, { npcId: e.target.value || null, poseKey: null })}>
|
||||
<option value="">— choose NPC —</option>
|
||||
{npcs.map(n => <option key={n.id} value={n.id}>{n.name}</option>)}
|
||||
</select></label>
|
||||
<label className="ins-field"><span>Pose</span>
|
||||
<select value={selected.poseKey || ''} onChange={e => patch(selected.id, { poseKey: e.target.value || null })}>
|
||||
<option value="">— default / none —</option>
|
||||
{(npcs.find(n => n.id === selected.npcId)?.poses || []).map(p => <option key={p.poseKey} value={p.poseKey}>{p.poseKey}</option>)}
|
||||
</select></label>
|
||||
</>}
|
||||
<label className="ins-field"><span>{selected.utterer === 'npc' ? 'Line' : 'Choice text'}</span>
|
||||
<textarea className="ins-text" defaultValue={selected.text} onBlur={e => e.target.value !== selected.text && patch(selected.id, { text: e.target.value })} /></label>
|
||||
<div className="ins-links">
|
||||
<div>Next: {(childCount.get(selected.id) || 0) > 0 ? `${childCount.get(selected.id)} ${(childCount.get(selected.id) || 0) >= 2 ? 'options' : 'line'}` : selected.terminalId ? `⇥ ${terminals.find(t => t.id === selected.terminalId)?.label || 'exit'}` : '— none —'}
|
||||
{selected.terminalId && <button className="ins-unwire" onClick={() => link(selected.id, { terminalId: null }, { terminalId: selected.terminalId })}>⊘</button>}</div>
|
||||
{selected.parentUtteranceId && <div>Follows another utterance <button className="ins-unwire" onClick={() => link(selected.id, { parentUtteranceId: null }, { parentUtteranceId: selected.parentUtteranceId })}>⊘</button></div>}
|
||||
</div>
|
||||
<button className="ins-delete" onClick={async () => { if (!window.confirm('Delete utterance?')) return; try { await api(`/api/admin/utterances/${selected.id}`, 'DELETE'); setSelectedId(null); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}>Delete utterance</button>
|
||||
</div>
|
||||
</aside>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
Reference in New Issue
Block a user