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' | 'merit' 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; musicAssetId: string | null; musicVolume: number awardsFlag: 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 = { 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' }], merit: [{ key: 'continue', label: 'Continue' }], } export interface StoryGraphRepository { getGraph(mysteryId: string): Promise createNode(mysteryId: string, input: { nodeType: StoryNodeType; xpos: number; ypos: number; label?: string }): Promise updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null; musicAssetId: string | null; musicVolume: number; awardsFlag: string | null }>): Promise deleteNode(nodeId: string): Promise addTerminal(nodeId: string, input: { terminalKey: string; label?: string }): Promise updateTerminal(terminalId: string, input: Partial<{ label: string; sortOrder: number; toNodeId: string | null }>): Promise<{ ok: boolean; error?: string }> deleteTerminal(terminalId: string): Promise setEntryNode(mysteryId: string, nodeId: string | null): Promise<{ ok: boolean; error?: string }> listLevelTemplates(): Promise listUtterances(nodeId: string): Promise createUtterance(nodeId: string, input: { utterer: Utterer; xpos: number; ypos: number; text?: string }): Promise 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 authorGraph(mysteryId: string, spec: GraphSpec): Promise<{ nodes: number } | null> } export function createStoryGraphRepository(pool: Pool): StoryGraphRepository { async function mysteryOfNode(nodeId: string): Promise { 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 { 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; music_asset_id: string | null; music_volume: number; awards_flag: string | null }>( 'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key,music_asset_id,music_volume,awards_flag 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() 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, musicAssetId: row.music_asset_id, musicVolume: row.music_volume, awardsFlag: row.awards_flag, 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 (input.musicAssetId !== undefined) set('music_asset_id', input.musicAssetId || null) if (input.musicVolume !== undefined) set('music_volume', Math.max(0, Math.min(100, Math.round(input.musicVolume)))) if (input.awardsFlag !== undefined) set('awards_flag', input.awardsFlag?.trim() || 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( `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( `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 = { 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)[key] !== undefined) { values.push((input as Record)[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() const terminalIds = new Map() // `${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, } }