2026-08-18 15:37:46 +02:00
import { createHash , randomUUID } from 'node:crypto'
import type { Pool , PoolClient } from 'pg'
import { cloneBoard } from './boardClone.js'
import type { ObjectStorage } from './objectStorage.js'
export type UploadedFile = { buffer : Buffer ; originalname : string ; mimetype : string ; size : number }
2026-08-18 19:05:29 +02:00
export type AssetDto = { id : string ; originalName : string ; mimeType : string ; byteSize : number ; url : string }
2026-08-18 15:37:46 +02:00
export type PoseDto = { poseKey : string ; assetId : string ; url : string }
export type NpcDto = { id : string ; key : string ; name : string ; role : string ; defaultPose : string | null ; poses : PoseDto []; inUse : boolean }
export type MysterySummary = { id : string ; slug : string ; title : string ; nodes : number }
export type PlaythroughSummary = { id : string ; mysterySlug : string ; levelSlug : string | null ; status : 'active' | 'finished' }
export type RuntimeUtterance = {
id : string ; utterer : 'npc' | 'player' ; speaker : { name : string ; role : string }
poseUrl : string | null ; text : string ; childIds : string []; terminalKey : string | null
}
export type RuntimeNode = {
id : string ; kind : 'cutscene' | 'dialogue' | 'level' ; label : string
2026-08-18 19:51:44 +02:00
componentKey? : string | null ; levelSlug? : string | null ; musicUrl? : string | null
2026-08-18 15:37:46 +02:00
utterances? : RuntimeUtterance []; rootId? : string | null
}
export type PlaythroughState = { playthrough : PlaythroughSummary ; node : RuntimeNode | null }
export type MysteryAuthoring = {
slug : string
title : string
cast : { key : string ; name : string ; role? : string ; defaultPose? : string ; poses ?: { poseKey : string ; assetId : string }[] }[]
}
/**
* Resolve a portrait with graceful fallback: the requested pose, else the NPC's
* default pose, else no artwork. Pure so it is unit-testable without a DB.
*/
export function resolvePoseAssetId (
poseAssets : Record < string , string | null | undefined >,
requestedPoseKey : string | null | undefined ,
defaultPoseKey : string | null | undefined ,
) : string | null {
if ( requestedPoseKey && poseAssets [ requestedPoseKey ]) return poseAssets [ requestedPoseKey ] !
if ( defaultPoseKey && poseAssets [ defaultPoseKey ]) return poseAssets [ defaultPoseKey ] !
return null
}
export interface NarrativeRepository {
authorMystery ( input : MysteryAuthoring ) : Promise < { slug : string } >
2026-08-18 19:24:03 +02:00
resolveDialogue ( nodeId : string ) : Promise < { utterances : RuntimeUtterance []; rootId : string | null } >
2026-08-18 15:37:46 +02:00
createPlaythrough ( userId : string , mysterySlug? : string ) : Promise < PlaythroughState | null >
getCurrentPlaythrough ( userId : string ) : Promise < PlaythroughState | null >
advancePlaythrough ( userId : string , playthroughId : string , terminalKey? : string ) : Promise < { ok : boolean ; state? : PlaythroughState ; error? : string } >
listMysteries () : Promise < MysterySummary [] >
2026-08-18 19:05:29 +02:00
deleteMystery ( id : string ) : Promise < boolean >
uploadAsset ( file : UploadedFile ) : Promise < AssetDto >
listAssets () : Promise < AssetDto [] >
deleteAsset ( id : string ) : Promise < 'deleted' | 'in_use' | 'not_found' >
2026-08-18 15:37:46 +02:00
listNpcs () : Promise < NpcDto [] >
createNpc ( input : { key : string ; name : string ; role? : string ; defaultPose? : string | null }) : Promise < NpcDto >
updateNpc ( id : string , input : { name? : string ; role? : string ; defaultPose? : string | null }) : Promise < NpcDto | null >
deleteNpc ( id : string ) : Promise < 'deleted' | 'in_use' | 'not_found' >
addPose ( npcId : string , poseKey : string , file : UploadedFile ) : Promise < NpcDto | null >
deletePose ( npcId : string , poseKey : string ) : Promise < NpcDto | null >
}
2026-08-18 19:51:44 +02:00
type GraphNodeRow = { id : string ; node_type : string ; label : string ; component_key : string | null ; level_template_version_id : string | null ; music_asset_id : string | null }
2026-08-18 15:37:46 +02:00
export function createNarrativeRepository ( pool : Pool , objectStorage : ObjectStorage ) : NarrativeRepository {
// ---- Runtime: walking the story graph -------------------------------------
// Resolve a dialogue node's whole utterance tree for the client to walk: each
// utterance carries its ordered children and (if it exits the node) its terminal key.
async function resolveDialogueGraph ( nodeId : string ) : Promise < { utterances : RuntimeUtterance []; rootId : string | null } > {
const [ utterances , poses , terminals ] = await Promise . all ([
pool . query < { id : string ; utterer : 'npc' | 'player' ; npc_id : string | null ; pose_key : string | null ; text : string ; parent_utterance_id : string | null ; terminal_id : string | null ; name : string | null ; role : string | null ; default_pose_key : string | null } > (
`SELECT u.id,u.utterer,u.npc_id,u.pose_key,u.text,u.parent_utterance_id,u.terminal_id,n.name,n.role,n.default_pose_key
FROM osint.utterances u LEFT JOIN osint.npcs n ON n.id=u.npc_id WHERE u.node_id=$1 ORDER BY u.sort_order` , [ nodeId ]),
pool . query < { npc_id : string ; pose_key : string ; asset_id : string | null } > (
`SELECT p.npc_id,p.pose_key,p.asset_id FROM osint.npc_poses p
WHERE p.npc_id IN (SELECT DISTINCT npc_id FROM osint.utterances WHERE node_id=$1 AND npc_id IS NOT NULL)` , [ nodeId ]),
pool . query < { id : string ; terminal_key : string } > ( 'SELECT id,terminal_key FROM osint.story_node_terminals WHERE parent_node_id=$1' , [ nodeId ]),
])
const poseAssets = new Map < string , Record < string , string | null >>()
for ( const row of poses . rows ) { const map = poseAssets . get ( row . npc_id ) || {}; map [ row . pose_key ] = row . asset_id ; poseAssets . set ( row . npc_id , map ) }
const terminalKey = new Map ( terminals . rows . map ( row => [ row . id , row . terminal_key ]))
const children = new Map < string , string [] >()
for ( const row of utterances . rows ) if ( row . parent_utterance_id ) children . set ( row . parent_utterance_id , [...( children . get ( row . parent_utterance_id ) || []), row . id ])
const root = utterances . rows . find ( row => ! row . parent_utterance_id )
return {
rootId : root?.id ?? null ,
utterances : utterances.rows.map ( row => {
const assetId = row . npc_id ? resolvePoseAssetId ( poseAssets . get ( row . npc_id ) || {}, row . pose_key , row . default_pose_key ) : null
return {
id : row.id , utterer : row.utterer , speaker : { name : row.name || '' , role : row.role || '' },
poseUrl : assetId ? `/api/assets/ ${ assetId } ` : null , text : row.text ,
childIds : children.get ( row . id ) || [], terminalKey : row.terminal_id ? ( terminalKey . get ( row . terminal_id ) ?? null ) : null ,
}
}),
}
}
async function resolveNodeForPlay ( nodeId : string , levelSlug : string | null ) : Promise < RuntimeNode | null > {
2026-08-18 19:51:44 +02:00
const node = ( await pool . query < GraphNodeRow >( 'SELECT id,node_type,label,component_key,level_template_version_id,music_asset_id FROM osint.story_nodes WHERE id=$1' , [ nodeId ])). rows [ 0 ]
2026-08-18 15:37:46 +02:00
if ( ! node ) return null
2026-08-18 19:51:44 +02:00
const musicUrl = node . music_asset_id ? `/api/assets/ ${ node . music_asset_id } ` : null
if ( node . node_type === 'cutscene' ) return { id : node.id , kind : 'cutscene' , label : node.label , componentKey : node.component_key , musicUrl }
if ( node . node_type === 'level' ) return { id : node.id , kind : 'level' , label : node.label , levelSlug , musicUrl }
if ( node . node_type === 'dialogue' ) return { id : node.id , kind : 'dialogue' , label : node.label , musicUrl , ...( await resolveDialogueGraph ( node . id )) }
2026-08-18 15:37:46 +02:00
return null // gates are auto-resolved during advance and never surfaced
}
// Skip through gate nodes (deterministic gate is dumb: it follows its first terminal).
async function resolveThroughGates ( client : PoolClient , nodeId : string | null ) : Promise < GraphNodeRow | null > {
let current = nodeId
for ( let guard = 0 ; guard < 50 && current ; guard ++ ) {
const node = ( await client . query < GraphNodeRow >( 'SELECT id,node_type,label,component_key,level_template_version_id FROM osint.story_nodes WHERE id=$1' , [ current ])). rows [ 0 ]
if ( ! node ) return null
if ( node . node_type !== 'det_gate' && node . node_type !== 'llm_gate' ) return node
const next = await client . query < { to_node_id : string | null } > ( 'SELECT to_node_id FROM osint.story_node_terminals WHERE parent_node_id=$1 ORDER BY sort_order LIMIT 1' , [ current ])
current = next . rows [ 0 ] ? . to_node_id ?? null
}
return null
}
async function instantiateLevel ( client : PoolClient , versionId : string , mysterySlug : string ) : Promise < string > {
const source = ( await client . query < { board_id : string ; title : string ; subtitle : string } > (
'SELECT board_id,title,subtitle FROM osint.level_template_versions WHERE id=$1 FOR SHARE' , [ versionId ])). rows [ 0 ]
if ( ! source ) throw new Error ( 'Level template version not found' )
const boardId = randomUUID (); const levelId = randomUUID ()
const levelSlug = ` ${ mysterySlug } -play- ${ randomUUID (). slice ( 0 , 8 ) } `
await client . query ( `INSERT INTO osint.boards (id,board_kind) VALUES ($1,'level')` , [ boardId ])
await client . query ( 'INSERT INTO osint.levels (id,slug,board_id,source_template_version_id,title,subtitle) VALUES ($1,$2,$3,$4,$5,$6)' ,
[ levelId , levelSlug , boardId , versionId , source . title , source . subtitle ])
await cloneBoard ( client , source . board_id , boardId )
return levelId
}
async function stateForPlaythrough ( playthroughId : string ) : Promise < PlaythroughState | null > {
const row = ( await pool . query < { id : string ; mystery_slug : string ; current_node_id : string | null ; level_slug : string | null ; status : 'active' | 'finished' } > (
`SELECT p.id,m.slug AS mystery_slug,p.current_node_id,l.slug AS level_slug,p.status
FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id LEFT JOIN osint.levels l ON l.id=p.current_level_id
WHERE p.id=$1` , [ playthroughId ])). rows [ 0 ]
if ( ! row ) return null
const node = row . current_node_id ? await resolveNodeForPlay ( row . current_node_id , row . level_slug ) : null
return { playthrough : { id : row.id , mysterySlug : row.mystery_slug , levelSlug : row.level_slug , status : row.status }, node }
}
// ---- Assets & NPC catalog -------------------------------------------------
async function storeAsset ( file : UploadedFile ) : Promise < string > {
const checksum = createHash ( 'sha256' ). update ( file . buffer ). digest ( 'hex' )
const existing = await pool . query < { id : string } > ( 'SELECT id FROM osint.assets WHERE checksum_sha256=$1 AND byte_size=$2' , [ checksum , file . size ])
if ( existing . rows [ 0 ]) return existing . rows [ 0 ]. id
const objectKey = `assets/ ${ checksum . slice ( 0 , 2 ) } / ${ checksum } `
const stored = await objectStorage . putObject ( objectKey , file . buffer , file . mimetype || 'application/octet-stream' )
const asset = await pool . query < { id : string } > ( `INSERT INTO osint.assets
(id,original_name,mime_type,byte_size,content,checksum_sha256,storage_provider,storage_bucket,object_key,etag)
VALUES ($1,$2,$3,$4,NULL,$5,'s3',$6,$7,$8)
ON CONFLICT (checksum_sha256,byte_size) DO UPDATE SET checksum_sha256=EXCLUDED.checksum_sha256 RETURNING id` ,
[ randomUUID (), file . originalname , file . mimetype || 'application/octet-stream' , file . size , checksum , objectStorage . bucket , objectKey , stored . etag || null ])
return asset . rows [ 0 ]. id
}
async function loadNpc ( id : string ) : Promise < NpcDto | null > {
const npc = ( await pool . query < { id : string ; npc_key : string ; name : string ; role : string ; default_pose_key : string | null } > (
'SELECT id,npc_key,name,role,default_pose_key FROM osint.npcs WHERE id=$1 AND mystery_id IS NULL' , [ id ])). rows [ 0 ]
if ( ! npc ) return null
const [ poses , usage ] = await Promise . all ([
pool . query < { pose_key : string ; asset_id : string } > ( 'SELECT pose_key,asset_id FROM osint.npc_poses WHERE npc_id=$1 AND asset_id IS NOT NULL ORDER BY pose_key' , [ id ]),
pool . query < { count : string } > ( 'SELECT COUNT(*)::text AS count FROM osint.utterances WHERE npc_id=$1' , [ id ]),
])
return {
id : npc.id , key : npc.npc_key , name : npc.name , role : npc.role , defaultPose : npc.default_pose_key ,
poses : poses.rows.map ( pose => ({ poseKey : pose.pose_key , assetId : pose.asset_id , url : `/api/assets/ ${ pose . asset_id } ` })),
inUse : Number ( usage . rows [ 0 ]. count ) > 0 ,
}
}
return {
async authorMystery ( input ) {
const client = await pool . connect ()
try {
await client . query ( 'BEGIN' )
// Dev-friendly replace: re-authoring the same slug supersedes the previous
// mystery (cascades to its graph, cast links, and playthroughs).
await client . query ( 'DELETE FROM osint.mysteries WHERE slug=$1' , [ input . slug ])
const mysteryId = randomUUID ()
await client . query ( 'INSERT INTO osint.mysteries (id,slug,title) VALUES ($1,$2,$3)' , [ mysteryId , input . slug , input . title ])
// NPCs are global templates referenced by key; create the first time a key is
// seen and never clobber an existing one (admin edits persist).
for ( const npc of input . cast ) {
const existing = await client . query ( 'SELECT 1 FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL' , [ npc . key ])
if ( existing . rows [ 0 ]) continue
const npcId = randomUUID ()
await client . query ( 'INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key) VALUES ($1,NULL,$2,$3,$4,$5)' ,
[ npcId , npc . key , npc . name , npc . role || '' , npc . defaultPose || null ])
for ( const pose of npc . poses || []) await client . query (
'INSERT INTO osint.npc_poses (id,npc_id,pose_key,asset_id) VALUES ($1,$2,$3,$4)' , [ randomUUID (), npcId , pose . poseKey , pose . assetId ])
}
await client . query ( 'COMMIT' )
} catch ( error ) { await client . query ( 'ROLLBACK' ); throw error } finally { client . release () }
return { slug : input.slug }
},
2026-08-18 19:24:03 +02:00
resolveDialogue ( nodeId ) { return resolveDialogueGraph ( nodeId ) },
2026-08-18 15:37:46 +02:00
async createPlaythrough ( userId , mysterySlug ) {
const client = await pool . connect ()
let playthroughId : string
try {
await client . query ( 'BEGIN' )
const mystery = ( await client . query < { id : string ; slug : string ; entry_node_id : string | null } > (
mysterySlug
? 'SELECT id,slug,entry_node_id FROM osint.mysteries WHERE slug=$1'
: 'SELECT id,slug,entry_node_id FROM osint.mysteries WHERE entry_node_id IS NOT NULL ORDER BY created_at DESC LIMIT 1' ,
mysterySlug ? [ mysterySlug ] : [])). rows [ 0 ]
if ( ! mystery ? . entry_node_id ) { await client . query ( 'ROLLBACK' ); return null }
const entry = await resolveThroughGates ( client , mystery . entry_node_id )
if ( ! entry ) { await client . query ( 'ROLLBACK' ); return null }
const levelId = entry . node_type === 'level' && entry . level_template_version_id
? await instantiateLevel ( client , entry . level_template_version_id , mystery . slug ) : null
playthroughId = randomUUID ()
await client . query ( 'INSERT INTO osint.playthroughs (id,user_id,mystery_id,current_node_id,current_level_id) VALUES ($1,$2,$3,$4,$5)' ,
[ playthroughId , userId , mystery . id , entry . id , levelId ])
await client . query ( 'COMMIT' )
} catch ( error ) { await client . query ( 'ROLLBACK' ); throw error } finally { client . release () }
return stateForPlaythrough ( playthroughId )
},
async getCurrentPlaythrough ( userId ) {
const row = ( await pool . query < { id : string } > (
`SELECT id FROM osint.playthroughs WHERE user_id=$1 AND status='active' ORDER BY updated_at DESC LIMIT 1` , [ userId ])). rows [ 0 ]
return row ? stateForPlaythrough ( row . id ) : null
},
async advancePlaythrough ( userId , playthroughId , terminalKey ) {
const client = await pool . connect ()
try {
await client . query ( 'BEGIN' )
const playthrough = ( await client . query < { current_node_id : string | null ; mystery_slug : string } > (
`SELECT p.current_node_id,m.slug AS mystery_slug FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id
WHERE p.id=$1 AND p.user_id=$2 AND p.status='active' FOR UPDATE OF p` , [ playthroughId , userId ])). rows [ 0 ]
if ( ! playthrough ) { await client . query ( 'ROLLBACK' ); return { ok : false , error : 'Playthrough not found' } }
if ( ! playthrough . current_node_id ) { await client . query ( 'ROLLBACK' ); return { ok : false , error : 'Playthrough already finished' } }
const terminals = ( await client . query < { terminal_key : string ; to_node_id : string | null } > (
'SELECT terminal_key,to_node_id FROM osint.story_node_terminals WHERE parent_node_id=$1 ORDER BY sort_order' , [ playthrough . current_node_id ])). rows
const wired = terminals . filter ( t => t . to_node_id )
const chosen = terminalKey ? terminals . find ( t => t . terminal_key === terminalKey )
: wired . length === 1 ? wired [ 0 ] : terminals . length === 1 ? terminals [ 0 ] : undefined
if ( ! chosen ) { await client . query ( 'ROLLBACK' ); return { ok : false , error : 'Ambiguous or unknown terminal — specify one' } }
const target = await resolveThroughGates ( client , chosen . to_node_id )
if ( ! target ) {
await client . query ( `UPDATE osint.playthroughs SET status='finished',current_node_id=NULL,current_level_id=NULL,updated_at=NOW() WHERE id=$1` , [ playthroughId ])
} else {
const levelId = target . node_type === 'level' && target . level_template_version_id
? await instantiateLevel ( client , target . level_template_version_id , playthrough . mystery_slug ) : null
await client . query ( 'UPDATE osint.playthroughs SET current_node_id=$2,current_level_id=$3,updated_at=NOW() WHERE id=$1' , [ playthroughId , target . id , levelId ])
}
await client . query ( 'COMMIT' )
} catch ( error ) { await client . query ( 'ROLLBACK' ); throw error } finally { client . release () }
const state = await stateForPlaythrough ( playthroughId )
return { ok : true , state : state ?? undefined }
},
async listMysteries() {
const result = await pool . query < { id : string ; slug : string ; title : string ; nodes : string } > (
`SELECT m.id,m.slug,m.title,COUNT(n.id)::text AS nodes
FROM osint.mysteries m LEFT JOIN osint.story_nodes n ON n.mystery_id=m.id
GROUP BY m.id ORDER BY m.created_at DESC` )
return result . rows . map ( row => ({ id : row.id , slug : row.slug , title : row.title , nodes : Number ( row . nodes ) }))
},
2026-08-18 19:05:29 +02:00
async deleteMystery ( id ) {
const result = await pool . query ( 'DELETE FROM osint.mysteries WHERE id=$1' , [ id ])
return ( result . rowCount ?? 0 ) > 0
},
async uploadAsset ( file ) {
const id = await storeAsset ( file )
const row = ( await pool . query < { original_name : string ; mime_type : string ; byte_size : string } > (
'SELECT original_name,mime_type,byte_size FROM osint.assets WHERE id=$1' , [ id ])). rows [ 0 ]
return { id , originalName : row.original_name , mimeType : row.mime_type , byteSize : Number ( row . byte_size ), url : `/api/assets/ ${ id } ` }
},
async listAssets() {
const result = await pool . query < { id : string ; original_name : string ; mime_type : string ; byte_size : string } > (
'SELECT id,original_name,mime_type,byte_size FROM osint.assets ORDER BY created_at DESC' )
return result . rows . map ( row => ({ id : row.id , originalName : row.original_name , mimeType : row.mime_type , byteSize : Number ( row . byte_size ), url : `/api/assets/ ${ row . id } ` }))
},
async deleteAsset ( id ) {
// document_exhibits.asset_id is ON DELETE RESTRICT, so an in-use asset raises a
// foreign-key violation (23503) rather than deleting.
try {
const result = await pool . query ( 'DELETE FROM osint.assets WHERE id=$1' , [ id ])
return ( result . rowCount ?? 0 ) > 0 ? 'deleted' : 'not_found'
} catch ( error ) {
if (( error as { code? : string }). code === '23503' ) return 'in_use'
throw error
}
},
2026-08-18 15:37:46 +02:00
async listNpcs() {
const npcs = await pool . query < { id : string } > ( 'SELECT id FROM osint.npcs WHERE mystery_id IS NULL ORDER BY name' )
return ( await Promise . all ( npcs . rows . map ( row => loadNpc ( row . id )))). filter (( npc ) : npc is NpcDto => npc !== null )
},
async createNpc ( input ) {
const key = input . key . trim (). toLowerCase (). replace ( /[^a-z0-9-]+/g , '-' ). replace ( /^-+|-+$/g , '' )
if ( ! key ) throw new Error ( 'An NPC key is required' )
const id = randomUUID ()
await pool . query ( 'INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key) VALUES ($1,NULL,$2,$3,$4,$5)' ,
[ id , key , input . name . trim () || key , input . role ? . trim () || '' , input . defaultPose || null ])
return ( await loadNpc ( id )) !
},
async updateNpc ( id , input ) {
const existing = await loadNpc ( id )
if ( ! existing ) return null
await pool . query ( 'UPDATE osint.npcs SET name=$2,role=$3,default_pose_key=$4 WHERE id=$1 AND mystery_id IS NULL' , [
id , input . name ? . trim () ?? existing . name , input . role ? . trim () ?? existing . role ,
input . defaultPose === undefined ? existing . defaultPose : ( input . defaultPose || null ),
])
return loadNpc ( id )
},
async deleteNpc ( id ) {
const existing = await loadNpc ( id )
if ( ! existing ) return 'not_found'
if ( existing . inUse ) return 'in_use'
await pool . query ( 'DELETE FROM osint.npcs WHERE id=$1 AND mystery_id IS NULL' , [ id ])
return 'deleted'
},
async addPose ( npcId , poseKey , file ) {
const npc = await loadNpc ( npcId )
if ( ! npc ) return null
const key = poseKey . trim (). toLowerCase (). replace ( /[^a-z0-9-]+/g , '-' ). replace ( /^-+|-+$/g , '' ) || 'default'
const assetId = await storeAsset ( file )
await pool . query ( `INSERT INTO osint.npc_poses (id,npc_id,pose_key,asset_id) VALUES ($1,$2,$3,$4)
ON CONFLICT (npc_id,pose_key) DO UPDATE SET asset_id=EXCLUDED.asset_id` , [ randomUUID (), npcId , key , assetId ])
return loadNpc ( npcId )
},
async deletePose ( npcId , poseKey ) {
const npc = await loadNpc ( npcId )
if ( ! npc ) return null
await pool . query ( 'DELETE FROM osint.npc_poses WHERE npc_id=$1 AND pose_key=$2' , [ npcId , poseKey ])
return loadNpc ( npcId )
},
}
}