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:
2026-08-18 15:37:46 +02:00
co-authored by Claude Opus 4.8
parent 0237da74cf
commit ddb3a386f0
30 changed files with 3016 additions and 55 deletions
+164
View File
@@ -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>
}