Files
gupi-osint-board/src/mysteryGraph.tsx
T
gitprovandClaude Opus 4.8 bbf34bab09 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 <noreply@anthropic.com>
2026-08-22 17:16:48 +02:00

238 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' | '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 }
// 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' },
{ 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 })
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 [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)
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(() => {})
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 = () => {
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>)}
{selected && (selected.nodeType === 'dialogue' || selected.hasUtterances) &&
<div className="graph-node-preview" style={{ left: selected.xpos + NODE_W + 28, top: selected.ypos }} onClick={event => event.stopPropagation()}>
<DialoguePreview nodeId={selected.id} />
</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} 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)) } }}
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 === '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, 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' || 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>
{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{node.nodeType === 'cutscene' && componentKey && !CUTSCENE_COMPONENT_KEYS.includes(componentKey) && <b className="ins-warn"> · not registered</b>}</span>
<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>
<select value={node.musicAssetId || ''} onChange={e => onPatch({ musicAssetId: e.target.value || null })}>
<option value=""> none / inherit </option>
{audioAssets.map(asset => <option key={asset.id} value={asset.id}>{asset.originalName}</option>)}
</select>
</label>
{node.musicAssetId && <label className="ins-field"><span>Volume {volume}%</span>
<input type="range" min={0} max={100} value={volume} onChange={e => setVolume(Number(e.target.value))}
onPointerUp={() => volume !== node.musicVolume && onPatch({ musicVolume: volume })} onBlur={() => volume !== node.musicVolume && onPatch({ musicVolume: volume })} />
</label>}
<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>
</div>)}
<button className="ins-delete" onClick={onDelete}>Delete node</button>
</div>
}