Merge main into Scene 7 evidence workflow

This commit is contained in:
2026-08-22 17:23:47 +02:00
16 changed files with 210 additions and 51 deletions
+38 -14
View File
@@ -6,7 +6,7 @@ import type { ObjectStorage } from './objectStorage.js'
export type UploadedFile = { buffer: Buffer; originalname: string; mimetype: string; size: number }
export type AssetDto = { id: string; originalName: string; mimeType: string; byteSize: number; url: string }
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 NpcDto = { id: string; key: string; name: string; role: string; defaultPose: string | null; phoneNumber: string | null; email: 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' }
@@ -15,8 +15,9 @@ export type RuntimeUtterance = {
poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null
}
export type RuntimeNode = {
id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string
id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string
componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number
awardsFlag?: string | null
utterances?: RuntimeUtterance[]; rootId?: string | null
}
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
@@ -59,19 +60,27 @@ export interface NarrativeRepository {
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }>
gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
listMysteries(): Promise<MysterySummary[]>
listPlayableMysteries(): Promise<{ slug: string; title: string }[]>
deleteMystery(id: string): Promise<boolean>
uploadAsset(file: UploadedFile): Promise<AssetDto>
listAssets(): Promise<AssetDto[]>
deleteAsset(id: string): Promise<'deleted' | 'in_use' | 'not_found'>
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>
createNpc(input: { key: string; name: string; role?: string; defaultPose?: string | null; phoneNumber?: string | null; email?: string | null }): Promise<NpcDto>
updateNpc(id: string, input: { name?: string; role?: string; defaultPose?: string | null; phoneNumber?: string | null; email?: 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>
}
type GraphNodeRow = { id: string; node_type: string; label: string; component_key: string | null; level_template_version_id: string | null; music_asset_id: string | null; music_volume: number }
type GraphNodeRow = { id: string; node_type: string; label: string; component_key: string | null; level_template_version_id: string | null; music_asset_id: string | null; music_volume: number; awards_flag?: string | null }
// Grant a merit node's achievement to the player on arrival (idempotent, with node
// provenance). Called from the write paths that move current_node_id onto a node.
async function awardMeritWithin(client: PoolClient, playthroughId: string, node: { id: string; node_type: string; awards_flag?: string | null }) {
if (node.node_type !== 'merit' || !node.awards_flag) return
await client.query('INSERT INTO osint.achievements (playthrough_id,flag_key,awarded_by_node_id) VALUES ($1,$2,$3) ON CONFLICT (playthrough_id,flag_key) DO NOTHING', [playthroughId, node.awards_flag, node.id])
}
export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStorage): NarrativeRepository {
// ---- Runtime: walking the story graph -------------------------------------
@@ -108,13 +117,14 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
}
async function resolveNodeForPlay(nodeId: string, levelSlug: string | null): Promise<RuntimeNode | null> {
const node = (await pool.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id,music_asset_id,music_volume FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0]
const node = (await pool.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id,music_asset_id,music_volume,awards_flag FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0]
if (!node) return null
const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null
const musicVolume = node.music_volume / 100
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key, musicUrl, musicVolume }
if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug, musicUrl, musicVolume }
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, musicVolume, ...(await resolveDialogueGraph(node.id)) }
if (node.node_type === 'merit') return { id: node.id, kind: 'merit', label: node.label, componentKey: node.component_key, awardsFlag: node.awards_flag, musicUrl, musicVolume }
return null // gates are auto-resolved during advance and never surfaced
}
@@ -122,7 +132,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
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]
const node = (await client.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id,awards_flag 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])
@@ -171,8 +181,8 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
}
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]
const npc = (await pool.query<{ id: string; npc_key: string; name: string; role: string; default_pose_key: string | null; phone_number: string | null; email: string | null }>(
'SELECT id,npc_key,name,role,default_pose_key,phone_number,email 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]),
@@ -180,6 +190,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
])
return {
id: npc.id, key: npc.npc_key, name: npc.name, role: npc.role, defaultPose: npc.default_pose_key,
phoneNumber: npc.phone_number, email: npc.email,
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,
}
@@ -231,6 +242,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
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 awardMeritWithin(client, playthroughId, entry)
await client.query('COMMIT')
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
return stateForPlaythrough(playthroughId)
@@ -277,12 +289,13 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
`SELECT p.mystery_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 FOR UPDATE OF p`, [playthroughId, userId])).rows[0]
if (!playthrough) { await client.query('ROLLBACK'); return { ok: false, error: 'Playthrough not found' } }
const node = (await client.query<{ id: string; node_type: string; level_template_version_id: string | null }>(
'SELECT id,node_type,level_template_version_id FROM osint.story_nodes WHERE id=$1 AND mystery_id=$2', [nodeId, playthrough.mystery_id])).rows[0]
const node = (await client.query<{ id: string; node_type: string; level_template_version_id: string | null; awards_flag: string | null }>(
'SELECT id,node_type,level_template_version_id,awards_flag FROM osint.story_nodes WHERE id=$1 AND mystery_id=$2', [nodeId, playthrough.mystery_id])).rows[0]
if (!node) { await client.query('ROLLBACK'); return { ok: false, error: 'Node not found' } }
const levelId = node.node_type === 'level' && node.level_template_version_id
? await instantiateLevel(client, node.level_template_version_id, playthrough.mystery_slug) : null
await client.query(`UPDATE osint.playthroughs SET status='active',current_node_id=$2,current_level_id=$3,updated_at=NOW() WHERE id=$1`, [playthroughId, node.id, levelId])
await awardMeritWithin(client, playthroughId, node)
await client.query('COMMIT')
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
const state = await stateForPlaythrough(playthroughId)
@@ -348,6 +361,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
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 awardMeritWithin(client, playthroughId, target)
}
await client.query('COMMIT')
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
@@ -355,6 +369,14 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
return { ok: true, state: state ?? undefined }
},
// Play mode: only mysteries with an entrypoint are launchable (this filters out
// half-authored, empty ones). Returns the minimum the case picker needs.
async listPlayableMysteries() {
const result = await pool.query<{ slug: string; title: string }>(
'SELECT slug,title FROM osint.mysteries WHERE entry_node_id IS NOT NULL ORDER BY title')
return result.rows.map(row => ({ slug: row.slug, title: row.title }))
},
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
@@ -402,17 +424,19 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
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])
await pool.query('INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key,phone_number,email) VALUES ($1,NULL,$2,$3,$4,$5,$6,$7)',
[id, key, input.name.trim() || key, input.role?.trim() || '', input.defaultPose || null, input.phoneNumber?.trim() || null, input.email?.trim() || 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', [
await pool.query('UPDATE osint.npcs SET name=$2,role=$3,default_pose_key=$4,phone_number=$5,email=$6 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),
input.phoneNumber === undefined ? existing.phoneNumber : (input.phoneNumber?.trim() || null),
input.email === undefined ? existing.email : (input.email?.trim() || null),
])
return loadNpc(id)
},