Merge main into Scene 7 evidence workflow

This commit is contained in:
2026-08-22 17:23:47 +02:00
16 changed files with 210 additions and 51 deletions
+7 -2
View File
@@ -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<T>(url: string, init?: RequestInit): Promise<T> {
@@ -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<HTMLInputElement>(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?:
</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>Phone number</label><input value={phoneNumber} onChange={event => setPhoneNumber(event.target.value)} placeholder="55501" /></div>
<div className="admin-field"><label>Email</label><input value={email} onChange={event => setEmail(event.target.value)} placeholder="hunter@glitch.university" /></div>
<div className="admin-field"><label>Default pose</label>
<select value={defaultPose} onChange={event => setDefaultPose(event.target.value)}>
<option value=""> none </option>
+21 -7
View File
@@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { UtteranceCanvas } from './utteranceCanvas'
import { CUTSCENE_COMPONENT_KEYS, DialoguePreview } from './narrative'
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; musicAssetId: string | null; musicVolume: number; terminals: Terminal[] }
type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' | 'merit' | 'phone'
type Terminal = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number; npcId: string | null }
type StoryNode = { 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: Terminal[] }
type AudioAsset = { id: string; originalName: string; mimeType: string }
type Npc = { id: string; name: string; phoneNumber: string | null }
type Graph = { mysteryId: string; entryNodeId: string | null; nodes: StoryNode[] }
type LevelTemplate = { versionId: string; slug: string; name: string; version: number }
@@ -14,6 +15,7 @@ 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' },
{ type: 'phone', label: 'Phone' }, { type: 'merit', label: 'Merit' },
]
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 })
@@ -28,6 +30,7 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
const [graph, setGraph] = useState<Graph | null>(null)
const [templates, setTemplates] = useState<LevelTemplate[]>([])
const [audioAssets, setAudioAssets] = useState<AudioAsset[]>([])
const [npcs, setNpcs] = useState<Npc[]>([])
const [view, setView] = useState({ x: 60, y: 60, zoom: 1 })
const [selectedId, setSelectedId] = useState<string | null>(null)
const [wiringFrom, setWiringFrom] = useState<string | null>(null)
@@ -43,6 +46,7 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
void reload()
api<LevelTemplate[]>('/api/admin/level-templates', 'GET').then(setTemplates).catch(() => {})
api<AudioAsset[]>('/api/admin/assets', 'GET').then(list => setAudioAssets(list.filter(a => a.mimeType.startsWith('audio/')))).catch(() => {})
api<Npc[]>('/api/admin/npcs', 'GET').then(setNpcs).catch(() => {})
}, [reload])
const centerInBoard = () => {
@@ -155,7 +159,7 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
</div>
{selected && <aside className="graph-inspector">
<NodeInspector key={selected.id} node={selected} graph={graph} templates={templates} audioAssets={audioAssets}
<NodeInspector key={selected.id} node={selected} graph={graph} templates={templates} audioAssets={audioAssets} npcs={npcs}
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)) } }}
@@ -170,21 +174,24 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
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 === 'merit') return node.awardsFlag ? `🏅 ${node.awardsFlag}` : '⚠ no achievement'
if (node.nodeType === 'phone') return `${node.terminals.length} contact${node.terminals.length === 1 ? '' : 's'}`
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, audioAssets, onPatch, onSetEntry, onDelete, onAddTerminal, onTerminalPatch, onTerminalDelete, onEditUtterances }: {
node: StoryNode; graph: Graph; templates: LevelTemplate[]; audioAssets: AudioAsset[]
function NodeInspector({ node, graph, templates, audioAssets, npcs, onPatch, onSetEntry, onDelete, onAddTerminal, onTerminalPatch, onTerminalDelete, onEditUtterances }: {
node: StoryNode; graph: Graph; templates: LevelTemplate[]; audioAssets: AudioAsset[]; npcs: Npc[]
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 [awardsFlag, setAwardsFlag] = useState(node.awardsFlag || '')
const [volume, setVolume] = useState(node.musicVolume)
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'
const usesComponent = node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate' || node.nodeType === 'merit'
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>
@@ -197,6 +204,9 @@ function NodeInspector({ node, graph, templates, audioAssets, onPatch, onSetEntr
<input list={node.nodeType === 'cutscene' ? 'cutscene-components' : undefined} 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 })} />
{node.nodeType === 'cutscene' && <datalist id="cutscene-components">{CUTSCENE_COMPONENT_KEYS.map(k => <option key={k} value={k} />)}</datalist>}
</label>}
{node.nodeType === 'merit' && <label className="ins-field"><span>Awards achievement{node.awardsFlag ? '' : <b className="ins-warn"> · required</b>}</span>
<input value={awardsFlag} placeholder="barricelli_luggage" onChange={e => setAwardsFlag(e.target.value)} onBlur={() => awardsFlag !== (node.awardsFlag || '') && onPatch({ awardsFlag: awardsFlag || null })} /></label>}
{node.nodeType === 'phone' && <p className="ins-hint">Add a terminal per contact, pick the NPC you reach by dialing their number, then wire it to the dialogue that answers.</p>}
{(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>}
<label className="ins-field"><span>Scene music</span>
@@ -213,6 +223,10 @@ function NodeInspector({ node, graph, templates, audioAssets, onPatch, onSetEntr
<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 })} />
{node.nodeType === 'phone' && <select className="ins-terminal-npc" value={t.npcId || ''} onChange={e => onTerminalPatch(t.id, { npcId: e.target.value || null })}>
<option value=""> NPC </option>
{npcs.map(n => <option key={n.id} value={n.id}>{n.name}{n.phoneNumber ? ` · ${n.phoneNumber}` : ' · (no number)'}</option>)}
</select>}
<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>
+20 -1
View File
@@ -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<string, FC<{ onComplete: () => 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<string, FC<{ label: string; onComplete: () => 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 <Component label={label} onComplete={onComplete} />
return <div className="cutscene-card merit-card" onClick={onComplete}>
<div className="title-card-inner">
<small className="merit-eyebrow"> MERIT AWARDED </small>
<h1>{label}</h1>
{awardsFlag && <p className="merit-flag">🏅 {awardsFlag}</p>}
<button className="cutscene-begin" onClick={event => { event.stopPropagation(); onComplete() }}>Accept </button>
</div>
</div>
}
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} />
+35 -8
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState } from './narrative'
import { CutsceneHost, DialoguePlayer, MeritHost, 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
@@ -7,6 +9,8 @@ import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState } fro
// board route (/level/<clone id>), which App renders.
export function Play() {
const [state, setState] = useState<PlaythroughState | null>(null)
const [resumable, setResumable] = useState<PlaythroughState | null>(null)
const [mysteries, setMysteries] = useState<MysterySummary[]>([])
const [splash, setSplash] = useState(false)
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState('')
@@ -37,20 +41,24 @@ export function Play() {
if (!cancelled) res.ok ? apply(await res.json()) : setStatus('NODE NOT FOUND')
return
}
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.status === 204) { setSplash(true); setStatus('AWAITING PRINCIPAL INVESTIGATOR'); return }
res.ok ? apply(await res.json()) : setSplash(true)
if (cases.ok) setMysteries(await cases.json())
if (current.ok && current.status !== 204) setResumable(await current.json())
setSplash(true)
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) }
}
@@ -62,11 +70,30 @@ export function Play() {
if (res.ok) apply(await res.json())
}
if (splash) return <SplashScreen hasResume={false} busy={busy} status={status} onNewGame={newGame} onResume={() => {}} />
if (splash) 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 · Case Files</p>
<div className="splash-cases">
{mysteries.map(mystery => {
const canResume = resumable?.playthrough.mysterySlug === mystery.slug
return <button key={mystery.slug} className="splash-case" disabled={busy}
onClick={() => (canResume && resumable) ? apply(resumable) : newGame(mystery.slug)}>
<span className="splash-case-title">{mystery.title}</span>
<span className="splash-case-action">{canResume ? 'RESUME ▸' : 'BEGIN ▸'}</span>
</button>
})}
{!mysteries.length && <p className="splash-status">NO CASE FILES AVAILABLE</p>}
</div>
<small className="splash-status">{busy ? 'OPENING CASE FILE…' : status}</small>
</div>
</div>
const node = state?.node
if (!node) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status || 'OPENING CASE FILE…'}</small></div>
if (node.kind === 'cutscene') return <CutsceneHost componentKey={node.componentKey} label={node.label} onComplete={() => { void advance() }} />
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} onComplete={() => { void advance() }} />
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }} onExit={terminalKey => { void advance(terminalKey) }} />
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
}
+15
View File
@@ -483,6 +483,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.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; }
.type-phone .gnode-type { color: #9fd020; } .type-merit .gnode-type { color: #cdea6a; }
.ins-hint { font: 10px IBM Plex Mono; color: #7f9a92; line-height: 1.5; margin: 2px 0 6px; }
.ins-terminal-npc { font: 10px IBM Plex Mono; background: #08201b; color: #cfe8df; border: 1px solid #3c5a52; max-width: 130px; }
.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; }
@@ -544,6 +547,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; }
@@ -672,3 +678,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; }