From cd4b8bf4fa88c30e4488caec8058b9992527d89f Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Sat, 22 Aug 2026 15:43:08 +0200 Subject: [PATCH 1/5] Env-driven dev ports + splash as the front door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vite.config.ts now reads WEB_PORT (Vite) and PORT (API) from .env via loadEnv, with the /api proxy following PORT and strictPort on — so two branch checkouts can each run `npm run dev` on their own ports. Play now always lands on the splash at the bare root instead of silently resuming an active playthrough; a Resume action continues one when present. Co-Authored-By: Claude Opus 4.8 --- .env.example | 9 +++++++++ src/play.tsx | 10 +++++++--- vite.config.ts | 23 ++++++++++++++++------- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 82e96e7..bef86de 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,18 @@ DATABASE_URL=postgres://osint:osint_secret@localhost:5433/osint_dev + +# Dev ports — give each branch checkout distinct values to run them side by side. +# PORT is the Express API; WEB_PORT is the Vite dev server, which proxies /api to PORT. PORT=8787 +WEB_PORT=5173 CORS_ORIGIN=http://localhost:5173 LEVEL_EDITING_ENABLED=true JWT_SECRET=osint-local-dev-secret MAX_DOCUMENT_BYTES=26214400 +OCR_ENABLED=true +OCR_LANGUAGES=nor+eng +OCR_TIMEOUT_MS=20000 +MAX_OCR_BYTES=15728640 +MAX_EXTRACTED_TEXT_CHARACTERS=200000 # Game asset storage (MinIO). Start it with: docker compose -f docker-compose.dev.yml up -d minio createbuckets S3_ENDPOINT=http://localhost:9000 diff --git a/src/play.tsx b/src/play.tsx index f824354..ac628a5 100644 --- a/src/play.tsx +++ b/src/play.tsx @@ -7,6 +7,7 @@ import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState } fro // board route (/level/), which App renders. export function Play() { const [state, setState] = useState(null) + const [resumable, setResumable] = useState(null) const [splash, setSplash] = useState(false) const [busy, setBusy] = useState(false) const [status, setStatus] = useState('') @@ -37,10 +38,13 @@ export function Play() { if (!cancelled) res.ok ? apply(await res.json()) : setStatus('NODE NOT FOUND') return } + // The bare root is always the front door: show the splash, and offer Resume + // when a playthrough is already in progress rather than auto-resuming into it. const res = await fetch('/api/playthroughs/current') if (cancelled) return - if (res.status === 204) { setSplash(true); setStatus('AWAITING PRINCIPAL INVESTIGATOR'); return } - res.ok ? apply(await res.json()) : setSplash(true) + if (res.ok && res.status !== 204) setResumable(await res.json()) + setSplash(true) + setStatus('AWAITING PRINCIPAL INVESTIGATOR') })() return () => { cancelled = true } }, [apply]) @@ -62,7 +66,7 @@ export function Play() { if (res.ok) apply(await res.json()) } - if (splash) return {}} /> + if (splash) return { if (resumable) apply(resumable) }} /> const node = state?.node if (!node) return
GU

GLITCH UNIVERSITY NETWORK TERMINAL

