Input on top, output on the bottom, exit sinks in a row below; wires flow downward. Cards auto-expand, so their heights are measured (offsetHeight) to place the bottom output port. Seed and Tab-created utterances now stack downward. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
315 lines
19 KiB
TypeScript
315 lines
19 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import type { Pool, PoolClient } from 'pg'
|
|
|
|
export type GraphSpecNode = {
|
|
key: string; type: StoryNodeType; label?: string; x: number; y: number
|
|
componentKey?: string; templateSlug?: string; version?: number
|
|
terminals?: { key: string; label?: string; to?: string | null }[]
|
|
utterances?: { npc?: string; pose?: string; text: string; utterer?: Utterer }[]
|
|
}
|
|
export type GraphSpec = { entry: string; nodes: GraphSpecNode[] }
|
|
|
|
export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
|
|
export type TerminalDto = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number }
|
|
export type StoryNodeDto = {
|
|
id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean
|
|
xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null
|
|
terminals: TerminalDto[]
|
|
}
|
|
export type StoryGraphDto = { mysteryId: string; entryNodeId: string | null; nodes: StoryNodeDto[] }
|
|
export type LevelTemplateOption = { versionId: string; slug: string; name: string; version: number }
|
|
export type Utterer = 'npc' | 'player'
|
|
export type UtteranceDto = {
|
|
id: string; nodeId: string; utterer: Utterer; npcId: string | null; poseKey: string | null; text: string
|
|
parentUtteranceId: string | null; terminalId: string | null
|
|
xpos: number; ypos: number; sortOrder: number
|
|
}
|
|
|
|
// A sensible starter terminal set so a freshly dropped node is immediately wireable.
|
|
const DEFAULT_TERMINALS: Record<StoryNodeType, { key: string; label: string }[]> = {
|
|
cutscene: [{ key: 'continue', label: 'Continue' }],
|
|
dialogue: [{ key: 'continue', label: 'Continue' }],
|
|
level: [{ key: 'report_back', label: 'Report back' }],
|
|
det_gate: [{ key: 'pass', label: 'Pass' }],
|
|
llm_gate: [{ key: 'pass', label: 'Pass' }],
|
|
}
|
|
|
|
export interface StoryGraphRepository {
|
|
getGraph(mysteryId: string): Promise<StoryGraphDto | null>
|
|
createNode(mysteryId: string, input: { nodeType: StoryNodeType; xpos: number; ypos: number; label?: string }): Promise<StoryNodeDto | null>
|
|
updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null }>): Promise<StoryNodeDto | null>
|
|
deleteNode(nodeId: string): Promise<boolean>
|
|
addTerminal(nodeId: string, input: { terminalKey: string; label?: string }): Promise<StoryNodeDto | null>
|
|
updateTerminal(terminalId: string, input: Partial<{ label: string; sortOrder: number; toNodeId: string | null }>): Promise<{ ok: boolean; error?: string }>
|
|
deleteTerminal(terminalId: string): Promise<boolean>
|
|
setEntryNode(mysteryId: string, nodeId: string | null): Promise<{ ok: boolean; error?: string }>
|
|
listLevelTemplates(): Promise<LevelTemplateOption[]>
|
|
listUtterances(nodeId: string): Promise<UtteranceDto[]>
|
|
createUtterance(nodeId: string, input: { utterer: Utterer; xpos: number; ypos: number; text?: string }): Promise<UtteranceDto | null>
|
|
updateUtterance(id: string, input: Partial<{ text: string; utterer: Utterer; npcId: string | null; poseKey: string | null; xpos: number; ypos: number; parentUtteranceId: string | null; terminalId: string | null }>): Promise<{ ok: boolean; error?: string }>
|
|
deleteUtterance(id: string): Promise<boolean>
|
|
authorGraph(mysteryId: string, spec: GraphSpec): Promise<{ nodes: number } | null>
|
|
}
|
|
|
|
export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
|
async function mysteryOfNode(nodeId: string): Promise<string | null> {
|
|
const result = await pool.query<{ mystery_id: string }>('SELECT mystery_id FROM osint.story_nodes WHERE id=$1', [nodeId])
|
|
return result.rows[0]?.mystery_id ?? null
|
|
}
|
|
|
|
async function loadGraph(mysteryId: string): Promise<StoryGraphDto | null> {
|
|
const mystery = await pool.query<{ id: string; entry_node_id: string | null }>('SELECT id,entry_node_id FROM osint.mysteries WHERE id=$1', [mysteryId])
|
|
if (!mystery.rows[0]) return null
|
|
const [nodes, terminals] = await Promise.all([
|
|
pool.query<{ id: string; node_type: StoryNodeType; label: string; has_utterances: boolean; xpos: number; ypos: number; level_template_version_id: string | null; component_key: string | null }>(
|
|
'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key FROM osint.story_nodes WHERE mystery_id=$1 ORDER BY created_at', [mysteryId]),
|
|
pool.query<{ id: string; parent_node_id: string; terminal_key: string; label: string; to_node_id: string | null; sort_order: number }>(
|
|
`SELECT t.id,t.parent_node_id,t.terminal_key,t.label,t.to_node_id,t.sort_order FROM osint.story_node_terminals t
|
|
JOIN osint.story_nodes n ON n.id=t.parent_node_id WHERE n.mystery_id=$1 ORDER BY t.sort_order,t.terminal_key`, [mysteryId]),
|
|
])
|
|
const byNode = new Map<string, TerminalDto[]>()
|
|
for (const row of terminals.rows) {
|
|
const list = byNode.get(row.parent_node_id) || []
|
|
list.push({ id: row.id, terminalKey: row.terminal_key, label: row.label, toNodeId: row.to_node_id, sortOrder: row.sort_order })
|
|
byNode.set(row.parent_node_id, list)
|
|
}
|
|
return {
|
|
mysteryId, entryNodeId: mystery.rows[0].entry_node_id,
|
|
nodes: nodes.rows.map(row => ({
|
|
id: row.id, nodeType: row.node_type, label: row.label, hasUtterances: row.has_utterances,
|
|
xpos: row.xpos, ypos: row.ypos, levelTemplateVersionId: row.level_template_version_id, componentKey: row.component_key,
|
|
terminals: byNode.get(row.id) || [],
|
|
})),
|
|
}
|
|
}
|
|
|
|
return {
|
|
getGraph: loadGraph,
|
|
|
|
async createNode(mysteryId, input) {
|
|
const client = await pool.connect()
|
|
try {
|
|
await client.query('BEGIN')
|
|
const mystery = await client.query('SELECT 1 FROM osint.mysteries WHERE id=$1', [mysteryId])
|
|
if (!mystery.rows[0]) { await client.query('ROLLBACK'); return null }
|
|
const nodeId = randomUUID()
|
|
const label = input.label?.trim() || input.nodeType
|
|
await client.query('INSERT INTO osint.story_nodes (id,mystery_id,node_type,label,xpos,ypos,has_utterances) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
|
[nodeId, mysteryId, input.nodeType, label, input.xpos, input.ypos, input.nodeType === 'dialogue' || input.nodeType === 'cutscene'])
|
|
for (const [index, terminal] of DEFAULT_TERMINALS[input.nodeType].entries())
|
|
await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5)',
|
|
[randomUUID(), nodeId, terminal.key, terminal.label, index])
|
|
await client.query('COMMIT')
|
|
const graph = await loadGraph(mysteryId)
|
|
return graph?.nodes.find(node => node.id === nodeId) ?? null
|
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
|
},
|
|
|
|
async updateNode(nodeId, input) {
|
|
const mysteryId = await mysteryOfNode(nodeId)
|
|
if (!mysteryId) return null
|
|
const sets: string[] = []
|
|
const values: unknown[] = [nodeId]
|
|
const set = (column: string, value: unknown) => { values.push(value); sets.push(`${column}=$${values.length}`) }
|
|
if (input.label !== undefined) set('label', input.label.trim())
|
|
if (input.xpos !== undefined) set('xpos', input.xpos)
|
|
if (input.ypos !== undefined) set('ypos', input.ypos)
|
|
if (input.hasUtterances !== undefined) set('has_utterances', input.hasUtterances)
|
|
if (input.componentKey !== undefined) set('component_key', input.componentKey || null)
|
|
if (input.levelTemplateVersionId !== undefined) set('level_template_version_id', input.levelTemplateVersionId || null)
|
|
if (sets.length) await pool.query(`UPDATE osint.story_nodes SET ${sets.join(',')} WHERE id=$1`, values)
|
|
const graph = await loadGraph(mysteryId)
|
|
return graph?.nodes.find(node => node.id === nodeId) ?? null
|
|
},
|
|
|
|
async deleteNode(nodeId) {
|
|
const result = await pool.query('DELETE FROM osint.story_nodes WHERE id=$1', [nodeId])
|
|
return (result.rowCount ?? 0) > 0
|
|
},
|
|
|
|
async addTerminal(nodeId, input) {
|
|
const mysteryId = await mysteryOfNode(nodeId)
|
|
if (!mysteryId) return null
|
|
const key = input.terminalKey.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'out'
|
|
const order = await pool.query<{ next: number }>('SELECT COALESCE(MAX(sort_order),-1)+1 AS next FROM osint.story_node_terminals WHERE parent_node_id=$1', [nodeId])
|
|
await pool.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (parent_node_id,terminal_key) DO NOTHING',
|
|
[randomUUID(), nodeId, key, input.label?.trim() || key, order.rows[0].next])
|
|
const graph = await loadGraph(mysteryId)
|
|
return graph?.nodes.find(node => node.id === nodeId) ?? null
|
|
},
|
|
|
|
async updateTerminal(terminalId, input) {
|
|
const owner = await pool.query<{ parent_node_id: string; mystery_id: string }>(
|
|
`SELECT t.parent_node_id, n.mystery_id FROM osint.story_node_terminals t JOIN osint.story_nodes n ON n.id=t.parent_node_id WHERE t.id=$1`, [terminalId])
|
|
if (!owner.rows[0]) return { ok: false, error: 'Terminal not found' }
|
|
if (input.toNodeId !== undefined && input.toNodeId !== null) {
|
|
const target = await mysteryOfNode(input.toNodeId)
|
|
if (target !== owner.rows[0].mystery_id) return { ok: false, error: 'A wire must stay within the same mystery' }
|
|
}
|
|
const sets: string[] = []
|
|
const values: unknown[] = [terminalId]
|
|
const set = (column: string, value: unknown) => { values.push(value); sets.push(`${column}=$${values.length}`) }
|
|
if (input.label !== undefined) set('label', input.label.trim())
|
|
if (input.sortOrder !== undefined) set('sort_order', input.sortOrder)
|
|
if (input.toNodeId !== undefined) set('to_node_id', input.toNodeId)
|
|
if (sets.length) await pool.query(`UPDATE osint.story_node_terminals SET ${sets.join(',')} WHERE id=$1`, values)
|
|
return { ok: true }
|
|
},
|
|
|
|
async deleteTerminal(terminalId) {
|
|
const result = await pool.query('DELETE FROM osint.story_node_terminals WHERE id=$1', [terminalId])
|
|
return (result.rowCount ?? 0) > 0
|
|
},
|
|
|
|
async setEntryNode(mysteryId, nodeId) {
|
|
if (nodeId !== null) {
|
|
const target = await mysteryOfNode(nodeId)
|
|
if (target !== mysteryId) return { ok: false, error: 'Entry node must belong to the mystery' }
|
|
}
|
|
const result = await pool.query('UPDATE osint.mysteries SET entry_node_id=$2 WHERE id=$1', [mysteryId, nodeId])
|
|
return (result.rowCount ?? 0) > 0 ? { ok: true } : { ok: false, error: 'Mystery not found' }
|
|
},
|
|
|
|
async listLevelTemplates() {
|
|
const result = await pool.query<{ version_id: string; slug: string; name: string; version: number }>(
|
|
`SELECT v.id AS version_id,t.slug,t.name,v.version FROM osint.level_templates t
|
|
JOIN osint.level_template_versions v ON v.id=t.current_version_id ORDER BY t.name`)
|
|
return result.rows.map(row => ({ versionId: row.version_id, slug: row.slug, name: row.name, version: row.version }))
|
|
},
|
|
|
|
async listUtterances(nodeId) {
|
|
const result = await pool.query<UtteranceRow>(
|
|
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,terminal_id,xpos,ypos,sort_order
|
|
FROM osint.utterances WHERE node_id=$1 ORDER BY sort_order,id`, [nodeId])
|
|
return result.rows.map(mapUtterance)
|
|
},
|
|
|
|
async createUtterance(nodeId, input) {
|
|
const node = await pool.query('SELECT 1 FROM osint.story_nodes WHERE id=$1', [nodeId])
|
|
if (!node.rows[0]) return null
|
|
const id = randomUUID()
|
|
const order = await pool.query<{ next: number }>('SELECT COALESCE(MAX(sort_order),-1)+1 AS next FROM osint.utterances WHERE node_id=$1', [nodeId])
|
|
await pool.query('INSERT INTO osint.utterances (id,node_id,utterer,text,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
|
[id, nodeId, input.utterer, input.text || '', input.xpos, input.ypos, order.rows[0].next])
|
|
const created = await pool.query<UtteranceRow>(
|
|
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,terminal_id,xpos,ypos,sort_order FROM osint.utterances WHERE id=$1`, [id])
|
|
return mapUtterance(created.rows[0])
|
|
},
|
|
|
|
async updateUtterance(id, input) {
|
|
const owner = await pool.query<{ node_id: string }>('SELECT node_id FROM osint.utterances WHERE id=$1', [id])
|
|
if (!owner.rows[0]) return { ok: false, error: 'Utterance not found' }
|
|
const nodeId = owner.rows[0].node_id
|
|
// Same-node integrity for the three links.
|
|
for (const link of ['parentUtteranceId'] as const) {
|
|
const value = input[link]
|
|
if (value) {
|
|
const target = await pool.query<{ node_id: string }>('SELECT node_id FROM osint.utterances WHERE id=$1', [value])
|
|
if (target.rows[0]?.node_id !== nodeId) return { ok: false, error: 'Linked utterance must be in the same node' }
|
|
}
|
|
}
|
|
if (input.terminalId) {
|
|
const terminal = await pool.query<{ parent_node_id: string }>('SELECT parent_node_id FROM osint.story_node_terminals WHERE id=$1', [input.terminalId])
|
|
if (terminal.rows[0]?.parent_node_id !== nodeId) return { ok: false, error: 'Terminal must belong to this node' }
|
|
}
|
|
const columns: Record<string, string> = {
|
|
text: 'text', utterer: 'utterer', npcId: 'npc_id', poseKey: 'pose_key', xpos: 'xpos', ypos: 'ypos',
|
|
parentUtteranceId: 'parent_utterance_id', terminalId: 'terminal_id',
|
|
}
|
|
const sets: string[] = []
|
|
const values: unknown[] = [id]
|
|
for (const [key, column] of Object.entries(columns)) {
|
|
if ((input as Record<string, unknown>)[key] !== undefined) { values.push((input as Record<string, unknown>)[key]); sets.push(`${column}=$${values.length}`) }
|
|
}
|
|
if (sets.length) await pool.query(`UPDATE osint.utterances SET ${sets.join(',')} WHERE id=$1`, values)
|
|
return { ok: true }
|
|
},
|
|
|
|
async deleteUtterance(id) {
|
|
const result = await pool.query('DELETE FROM osint.utterances WHERE id=$1', [id])
|
|
return (result.rowCount ?? 0) > 0
|
|
},
|
|
|
|
// Seed/replace a mystery's whole graph from a spec (used by the manifest importer),
|
|
// so a default flow is authored content that survives re-imports.
|
|
async authorGraph(mysteryId, spec) {
|
|
const client: PoolClient = await pool.connect()
|
|
try {
|
|
await client.query('BEGIN')
|
|
const mystery = await client.query('SELECT 1 FROM osint.mysteries WHERE id=$1', [mysteryId])
|
|
if (!mystery.rows[0]) { await client.query('ROLLBACK'); return null }
|
|
await client.query('UPDATE osint.mysteries SET entry_node_id=NULL WHERE id=$1', [mysteryId])
|
|
await client.query('DELETE FROM osint.story_nodes WHERE mystery_id=$1', [mysteryId])
|
|
|
|
const nodeIds = new Map<string, string>()
|
|
const terminalIds = new Map<string, string>() // `${nodeKey}:${terminalKey}` -> id
|
|
for (const node of spec.nodes) {
|
|
const id = randomUUID(); nodeIds.set(node.key, id)
|
|
let versionId: string | null = null
|
|
if (node.type === 'level' && node.templateSlug) {
|
|
const version = await client.query<{ id: string }>(
|
|
`SELECT v.id FROM osint.level_templates t JOIN osint.level_template_versions v ON v.template_id=t.id
|
|
WHERE t.slug=$1 AND (($2::int IS NULL AND v.id=t.current_version_id) OR v.version=$2)`, [node.templateSlug, node.version ?? null])
|
|
versionId = version.rows[0]?.id ?? null
|
|
if (!versionId) throw new Error(`Graph node ${node.key}: unknown level template ${node.templateSlug}`)
|
|
}
|
|
await client.query('INSERT INTO osint.story_nodes (id,mystery_id,node_type,label,xpos,ypos,has_utterances,level_template_version_id,component_key) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
|
|
[id, mysteryId, node.type, node.label || node.type, node.x, node.y, Boolean(node.utterances?.length), versionId, node.componentKey || null])
|
|
for (const [index, terminal] of (node.terminals || []).entries()) {
|
|
const terminalId = randomUUID(); terminalIds.set(`${node.key}:${terminal.key}`, terminalId)
|
|
await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5)',
|
|
[terminalId, id, terminal.key, terminal.label || terminal.key, index])
|
|
}
|
|
}
|
|
// Wire terminals now that all nodes exist.
|
|
for (const node of spec.nodes) for (const terminal of node.terminals || []) {
|
|
if (!terminal.to) continue
|
|
const toId = nodeIds.get(terminal.to)
|
|
if (!toId) throw new Error(`Graph node ${node.key}: terminal ${terminal.key} points at unknown node ${terminal.to}`)
|
|
await client.query('UPDATE osint.story_node_terminals SET to_node_id=$2 WHERE id=$1', [terminalIds.get(`${node.key}:${terminal.key}`), toId])
|
|
}
|
|
// Utterances (linear seed): create, then chain them and exit the last one via
|
|
// the node's first terminal, so the crafter shows a connected flow.
|
|
for (const node of spec.nodes) {
|
|
const created: string[] = []
|
|
for (const [index, utterance] of (node.utterances || []).entries()) {
|
|
let npcId: string | null = null
|
|
if (utterance.npc) {
|
|
const npc = await client.query<{ id: string }>('SELECT id FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [utterance.npc])
|
|
npcId = npc.rows[0]?.id ?? null
|
|
}
|
|
const utteranceId = randomUUID(); created.push(utteranceId)
|
|
await client.query('INSERT INTO osint.utterances (id,node_id,utterer,npc_id,pose_key,text,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
|
|
[utteranceId, nodeIds.get(node.key), utterance.utterer || 'npc', npcId, utterance.pose || null, utterance.text, 60, 60 + index * 120, index])
|
|
}
|
|
// Chain via parent: each line follows the previous one (one child = linear).
|
|
for (let i = 1; i < created.length; i++)
|
|
await client.query('UPDATE osint.utterances SET parent_utterance_id=$2 WHERE id=$1', [created[i], created[i - 1]])
|
|
const firstTerminalKey = node.terminals?.[0]?.key
|
|
const exitTerminalId = firstTerminalKey ? terminalIds.get(`${node.key}:${firstTerminalKey}`) : undefined
|
|
if (created.length && exitTerminalId)
|
|
await client.query('UPDATE osint.utterances SET terminal_id=$2 WHERE id=$1', [created[created.length - 1], exitTerminalId])
|
|
}
|
|
const entryId = nodeIds.get(spec.entry)
|
|
if (!entryId) throw new Error(`Graph entry node ${spec.entry} not found`)
|
|
await client.query('UPDATE osint.mysteries SET entry_node_id=$2 WHERE id=$1', [mysteryId, entryId])
|
|
await client.query('COMMIT')
|
|
return { nodes: spec.nodes.length }
|
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
|
},
|
|
}
|
|
}
|
|
|
|
type UtteranceRow = {
|
|
id: string; node_id: string; utterer: Utterer; npc_id: string | null; pose_key: string | null; text: string
|
|
parent_utterance_id: string | null; terminal_id: string | null
|
|
xpos: number; ypos: number; sort_order: number
|
|
}
|
|
function mapUtterance(row: UtteranceRow): UtteranceDto {
|
|
return {
|
|
id: row.id, nodeId: row.node_id, utterer: row.utterer, npcId: row.npc_id, poseKey: row.pose_key, text: row.text,
|
|
parentUtteranceId: row.parent_utterance_id, terminalId: row.terminal_id,
|
|
xpos: row.xpos, ypos: row.ypos, sortOrder: row.sort_order,
|
|
}
|
|
}
|