A dialogue line gains a "✎ Note this" capture that saves it to a per-playthrough notebook (migration 035 + notebook endpoints). The inventory notebook lists captured pages in a handwriting font (Google "Reenie Beanie") and "✂ Tear to board" drops a page onto the current board as a note exhibit (reusing addNote), then removes the page. Board notes render handwritten too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
554 lines
36 KiB
TypeScript
554 lines
36 KiB
TypeScript
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 }
|
|
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; 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' }
|
|
export type RuntimeUtterance = {
|
|
id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }
|
|
poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null; awardsFlag: string | null
|
|
}
|
|
export type RuntimeNode = {
|
|
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 }
|
|
export type PlaythroughAdvanceResult = {
|
|
ok: boolean
|
|
state?: PlaythroughState
|
|
error?: string
|
|
errorCode?: 'goals_incomplete' | 'report_incomplete'
|
|
pendingGoals?: { key: string; title: string }[]
|
|
}
|
|
|
|
export type MysteryAuthoring = {
|
|
slug: string
|
|
title: string
|
|
cast: { key: string; name: string; role?: string; defaultPose?: string; phoneNumber?: string; email?: 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 }>
|
|
resolveDialogue(nodeId: string): Promise<{ utterances: RuntimeUtterance[]; rootId: string | null }>
|
|
createPlaythrough(userId: string, mysterySlug?: string): Promise<PlaythroughState | null>
|
|
getCurrentPlaythrough(userId: string): Promise<PlaythroughState | null>
|
|
ownsActiveLevel(userId: string, levelSlug: string): Promise<boolean>
|
|
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<PlaythroughAdvanceResult>
|
|
listAchievements(playthroughId: string): Promise<string[] | null>
|
|
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }>
|
|
reachUtterance(playthroughId: string, utteranceId: string): Promise<{ ok: boolean; earned?: boolean }>
|
|
notebookPages(playthroughId: string): Promise<{ id: string; text: string; createdAt: string }[]>
|
|
addNotebookPage(playthroughId: string, text: string, sourceUtteranceId?: string | null): Promise<{ id: string; text: string } | null>
|
|
removeNotebookPage(playthroughId: string, pageId: string): Promise<boolean>
|
|
phoneDirectory(playthroughId: string): Promise<{ available: boolean; numbers: { number: string; name: string }[] }>
|
|
dial(playthroughId: string, number: string): Promise<{ outcome: 'connect' | 'voicemail' | 'unknown'; name?: string; state?: PlaythroughState }>
|
|
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; 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; 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 -------------------------------------
|
|
|
|
// 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.
|
|
// earnedFlags gates player options: any utterance whose requires_flag isn't held is
|
|
// dropped (so it can't be offered). Pass undefined (authoring preview) to show all.
|
|
async function resolveDialogueGraph(nodeId: string, earnedFlags?: Set<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; awards_flag: string | null; requires_flag: 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,u.awards_flag,u.requires_flag,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 rows = earnedFlags ? utterances.rows.filter(row => !row.requires_flag || earnedFlags.has(row.requires_flag)) : utterances.rows
|
|
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 rows) if (row.parent_utterance_id) children.set(row.parent_utterance_id, [...(children.get(row.parent_utterance_id) || []), row.id])
|
|
const root = rows.find(row => !row.parent_utterance_id)
|
|
return {
|
|
rootId: root?.id ?? null,
|
|
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, awardsFlag: row.awards_flag,
|
|
childIds: children.get(row.id) || [], terminalKey: row.terminal_id ? (terminalKey.get(row.terminal_id) ?? null) : null,
|
|
}
|
|
}),
|
|
}
|
|
}
|
|
|
|
async function resolveNodeForPlay(nodeId: string, levelSlug: string | null, earnedFlags?: Set<string>): 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,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, earnedFlags)) }
|
|
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
|
|
}
|
|
|
|
// 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,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])
|
|
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 earned = new Set((await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1', [playthroughId])).rows.map(r => r.flag_key))
|
|
const node = row.current_node_id ? await resolveNodeForPlay(row.current_node_id, row.level_slug, earned) : 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; 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]),
|
|
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,
|
|
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,
|
|
}
|
|
}
|
|
|
|
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,phone_number,email) VALUES ($1,NULL,$2,$3,$4,$5,$6,$7)',
|
|
[npcId, npc.key, npc.name, npc.role || '', npc.defaultPose || null, npc.phoneNumber || null, npc.email || 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 }
|
|
},
|
|
|
|
resolveDialogue(nodeId) { return resolveDialogueGraph(nodeId) },
|
|
|
|
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 awardMeritWithin(client, playthroughId, entry)
|
|
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 ownsActiveLevel(userId, levelSlug) {
|
|
const result = await pool.query(`SELECT 1 FROM osint.playthroughs playthrough
|
|
JOIN osint.levels level ON level.id=playthrough.current_level_id
|
|
WHERE playthrough.user_id=$1 AND playthrough.status='active' AND level.slug=$2`, [userId,levelSlug])
|
|
return Boolean(result.rowCount)
|
|
},
|
|
|
|
// A dialogue line was reached in play: grant its authored achievement, but only if
|
|
// the utterance really belongs to the player's current node (so it can't be forged).
|
|
async reachUtterance(playthroughId, utteranceId) {
|
|
const row = (await pool.query<{ awards_flag: string | null; node_id: string; current_node_id: string | null }>(
|
|
`SELECT u.awards_flag,u.node_id,p.current_node_id FROM osint.utterances u
|
|
JOIN osint.playthroughs p ON p.id=$2 WHERE u.id=$1`, [utteranceId, playthroughId])).rows[0]
|
|
if (!row) return { ok: false }
|
|
if (!row.awards_flag || row.node_id !== row.current_node_id) return { ok: true, earned: false }
|
|
const result = await pool.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, row.awards_flag, row.node_id])
|
|
return { ok: true, earned: (result.rowCount || 0) > 0 }
|
|
},
|
|
|
|
// Field notebook: lines the player captured from NPCs during this playthrough.
|
|
async notebookPages(playthroughId) {
|
|
const rows = (await pool.query<{ id: string; text: string; created_at: Date }>(
|
|
'SELECT id,text,created_at FROM osint.notebook_pages WHERE playthrough_id=$1 ORDER BY created_at', [playthroughId])).rows
|
|
return rows.map(row => ({ id: row.id, text: row.text, createdAt: row.created_at.toISOString() }))
|
|
},
|
|
async addNotebookPage(playthroughId, text, sourceUtteranceId) {
|
|
const clean = text.trim()
|
|
if (!clean) return null
|
|
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
|
|
const row = (await pool.query<{ id: string }>(
|
|
'INSERT INTO osint.notebook_pages (playthrough_id,text,source_utterance_id) VALUES ($1,$2,$3) RETURNING id',
|
|
[playthroughId, clean, sourceUtteranceId || null])).rows[0]
|
|
return { id: row.id, text: clean }
|
|
},
|
|
async removeNotebookPage(playthroughId, pageId) {
|
|
const result = await pool.query('DELETE FROM osint.notebook_pages WHERE id=$1 AND playthrough_id=$2', [pageId, playthroughId])
|
|
return (result.rowCount || 0) > 0
|
|
},
|
|
|
|
// The phone directory available on the player's current node: the terminals of a
|
|
// phone node the current node is wired to. No connected phone node => nobody's listed.
|
|
async phoneDirectory(playthroughId) {
|
|
const pt = (await pool.query<{ current_node_id: string | null }>('SELECT current_node_id FROM osint.playthroughs WHERE id=$1', [playthroughId])).rows[0]
|
|
if (!pt?.current_node_id) return { available: false, numbers: [] }
|
|
const phoneNode = (await pool.query<{ id: string }>(
|
|
`SELECT pn.id FROM osint.story_node_terminals t JOIN osint.story_nodes pn ON pn.id=t.to_node_id
|
|
WHERE t.parent_node_id=$1 AND pn.node_type='phone' LIMIT 1`, [pt.current_node_id])).rows[0]
|
|
if (!phoneNode) return { available: true, numbers: [] }
|
|
const dir = (await pool.query<{ number: string; name: string }>(
|
|
`SELECT npc.phone_number AS number, npc.name FROM osint.story_node_terminals t JOIN osint.npcs npc ON npc.id=t.npc_id
|
|
WHERE t.parent_node_id=$1 AND npc.phone_number IS NOT NULL ORDER BY t.sort_order`, [phoneNode.id])).rows
|
|
return { available: true, numbers: dir }
|
|
},
|
|
|
|
// Resolve a dialed number: connect (advance to the wired dialogue), voicemail (a
|
|
// known contact with no line here), or not-in-service (no such number).
|
|
async dial(playthroughId, rawNumber) {
|
|
const number = rawNumber.replace(/\D/g, '')
|
|
if (!number) return { outcome: 'unknown' }
|
|
const pt = (await pool.query<{ current_node_id: string | null }>('SELECT current_node_id FROM osint.playthroughs WHERE id=$1', [playthroughId])).rows[0]
|
|
if (!pt?.current_node_id) return { outcome: 'unknown' }
|
|
const phoneNode = (await pool.query<{ id: string }>(
|
|
`SELECT pn.id FROM osint.story_node_terminals t JOIN osint.story_nodes pn ON pn.id=t.to_node_id
|
|
WHERE t.parent_node_id=$1 AND pn.node_type='phone' LIMIT 1`, [pt.current_node_id])).rows[0]
|
|
if (phoneNode) {
|
|
const term = (await pool.query<{ to_node_id: string | null; name: string }>(
|
|
`SELECT t.to_node_id, npc.name FROM osint.story_node_terminals t JOIN osint.npcs npc ON npc.id=t.npc_id
|
|
WHERE t.parent_node_id=$1 AND regexp_replace(npc.phone_number,'\\D','','g')=$2 LIMIT 1`, [phoneNode.id, number])).rows[0]
|
|
if (term?.to_node_id) {
|
|
await pool.query('UPDATE osint.playthroughs SET current_node_id=$2,current_level_id=NULL,updated_at=NOW() WHERE id=$1', [playthroughId, term.to_node_id])
|
|
const state = await stateForPlaythrough(playthroughId)
|
|
return { outcome: 'connect', name: term.name, state: state ?? undefined }
|
|
}
|
|
}
|
|
const npc = (await pool.query<{ name: string }>(
|
|
`SELECT name FROM osint.npcs WHERE regexp_replace(phone_number,'\\D','','g')=$1 AND mystery_id IS NULL LIMIT 1`, [number])).rows[0]
|
|
return npc ? { outcome: 'voicemail', name: npc.name } : { outcome: 'unknown' }
|
|
},
|
|
|
|
async listAchievements(playthroughId) {
|
|
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
|
|
const rows = (await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1 ORDER BY flag_key', [playthroughId])).rows
|
|
return rows.map(row => row.flag_key)
|
|
},
|
|
|
|
// Grant an achievement (idempotent). `earned` is true only on the first grant.
|
|
// The eventual server-side rule engine calls this same operation.
|
|
async awardAchievement(playthroughId, rawKey, nodeId) {
|
|
const key = rawKey.trim()
|
|
if (!/^[a-z][a-z0-9_.-]{0,63}$/.test(key)) return { ok: false, earned: false, error: 'Invalid achievement key' }
|
|
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return { ok: false, earned: false, error: 'Playthrough not found' }
|
|
const result = await pool.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, key, nodeId || null])
|
|
return { ok: true, earned: (result.rowCount || 0) > 0 }
|
|
},
|
|
|
|
// Dev teleport: jump the playthrough straight to an explicit node (no gate
|
|
// resolution). Instantiates a fresh level clone for level nodes. Powers /node/:id.
|
|
async gotoNode(userId, playthroughId, nodeId) {
|
|
const client = await pool.connect()
|
|
try {
|
|
await client.query('BEGIN')
|
|
const playthrough = (await client.query<{ mystery_id: string; mystery_slug: string }>(
|
|
`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; 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)
|
|
return { ok: true, state: state ?? undefined }
|
|
},
|
|
|
|
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; current_level_id: string | null; mystery_slug: string }>(
|
|
`SELECT p.current_node_id,p.current_level_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 currentNode = (await client.query<{ node_type:string }>('SELECT node_type FROM osint.story_nodes WHERE id=$1', [playthrough.current_node_id])).rows[0]
|
|
if (currentNode?.node_type === 'level' && playthrough.current_level_id) {
|
|
const pendingGoals = (await client.query<{ goal_key:string; title:string }>(`SELECT goal.goal_key,goal.title
|
|
FROM osint.level_goals goal JOIN osint.levels level ON level.board_id=goal.board_id
|
|
WHERE level.id=$1 AND goal.enabled AND (
|
|
NOT EXISTS (SELECT 1 FROM osint.level_goal_flag_requirements requirement WHERE requirement.goal_id=goal.id)
|
|
OR EXISTS (
|
|
SELECT 1 FROM osint.level_goal_flag_requirements requirement
|
|
WHERE requirement.goal_id=goal.id AND NOT EXISTS (
|
|
SELECT 1 FROM osint.level_flags flag
|
|
WHERE flag.level_id=level.id AND flag.flag_key=requirement.flag_key
|
|
)
|
|
)
|
|
) ORDER BY goal.created_at,goal.id`, [playthrough.current_level_id])).rows
|
|
if (pendingGoals.length) {
|
|
await client.query('ROLLBACK')
|
|
return { ok:false,error:'Complete the level objective before continuing',errorCode:'goals_incomplete',
|
|
pendingGoals:pendingGoals.map(goal => ({ key:goal.goal_key,title:goal.title })) }
|
|
}
|
|
const reportIncomplete = (await client.query<{ required:boolean;accepted:boolean }>(`SELECT report.required_for_completion AS required,
|
|
EXISTS (SELECT 1 FROM osint.case_report_submissions submission
|
|
WHERE submission.level_id=level.id AND submission.status='accepted') AS accepted
|
|
FROM osint.levels level JOIN osint.case_reports report ON report.board_id=level.board_id
|
|
WHERE level.id=$1`,[playthrough.current_level_id])).rows[0]
|
|
if (reportIncomplete?.required && !reportIncomplete.accepted) {
|
|
await client.query('ROLLBACK')
|
|
return { ok:false,error:'Submit an accepted case report before continuing',errorCode:'report_incomplete' }
|
|
}
|
|
await client.query(`INSERT INTO osint.achievements (playthrough_id,flag_key,awarded_by_node_id)
|
|
SELECT $1,requirement.flag_key,$3 FROM osint.levels level
|
|
JOIN osint.level_goals goal ON goal.board_id=level.board_id AND goal.enabled
|
|
JOIN osint.level_goal_flag_requirements requirement ON requirement.goal_id=goal.id
|
|
JOIN osint.level_flags flag ON flag.level_id=level.id AND flag.flag_key=requirement.flag_key
|
|
WHERE level.id=$2 ON CONFLICT (playthrough_id,flag_key) DO NOTHING`,
|
|
[playthroughId,playthrough.current_level_id,playthrough.current_node_id])
|
|
}
|
|
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 awardMeritWithin(client, playthroughId, target)
|
|
}
|
|
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 }
|
|
},
|
|
|
|
// 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
|
|
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) }))
|
|
},
|
|
|
|
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
|
|
}
|
|
},
|
|
|
|
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,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,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)
|
|
},
|
|
|
|
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)
|
|
},
|
|
}
|
|
}
|