2026-08-18 15:37:46 +02:00
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 [] }
2026-08-22 16:11:53 +02:00
export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' | 'merit'
2026-08-18 15:37:46 +02:00
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
2026-08-18 21:02:16 +02:00
xpos : number ; ypos : number ; levelTemplateVersionId : string | null ; componentKey : string | null ; musicAssetId : string | null ; musicVolume : number
2026-08-22 16:11:53 +02:00
awardsFlag : string | null
2026-08-18 15:37:46 +02:00
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
2026-08-18 16:28:11 +02:00
parentUtteranceId : string | null ; terminalId : string | null
xpos : number ; ypos : number ; sortOrder : number
2026-08-18 15:37:46 +02:00
}
// 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' }],
2026-08-22 16:11:53 +02:00
merit : [{ key : 'continue' , label : 'Continue' }],
2026-08-18 15:37:46 +02:00
}
export interface StoryGraphRepository {
getGraph ( mysteryId : string ) : Promise < StoryGraphDto | null >
createNode ( mysteryId : string , input : { nodeType : StoryNodeType ; xpos : number ; ypos : number ; label? : string }) : Promise < StoryNodeDto | null >
2026-08-22 16:11:53 +02:00
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 < StoryNodeDto | null >
2026-08-18 15:37:46 +02:00
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 >
2026-08-18 16:28:11 +02:00
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 } >
2026-08-18 15:37:46 +02:00
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 ([
2026-08-22 16:11:53 +02:00
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 ]),
2026-08-18 15:37:46 +02:00
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 ,
2026-08-18 21:02:16 +02:00
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 ,
2026-08-22 16:11:53 +02:00
awardsFlag : row.awards_flag ,
2026-08-18 15:37:46 +02:00
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 )
2026-08-18 19:51:44 +02:00
if ( input . musicAssetId !== undefined ) set ( 'music_asset_id' , input . musicAssetId || null )
2026-08-18 21:02:16 +02:00
if ( input . musicVolume !== undefined ) set ( 'music_volume' , Math . max ( 0 , Math . min ( 100 , Math . round ( input . musicVolume ))))
2026-08-22 16:11:53 +02:00
if ( input . awardsFlag !== undefined ) set ( 'awards_flag' , input . awardsFlag ? . trim () || null )
2026-08-18 15:37:46 +02:00
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 >(
2026-08-18 16:28:11 +02:00
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,terminal_id,xpos,ypos,sort_order
2026-08-18 15:37:46 +02:00
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 >(
2026-08-18 16:28:11 +02:00
`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 ])
2026-08-18 15:37:46 +02:00
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.
2026-08-18 16:28:11 +02:00
for ( const link of [ 'parentUtteranceId' ] as const ) {
2026-08-18 15:37:46 +02:00
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' ,
2026-08-18 16:28:11 +02:00
parentUtteranceId : 'parent_utterance_id' , terminalId : 'terminal_id' ,
2026-08-18 15:37:46 +02:00
}
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)' ,
2026-08-18 17:59:17 +02:00
[ utteranceId , nodeIds . get ( node . key ), utterance . utterer || 'npc' , npcId , utterance . pose || null , utterance . text , 60 , 60 + index * 120 , index ])
2026-08-18 15:37:46 +02:00
}
// 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
2026-08-18 16:28:11 +02:00
parent_utterance_id : string | null ; terminal_id : string | null
xpos : number ; ypos : number ; sort_order : number
2026-08-18 15:37:46 +02:00
}
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 ,
2026-08-18 16:28:11 +02:00
parentUtteranceId : row.parent_utterance_id , terminalId : row.terminal_id ,
xpos : row.xpos , ypos : row.ypos , sortOrder : row.sort_order ,
2026-08-18 15:37:46 +02:00
}
}