{status || 'OPENING CASE FILE…'}
diff --git a/vite.config.ts b/vite.config.ts index 254ce69..bc61653 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,19 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' -export default defineConfig({ - plugins: [react()], - server: { - port: 5173, - proxy: { '/api': 'http://127.0.0.1:8787' }, - }, +// Ports are read from .env so two branch checkouts can run `npm run dev` side by +// side: give each folder its own WEB_PORT (Vite) and PORT (API). The /api proxy +// follows PORT so the web server always talks to its own backend. +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const webPort = Number(env.WEB_PORT || 5173) + const apiPort = Number(env.PORT || 8787) + return { + plugins: [react()], + server: { + port: webPort, + strictPort: true, // fail loudly on a clash instead of silently picking another port + proxy: { '/api': `http://127.0.0.1:${apiPort}` }, + }, + } }) From 5c514562a20c0b79938ca91c3ef1c835efa2fc8a Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Sat, 22 Aug 2026 15:51:32 +0200 Subject: [PATCH 2/5] Splash case picker + playable-mystery list GET /api/mysteries returns only mysteries with an entrypoint (empties are filtered out), and the splash now lists them as case files with a per-case BEGIN / RESUME action instead of a single hardcoded New Game. Co-Authored-By: Claude Opus 4.8 --- server/index.ts | 6 +++++ server/narrativeRepository.ts | 9 ++++++++ src/play.tsx | 42 ++++++++++++++++++++++++++--------- src/styles.css | 9 ++++++++ 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/server/index.ts b/server/index.ts index 8b82ad5..e7d0814 100644 --- a/server/index.ts +++ b/server/index.ts @@ -373,6 +373,12 @@ app.delete('/api/admin/utterances/:id', requireAdmin, async (req, res, next) => } catch (error) { next(error) } }) +// Play mode: the launchable case list for the splash picker (entrypoint required). +app.get('/api/mysteries', async (_req, res, next) => { + try { res.json(await narrative.listPlayableMysteries()) } + 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) => { diff --git a/server/narrativeRepository.ts b/server/narrativeRepository.ts index 6d278cb..16c13e6 100644 --- a/server/narrativeRepository.ts +++ b/server/narrativeRepository.ts @@ -51,6 +51,7 @@ export interface NarrativeRepository { awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }> gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }> listMysteries(): Promise + listPlayableMysteries(): Promise<{ slug: string; title: string }[]> deleteMystery(id: string): Promise uploadAsset(file: UploadedFile): Promise listAssets(): Promise @@ -304,6 +305,14 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora return { ok: true, state: state ?? undefined } }, + // Play mode: only mysteries with an entrypoint are launchable (this filters out + // half-authored, empty ones). Returns the minimum the case picker needs. + async listPlayableMysteries() { + const result = await pool.query<{ slug: string; title: string }>( + 'SELECT slug,title FROM osint.mysteries WHERE entry_node_id IS NOT NULL ORDER BY title') + return result.rows.map(row => ({ slug: row.slug, title: row.title })) + }, + 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 diff --git a/src/play.tsx b/src/play.tsx index ac628a5..bbede76 100644 --- a/src/play.tsx +++ b/src/play.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useState } from 'react' -import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState } from './narrative' +import { CutsceneHost, DialoguePlayer, type PlaythroughState } from './narrative' + +type MysterySummary = { slug: string; title: string } // The game's front door and campaign runtime. Shows the splash when there's no // active playthrough; otherwise walks the story graph — cutscene and dialogue @@ -8,6 +10,7 @@ import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState } fro export function Play() { const [state, setState] = useState(null) const [resumable, setResumable] = useState(null) + const [mysteries, setMysteries] = useState([]) const [splash, setSplash] = useState(false) const [busy, setBusy] = useState(false) const [status, setStatus] = useState('') @@ -38,23 +41,24 @@ export function Play() { if (!cancelled) res.ok ? apply(await res.json()) : setStatus('NODE NOT FOUND') return } - // The bare root is always the front door: show the splash, and offer Resume - // when a playthrough is already in progress rather than auto-resuming into it. - const res = await fetch('/api/playthroughs/current') + // The bare root is always the front door: list the playable cases, and offer + // Resume on the one already in progress rather than auto-resuming into it. + const [cases, current] = await Promise.all([fetch('/api/mysteries'), fetch('/api/playthroughs/current')]) if (cancelled) return - if (res.ok && res.status !== 204) setResumable(await res.json()) + if (cases.ok) setMysteries(await cases.json()) + if (current.ok && current.status !== 204) setResumable(await current.json()) setSplash(true) - setStatus('AWAITING PRINCIPAL INVESTIGATOR') + setStatus('SELECT A CASE FILE') })() return () => { cancelled = true } }, [apply]) - const newGame = async () => { + const newGame = async (slug: string) => { setBusy(true) try { - const res = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: 'glass-harbor' }) }) + const res = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: slug }) }) if (res.ok) apply(await res.json()) - else setStatus('NO MYSTERY AVAILABLE') + else setStatus('COULD NOT OPEN CASE') } finally { setBusy(false) } } @@ -66,7 +70,25 @@ export function Play() { if (res.ok) apply(await res.json()) } - if (splash) return { if (resumable) apply(resumable) }} /> + if (splash) return
+
+
GU
+

PRINCIPAL INVESTIGATOR

+

Glitch University · Case Files

+
+ {mysteries.map(mystery => { + const canResume = resumable?.playthrough.mysterySlug === mystery.slug + return + })} + {!mysteries.length &&

NO CASE FILES AVAILABLE

} +
+ {busy ? 'OPENING CASE FILE…' : status} +
+
const node = state?.node if (!node) return
GU

GLITCH UNIVERSITY NETWORK TERMINAL

