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>
This commit is contained in:
+21
-7
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user