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 AudioAsset = { id: string; originalName: string; mimeType: string } 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(url: string, method: string, body?: unknown): Promise { 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(null) const [templates, setTemplates] = useState([]) const [audioAssets, setAudioAssets] = useState([]) const [view, setView] = useState({ x: 60, y: 60, zoom: 1 }) const [selectedId, setSelectedId] = useState(null) const [wiringFrom, setWiringFrom] = useState(null) const [utterancesNode, setUtterancesNode] = useState(null) const canvasRef = useRef(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(`/api/admin/mysteries/${mysteryId}/graph`, 'GET')) } catch (error) { setStatus(String((error as Error).message || error)) } }, [mysteryId, setStatus]) useEffect(() => { void reload() api('/api/admin/level-templates', 'GET').then(setTemplates).catch(() => {}) api('/api/admin/assets', 'GET').then(list => setAudioAssets(list.filter(a => a.mimeType.startsWith('audio/')))).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(`/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) => { 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
Loading graph…
const selected = graph.nodes.find(n => n.id === selectedId) || null const nodeById = new Map(graph.nodes.map(n => [n.id, n])) return
{utterancesNode && { setUtterancesNode(null); void reload() }} setStatus={setStatus} />}
{title} Add: {TYPES.map(t => )} {wiringFrom && Click a target node to wire · click empty to cancel} {Math.round(view.zoom * 100)}%
{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 [ { event.stopPropagation(); void wire(t.id, null) }} />, , ] }))} {graph.nodes.map(node =>
event.stopPropagation()} onClick={event => onNodeClick(event, node)} onDoubleClick={event => { event.stopPropagation(); if (node.nodeType === 'dialogue' || node.hasUtterances) setUtterancesNode(node) }}>
onNodePointerDown(event, node)}> {node.nodeType} {node.label} {graph.entryNodeId === node.id && }
{nodeSummary(node, templates)}
{node.terminals.map(t =>
{t.label || t.terminalKey}
)}
)} {selected && (selected.nodeType === 'dialogue' || selected.hasUtterances) &&
event.stopPropagation()}>
}
{graph.nodes.length === 0 &&
Empty graph — add a node to begin.
}
{selected && }
} 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, audioAssets, onPatch, onSetEntry, onDelete, onAddTerminal, onTerminalPatch, onTerminalDelete, onEditUtterances }: { node: StoryNode; graph: Graph; templates: LevelTemplate[]; audioAssets: AudioAsset[] onPatch: (body: Record) => void; onSetEntry: () => void; onDelete: () => void onAddTerminal: () => void; onTerminalPatch: (id: string, body: Record) => void; onTerminalDelete: (id: string) => void; onEditUtterances: () => void }) { const [label, setLabel] = useState(node.label) const [componentKey, setComponentKey] = useState(node.componentKey || '') 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' return
{node.nodeType}{graph.entryNodeId !== node.id && }
{node.nodeType === 'level' && } {usesComponent && } {(node.nodeType === 'dialogue' || node.nodeType === 'cutscene') && } {(node.nodeType === 'dialogue' || node.hasUtterances) && } {node.musicAssetId && }
Output terminals
{node.terminals.map(t =>
e.target.value !== t.label && onTerminalPatch(t.id, { label: e.target.value })} /> → {nodeName(t.toNodeId)} {t.toNodeId && }
)}
}