{status || 'OPENING CASE FILE…'}
diff --git a/src/styles.css b/src/styles.css index bdc46d1..d5e8fb9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -648,3 +648,12 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .phone-dev-btn { border: 1px dashed #6f8f85; background: #0a1a16cc; color: #9fd020; font-family: ui-monospace, monospace; font-size: 11px; letter-spacing: 1px; padding: 5px 12px; cursor: pointer; } .phone-dev-btn:disabled { color: #5f7b73; border-style: solid; cursor: default; } .phone-dev-btn:not(:disabled):hover { border-color: #cdea6a; color: #cdea6a; } + +/* Splash case picker */ +.splash-cases { display: flex; flex-direction: column; gap: 10px; width: 100%; margin: 20px 0 8px; } +.splash-case { display: flex; align-items: center; justify-content: space-between; gap: 14px; width: 100%; padding: 12px 16px; + border: 1px solid #3c5a52; background: #0a211d; color: #cfe8df; cursor: pointer; text-align: left; font: inherit; } +.splash-case:hover:not(:disabled) { border-color: #6f8f85; background: #0d2a24; } +.splash-case:disabled { opacity: .5; cursor: default; } +.splash-case-title { font-weight: 600; letter-spacing: .5px; } +.splash-case-action { color: #d58a46; font-size: 12px; letter-spacing: 1px; white-space: nowrap; } From 778c6d972fbde40401a7a204e8a634ad6c093c9b Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Sat, 22 Aug 2026 16:11:53 +0200 Subject: [PATCH 3/5] Add merit node type (Scene 8 ceremony + achievement award) A merit node awards a configured achievement (awards_flag) to the player the moment the playthrough arrives on it, with node provenance, then presents a ceremony. Migration 028 extends node_type + the component_key CHECK and adds awards_flag; the runtime grants the flag in advance/goto/ new-game write paths; MeritHost renders the ceremony (a component_key can supply a bespoke one, e.g. a 3D model). Verified: teleport to a merit node auto-grants its achievement. Co-Authored-By: Claude Opus 4.8 --- migrations/028_merit_nodes.sql | 18 ++++++++++++++++++ server/index.ts | 2 +- server/narrativeRepository.ts | 24 ++++++++++++++++++------ server/storyGraphRepository.ts | 12 ++++++++---- src/narrative.tsx | 21 ++++++++++++++++++++- src/play.tsx | 3 ++- src/styles.css | 3 +++ 7 files changed, 70 insertions(+), 13 deletions(-) create mode 100644 migrations/028_merit_nodes.sql diff --git a/migrations/028_merit_nodes.sql b/migrations/028_merit_nodes.sql new file mode 100644 index 0000000..647530a --- /dev/null +++ b/migrations/028_merit_nodes.sql @@ -0,0 +1,18 @@ +-- Merit nodes: a ceremony node that awards an achievement when the playthrough +-- reaches it (Scene 8 — "The Barricelli Luggage"). The awarded flag is authored on +-- the node; the runtime grants it on arrival with node provenance. Merit nodes may +-- also carry a component_key for their ceremony presentation (e.g. a 3D model). + +ALTER TABLE osint.story_nodes DROP CONSTRAINT story_nodes_node_type_check; +ALTER TABLE osint.story_nodes ADD CONSTRAINT story_nodes_node_type_check + CHECK (node_type IN ('cutscene','dialogue','level','det_gate','llm_gate','merit')); + +-- Let a merit node carry a ceremony component_key (was cutscene/gate only). +ALTER TABLE osint.story_nodes DROP CONSTRAINT story_nodes_check1; +ALTER TABLE osint.story_nodes ADD CONSTRAINT story_nodes_check1 + CHECK (component_key IS NULL OR node_type IN ('cutscene','det_gate','llm_gate','merit')); + +ALTER TABLE osint.story_nodes ADD COLUMN awards_flag TEXT + CHECK (awards_flag IS NULL OR awards_flag ~ '^[a-z][a-z0-9_.-]{0,63}$'); +ALTER TABLE osint.story_nodes ADD CONSTRAINT story_nodes_awards_flag_type + CHECK (awards_flag IS NULL OR node_type = 'merit'); diff --git a/server/index.ts b/server/index.ts index e7d0814..f66b647 100644 --- a/server/index.ts +++ b/server/index.ts @@ -30,7 +30,7 @@ const textExtractor = createTextExtractorFromEnv() 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'] +const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate', 'merit'] function wantsEdit(req: express.Request) { return editingEnabled && req.query.edit === '1' && hasAdminClaim(req) diff --git a/server/narrativeRepository.ts b/server/narrativeRepository.ts index 16c13e6..dd21c53 100644 --- a/server/narrativeRepository.ts +++ b/server/narrativeRepository.ts @@ -15,8 +15,9 @@ export type RuntimeUtterance = { poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null } export type RuntimeNode = { - id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string + id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number + awardsFlag?: string | null utterances?: RuntimeUtterance[]; rootId?: string | null } export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null } @@ -64,7 +65,14 @@ export interface NarrativeRepository { deletePose(npcId: string, poseKey: string): Promise } -type GraphNodeRow = { id: string; node_type: string; label: string; component_key: string | null; level_template_version_id: string | null; music_asset_id: string | null; music_volume: number } +type GraphNodeRow = { id: string; node_type: string; label: string; component_key: string | null; level_template_version_id: string | null; music_asset_id: string | null; music_volume: number; awards_flag?: string | null } + +// Grant a merit node's achievement to the player on arrival (idempotent, with node +// provenance). Called from the write paths that move current_node_id onto a node. +async function awardMeritWithin(client: PoolClient, playthroughId: string, node: { id: string; node_type: string; awards_flag?: string | null }) { + if (node.node_type !== 'merit' || !node.awards_flag) return + await client.query('INSERT INTO osint.achievements (playthrough_id,flag_key,awarded_by_node_id) VALUES ($1,$2,$3) ON CONFLICT (playthrough_id,flag_key) DO NOTHING', [playthroughId, node.awards_flag, node.id]) +} export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStorage): NarrativeRepository { // ---- Runtime: walking the story graph ------------------------------------- @@ -101,13 +109,14 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora } async function resolveNodeForPlay(nodeId: string, levelSlug: string | null): Promise { - const node = (await pool.query('SELECT id,node_type,label,component_key,level_template_version_id,music_asset_id,music_volume FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0] + const node = (await pool.query('SELECT id,node_type,label,component_key,level_template_version_id,music_asset_id,music_volume,awards_flag FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0] if (!node) return null const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null const musicVolume = node.music_volume / 100 if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key, musicUrl, musicVolume } if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug, musicUrl, musicVolume } if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, musicVolume, ...(await resolveDialogueGraph(node.id)) } + if (node.node_type === 'merit') return { id: node.id, kind: 'merit', label: node.label, componentKey: node.component_key, awardsFlag: node.awards_flag, musicUrl, musicVolume } return null // gates are auto-resolved during advance and never surfaced } @@ -115,7 +124,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora async function resolveThroughGates(client: PoolClient, nodeId: string | null): Promise { let current = nodeId for (let guard = 0; guard < 50 && current; guard++) { - const node = (await client.query('SELECT id,node_type,label,component_key,level_template_version_id FROM osint.story_nodes WHERE id=$1', [current])).rows[0] + const node = (await client.query('SELECT id,node_type,label,component_key,level_template_version_id,awards_flag 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]) @@ -224,6 +233,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora 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 awardMeritWithin(client, playthroughId, entry) await client.query('COMMIT') } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } return stateForPlaythrough(playthroughId) @@ -263,12 +273,13 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora `SELECT p.mystery_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 FOR UPDATE OF p`, [playthroughId, userId])).rows[0] if (!playthrough) { await client.query('ROLLBACK'); return { ok: false, error: 'Playthrough not found' } } - const node = (await client.query<{ id: string; node_type: string; level_template_version_id: string | null }>( - 'SELECT id,node_type,level_template_version_id FROM osint.story_nodes WHERE id=$1 AND mystery_id=$2', [nodeId, playthrough.mystery_id])).rows[0] + const node = (await client.query<{ id: string; node_type: string; level_template_version_id: string | null; awards_flag: string | null }>( + 'SELECT id,node_type,level_template_version_id,awards_flag FROM osint.story_nodes WHERE id=$1 AND mystery_id=$2', [nodeId, playthrough.mystery_id])).rows[0] if (!node) { await client.query('ROLLBACK'); return { ok: false, error: 'Node not found' } } const levelId = node.node_type === 'level' && node.level_template_version_id ? await instantiateLevel(client, node.level_template_version_id, playthrough.mystery_slug) : null await client.query(`UPDATE osint.playthroughs SET status='active',current_node_id=$2,current_level_id=$3,updated_at=NOW() WHERE id=$1`, [playthroughId, node.id, levelId]) + await awardMeritWithin(client, playthroughId, node) await client.query('COMMIT') } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } const state = await stateForPlaythrough(playthroughId) @@ -298,6 +309,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora 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 awardMeritWithin(client, playthroughId, target) } await client.query('COMMIT') } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } diff --git a/server/storyGraphRepository.ts b/server/storyGraphRepository.ts index 9c3d4f7..bfc74c1 100644 --- a/server/storyGraphRepository.ts +++ b/server/storyGraphRepository.ts @@ -9,11 +9,12 @@ export type GraphSpecNode = { } export type GraphSpec = { entry: string; nodes: GraphSpecNode[] } -export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' +export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' | 'merit' 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; musicAssetId: string | null; musicVolume: number + awardsFlag: string | null terminals: TerminalDto[] } export type StoryGraphDto = { mysteryId: string; entryNodeId: string | null; nodes: StoryNodeDto[] } @@ -32,12 +33,13 @@ const DEFAULT_TERMINALS: Record level: [{ key: 'report_back', label: 'Report back' }], det_gate: [{ key: 'pass', label: 'Pass' }], llm_gate: [{ key: 'pass', label: 'Pass' }], + merit: [{ key: 'continue', label: 'Continue' }], } export interface StoryGraphRepository { getGraph(mysteryId: string): Promise createNode(mysteryId: string, input: { nodeType: StoryNodeType; xpos: number; ypos: number; label?: string }): Promise - updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null; musicAssetId: string | null; musicVolume: number }>): Promise + updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null; musicAssetId: string | null; musicVolume: number; awardsFlag: string | null }>): Promise deleteNode(nodeId: string): Promise addTerminal(nodeId: string, input: { terminalKey: string; label?: string }): Promise updateTerminal(terminalId: string, input: Partial<{ label: string; sortOrder: number; toNodeId: string | null }>): Promise<{ ok: boolean; error?: string }> @@ -61,8 +63,8 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository { 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; music_asset_id: string | null; music_volume: number }>( - 'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key,music_asset_id,music_volume FROM osint.story_nodes WHERE mystery_id=$1 ORDER BY created_at', [mysteryId]), + 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; music_asset_id: string | null; music_volume: number; awards_flag: string | null }>( + 'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key,music_asset_id,music_volume,awards_flag 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]), @@ -78,6 +80,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository { 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, musicAssetId: row.music_asset_id, musicVolume: row.music_volume, + awardsFlag: row.awards_flag, terminals: byNode.get(row.id) || [], })), } @@ -119,6 +122,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository { if (input.levelTemplateVersionId !== undefined) set('level_template_version_id', input.levelTemplateVersionId || null) if (input.musicAssetId !== undefined) set('music_asset_id', input.musicAssetId || null) if (input.musicVolume !== undefined) set('music_volume', Math.max(0, Math.min(100, Math.round(input.musicVolume)))) + if (input.awardsFlag !== undefined) set('awards_flag', input.awardsFlag?.trim() || 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 diff --git a/src/narrative.tsx b/src/narrative.tsx index 86dafa1..1557f9f 100644 --- a/src/narrative.tsx +++ b/src/narrative.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState, type FC } from 'react' import { audio } from './audio' 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; musicUrl?: string | null; musicVolume?: number; utterances?: RuntimeUtterance[]; rootId?: string | null } +export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number; awardsFlag?: 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 } @@ -48,6 +48,25 @@ const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) = const CUTSCENE_REGISTRY: Record void }>> = { 'glass-harbour-diversion': GlassHarbourDiversion } export const CUTSCENE_COMPONENT_KEYS = Object.keys(CUTSCENE_REGISTRY) +// Merit ceremony components, keyed by a merit node's component_key (e.g. a 3D +// award model). The achievement itself is granted server-side on arrival; this is +// purely the presentation of receiving it. +const MERIT_REGISTRY: Record void }>> = {} +export const MERIT_COMPONENT_KEYS = Object.keys(MERIT_REGISTRY) + +export function MeritHost({ componentKey, label, awardsFlag, onComplete }: { componentKey: string | null | undefined; label: string; awardsFlag?: string | null; onComplete: () => void }) { + const Component = componentKey ? MERIT_REGISTRY[componentKey] : undefined + if (Component) return + return
+
+ ◆ MERIT AWARDED ◆ +

{label}

+ {awardsFlag &&

🏅 {awardsFlag}

} + +
+
+} + export function CutsceneHost({ componentKey, label, onComplete }: { componentKey: string | null | undefined; label: string; onComplete: () => void }) { const Component = componentKey ? CUTSCENE_REGISTRY[componentKey] : undefined if (Component) return diff --git a/src/play.tsx b/src/play.tsx index bbede76..c074154 100644 --- a/src/play.tsx +++ b/src/play.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react' -import { CutsceneHost, DialoguePlayer, type PlaythroughState } from './narrative' +import { CutsceneHost, DialoguePlayer, MeritHost, type PlaythroughState } from './narrative' type MysterySummary = { slug: string; title: string } @@ -93,6 +93,7 @@ export function Play() { const node = state?.node if (!node) return
GU

GLITCH UNIVERSITY NETWORK TERMINAL

{status || 'OPENING CASE FILE…'}
if (node.kind === 'cutscene') return { void advance() }} /> + if (node.kind === 'merit') return { void advance() }} /> if (node.kind === 'dialogue' && node.utterances) return { void advance(terminalKey) }} /> return
GU
{node.label}
} diff --git a/src/styles.css b/src/styles.css index d5e8fb9..3a3e27a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -520,6 +520,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } /* 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; } +.merit-card { background: radial-gradient(120% 90% at 50% 35%, #16240f 0%, #0a1408 60%, #04110e 100%); } +.merit-card .merit-eyebrow { color: #cdea6a; } +.merit-flag { font: 13px IBM Plex Mono; letter-spacing: .12em; color: #d58a46; margin: 0; } .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; } From a925d61b7e7c8547f085147765b2bfe4112b8ee0 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Sat, 22 Aug 2026 16:55:45 +0200 Subject: [PATCH 4/5] =?UTF-8?q?Phone=20directory=20foundation:=20NPC=20con?= =?UTF-8?q?tacts=20+=20phone=20node=20+=20terminal=E2=86=92NPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NPCs gain phone_number and email (the phone book). A new 'phone' node type holds a directory whose terminals each bind to an NPC via npc_id (the callee reached by dialing that number) and wire to the dialogue that plays on connect. Migration 029 adds the columns/type; repositories and admin API thread contacts and the per-terminal NPC binding. Authoring foundation only — the dial runtime and tools sidebar come next. Co-Authored-By: Claude Opus 4.8 --- migrations/029_phone_directory.sql | 12 ++++++++++++ server/index.ts | 6 +++--- server/narrativeRepository.ts | 19 +++++++++++-------- server/storyGraphRepository.ts | 14 ++++++++------ 4 files changed, 34 insertions(+), 17 deletions(-) create mode 100644 migrations/029_phone_directory.sql diff --git a/migrations/029_phone_directory.sql b/migrations/029_phone_directory.sql new file mode 100644 index 0000000..23c671f --- /dev/null +++ b/migrations/029_phone_directory.sql @@ -0,0 +1,12 @@ +-- Phone book + phone directory nodes. NPCs gain contact details; a phone node's +-- terminals each bind to an NPC (the callee) whose number the player dials. + +ALTER TABLE osint.npcs ADD COLUMN phone_number TEXT; +ALTER TABLE osint.npcs ADD COLUMN email TEXT; + +ALTER TABLE osint.story_nodes DROP CONSTRAINT story_nodes_node_type_check; +ALTER TABLE osint.story_nodes ADD CONSTRAINT story_nodes_node_type_check + CHECK (node_type IN ('cutscene','dialogue','level','det_gate','llm_gate','merit','phone')); + +-- A terminal on a phone node binds to the NPC you reach by dialing their number. +ALTER TABLE osint.story_node_terminals ADD COLUMN npc_id UUID REFERENCES osint.npcs(id) ON DELETE SET NULL; diff --git a/server/index.ts b/server/index.ts index f66b647..8a6d585 100644 --- a/server/index.ts +++ b/server/index.ts @@ -30,7 +30,7 @@ const textExtractor = createTextExtractorFromEnv() 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', 'merit'] +const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate', 'merit', 'phone'] function wantsEdit(req: express.Request) { return editingEnabled && req.query.edit === '1' && hasAdminClaim(req) @@ -236,13 +236,13 @@ 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 })) + 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, phoneNumber: req.body.phoneNumber ?? null, email: req.body.email ?? 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 }) + const npc = await narrative.updateNpc(String(req.params.id), { name: req.body?.name, role: req.body?.role, defaultPose: req.body?.defaultPose, phoneNumber: req.body?.phoneNumber, email: req.body?.email }) npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' }) } catch (error) { next(error) } }) diff --git a/server/narrativeRepository.ts b/server/narrativeRepository.ts index dd21c53..1dfbebd 100644 --- a/server/narrativeRepository.ts +++ b/server/narrativeRepository.ts @@ -6,7 +6,7 @@ import type { ObjectStorage } from './objectStorage.js' export type UploadedFile = { buffer: Buffer; originalname: string; mimetype: string; size: number } export type AssetDto = { id: string; originalName: string; mimeType: string; byteSize: number; url: string } 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 NpcDto = { id: string; key: string; name: string; role: string; defaultPose: string | null; phoneNumber: string | null; email: 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' } @@ -58,8 +58,8 @@ export interface NarrativeRepository { listAssets(): Promise deleteAsset(id: string): Promise<'deleted' | 'in_use' | 'not_found'> listNpcs(): Promise - createNpc(input: { key: string; name: string; role?: string; defaultPose?: string | null }): Promise - updateNpc(id: string, input: { name?: string; role?: string; defaultPose?: string | null }): Promise + createNpc(input: { key: string; name: string; role?: string; defaultPose?: string | null; phoneNumber?: string | null; email?: string | null }): Promise + updateNpc(id: string, input: { name?: string; role?: string; defaultPose?: string | null; phoneNumber?: string | null; email?: string | null }): Promise deleteNpc(id: string): Promise<'deleted' | 'in_use' | 'not_found'> addPose(npcId: string, poseKey: string, file: UploadedFile): Promise deletePose(npcId: string, poseKey: string): Promise @@ -173,8 +173,8 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora } async function loadNpc(id: string): Promise { - 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] + const npc = (await pool.query<{ id: string; npc_key: string; name: string; role: string; default_pose_key: string | null; phone_number: string | null; email: string | null }>( + 'SELECT id,npc_key,name,role,default_pose_key,phone_number,email 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]), @@ -182,6 +182,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora ]) return { id: npc.id, key: npc.npc_key, name: npc.name, role: npc.role, defaultPose: npc.default_pose_key, + phoneNumber: npc.phone_number, email: npc.email, 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, } @@ -372,17 +373,19 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora 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]) + await pool.query('INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key,phone_number,email) VALUES ($1,NULL,$2,$3,$4,$5,$6,$7)', + [id, key, input.name.trim() || key, input.role?.trim() || '', input.defaultPose || null, input.phoneNumber?.trim() || null, input.email?.trim() || 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', [ + await pool.query('UPDATE osint.npcs SET name=$2,role=$3,default_pose_key=$4,phone_number=$5,email=$6 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), + input.phoneNumber === undefined ? existing.phoneNumber : (input.phoneNumber?.trim() || null), + input.email === undefined ? existing.email : (input.email?.trim() || null), ]) return loadNpc(id) }, diff --git a/server/storyGraphRepository.ts b/server/storyGraphRepository.ts index bfc74c1..809885f 100644 --- a/server/storyGraphRepository.ts +++ b/server/storyGraphRepository.ts @@ -9,8 +9,8 @@ export type GraphSpecNode = { } export type GraphSpec = { entry: string; nodes: GraphSpecNode[] } -export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' | 'merit' -export type TerminalDto = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number } +export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' | 'merit' | 'phone' +export type TerminalDto = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number; npcId: string | null } export type StoryNodeDto = { id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null; musicVolume: number @@ -34,6 +34,7 @@ const DEFAULT_TERMINALS: Record det_gate: [{ key: 'pass', label: 'Pass' }], llm_gate: [{ key: 'pass', label: 'Pass' }], merit: [{ key: 'continue', label: 'Continue' }], + phone: [], // a phone node's terminals are added per contact (each bound to an NPC) } export interface StoryGraphRepository { @@ -42,7 +43,7 @@ export interface StoryGraphRepository { updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null; musicAssetId: string | null; musicVolume: number; awardsFlag: string | null }>): Promise deleteNode(nodeId: string): Promise addTerminal(nodeId: string, input: { terminalKey: string; label?: string }): Promise - updateTerminal(terminalId: string, input: Partial<{ label: string; sortOrder: number; toNodeId: string | null }>): Promise<{ ok: boolean; error?: string }> + updateTerminal(terminalId: string, input: Partial<{ label: string; sortOrder: number; toNodeId: string | null; npcId: string | null }>): Promise<{ ok: boolean; error?: string }> deleteTerminal(terminalId: string): Promise setEntryNode(mysteryId: string, nodeId: string | null): Promise<{ ok: boolean; error?: string }> listLevelTemplates(): Promise @@ -65,14 +66,14 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository { 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; music_asset_id: string | null; music_volume: number; awards_flag: string | null }>( 'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key,music_asset_id,music_volume,awards_flag 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 + pool.query<{ id: string; parent_node_id: string; terminal_key: string; label: string; to_node_id: string | null; sort_order: number; npc_id: string | null }>( + `SELECT t.id,t.parent_node_id,t.terminal_key,t.label,t.to_node_id,t.sort_order,t.npc_id 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() 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 }) + list.push({ id: row.id, terminalKey: row.terminal_key, label: row.label, toNodeId: row.to_node_id, sortOrder: row.sort_order, npcId: row.npc_id }) byNode.set(row.parent_node_id, list) } return { @@ -158,6 +159,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository { 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 (input.npcId !== undefined) set('npc_id', input.npcId || null) if (sets.length) await pool.query(`UPDATE osint.story_node_terminals SET ${sets.join(',')} WHERE id=$1`, values) return { ok: true } }, From bbf34bab09969f65f42945f74e5443bd6235f6c0 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Sat, 22 Aug 2026 17:16:48 +0200 Subject: [PATCH 5/5] Phone/merit authoring UI: NPC contacts + phone node + terminal NPC picker Admin NPC editor gains phone number and email fields. The mystery graph editor gets Phone and Merit in the node palette; a phone node's terminals each get an NPC picker (bind the callee you reach by dialing their number), and merit nodes get an "awards achievement" field. Node summaries + type colors for both. Co-Authored-By: Claude Opus 4.8 --- src/admin.tsx | 9 +++++++-- src/mysteryGraph.tsx | 28 +++++++++++++++++++++------- src/styles.css | 3 +++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/admin.tsx b/src/admin.tsx index 68cb633..54dac0d 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -2,7 +2,7 @@ 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 Npc = { id: string; key: string; name: string; role: string; defaultPose: string | null; phoneNumber: string | null; email: string | null; poses: Pose[]; inUse: boolean } type Mystery = { id: string; slug: string; title: string; nodes: number } async function json(url: string, init?: RequestInit): Promise { @@ -172,15 +172,18 @@ function NpcEditor({ npc, onChanged, setStatus }: { npc: Npc; onChanged: (key?: const [name, setName] = useState(npc.name) const [role, setRole] = useState(npc.role) const [defaultPose, setDefaultPose] = useState(npc.defaultPose || '') + const [phoneNumber, setPhoneNumber] = useState(npc.phoneNumber || '') + const [email, setEmail] = useState(npc.email || '') const [poseKey, setPoseKey] = useState('') const [busy, setBusy] = useState(false) const fileRef = useRef(null) const dirty = name !== npc.name || role !== npc.role || (defaultPose || null) !== npc.defaultPose + || (phoneNumber || null) !== npc.phoneNumber || (email || null) !== npc.email 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 json(`/api/admin/npcs/${npc.id}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, role, defaultPose: defaultPose || null, phoneNumber: phoneNumber || null, email: email || null }) }) await onChanged(npc.key); setStatus(`Saved ${name}`) } catch (error) { setStatus(String((error as Error).message || error)) } finally { setBusy(false) } } @@ -213,6 +216,8 @@ function NpcEditor({ npc, onChanged, setStatus }: { npc: Npc; onChanged: (key?:
setName(event.target.value)} />
setRole(event.target.value)} placeholder="Glitch University · Investigative Method" />
+
setPhoneNumber(event.target.value)} placeholder="55501" />
+
setEmail(event.target.value)} placeholder="hunter@glitch.university" />
setLabel(e.target.value)} onBlur={() => label !== node.label && onPatch({ label })} /> @@ -197,6 +204,9 @@ function NodeInspector({ node, graph, templates, audioAssets, onPatch, onSetEntr setComponentKey(e.target.value)} onBlur={() => componentKey !== (node.componentKey || '') && onPatch({ componentKey })} /> {node.nodeType === 'cutscene' && {CUTSCENE_COMPONENT_KEYS.map(k => } } + {node.nodeType === 'merit' && } + {node.nodeType === 'phone' &&

Add a terminal per contact, pick the NPC you reach by dialing their number, then wire it to the dialogue that answers.

} {(node.nodeType === 'dialogue' || node.nodeType === 'cutscene') && } {(node.nodeType === 'dialogue' || node.hasUtterances) && }