Add story-graph narrative system (campaigns, editors, runtime)
Introduce the narrative layer as a directed story-flow graph: an authored campaign a player walks node by node, replacing the interim slot/chapter model. Schema (migrations 015-021): - mysteries, global NPC templates + named poses, per-user playthroughs - story_nodes, terminals, utterances (the flow graph and dialogue trees) - clean cutover: retire slot cutscenes/chapters/seen_dialogue Runtime: - New Game creates a playthrough bound to the JWT identity (dev test-user fallback) - advance() walks the graph cutscene -> dialogue -> level -> ..., auto-skipping gates - branching dialogue: player choices route out through node terminals Admin authoring: - NPC editor: upload named poses to the gupi MinIO bucket - mystery graph editor: vertical node canvas, wiring, entrypoint, delete-by-click - dialogue crafter: utterance tree, Tab to add child, 1/2 speaker, undo Content authored via the manifest importer / admin panel and seeded for Glass Harbour. MinIO added to the dev stack; dev container runs in development mode. Also includes a folder-widget simplification (removes open/close) and a resolveUserId auth helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,19 @@ export function hasAdminClaim(req: Request) {
|
||||
return req.authClaims?.role === 'admin' || req.authClaims?.isAdmin === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity for a player's game state. Real players arrive with a JWT issued by
|
||||
* glitch.university (verified through the key-exchange handoff); until that lands,
|
||||
* an absent token resolves to a single fixed development user so the game is
|
||||
* playable locally with no identity provider. Only this fallback branch changes
|
||||
* when the external handoff is wired — the `user_id` column stays the same.
|
||||
*/
|
||||
export const DEVELOPMENT_TEST_USER_ID = 'osint-test-player'
|
||||
export function resolveUserId(req: Request): string {
|
||||
const sub = req.authClaims?.sub
|
||||
return typeof sub === 'string' && sub.length > 0 ? sub : DEVELOPMENT_TEST_USER_ID
|
||||
}
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||
if (!hasAdminClaim(req)) return res.status(403).json({ error: 'Administrator claim required' })
|
||||
next()
|
||||
|
||||
+189
-1
@@ -8,8 +8,10 @@ import { fileURLToPath } from 'node:url'
|
||||
import multer from 'multer'
|
||||
import pg from 'pg'
|
||||
import type { CaseState } from '../src/types.js'
|
||||
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin } from './auth.js'
|
||||
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolveUserId } from './auth.js'
|
||||
import { createLevelRepository } from './levelRepository.js'
|
||||
import { createNarrativeRepository } from './narrativeRepository.js'
|
||||
import { createStoryGraphRepository, type StoryNodeType } from './storyGraphRepository.js'
|
||||
import { createObjectStorageFromEnv } from './objectStorage.js'
|
||||
|
||||
const { Pool } = pg
|
||||
@@ -24,6 +26,9 @@ const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
|
||||
const objectStorage = createObjectStorageFromEnv()
|
||||
await objectStorage.initialize()
|
||||
const levels = createLevelRepository(pool, editingEnabled, objectStorage)
|
||||
const narrative = createNarrativeRepository(pool, objectStorage)
|
||||
const storyGraph = createStoryGraphRepository(pool)
|
||||
const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate']
|
||||
|
||||
function wantsEdit(req: express.Request) {
|
||||
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
|
||||
@@ -131,6 +136,189 @@ app.post('/api/levels/:id/reset', async (req, res, next) => {
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
// Admin authoring panel: NPC template library and mystery listing. Reads require an
|
||||
// admin claim; writes additionally require editing to be enabled on this deployment.
|
||||
function requireEditing(res: express.Response) {
|
||||
if (!editingEnabled) { res.status(403).json({ error: 'Level editing is disabled' }); return false }
|
||||
return true
|
||||
}
|
||||
app.get('/api/admin/mysteries', requireAdmin, async (_req, res, next) => {
|
||||
try { res.json(await narrative.listMysteries()) } catch (error) { next(error) }
|
||||
})
|
||||
app.get('/api/admin/npcs', requireAdmin, async (_req, res, next) => {
|
||||
try { res.json(await narrative.listNpcs()) } catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/admin/npcs', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
if (!req.body?.key) return res.status(400).json({ error: 'An NPC key is required' })
|
||||
res.status(201).json(await narrative.createNpc({ key: String(req.body.key), name: String(req.body.name || ''), role: String(req.body.role || ''), defaultPose: req.body.defaultPose || null }))
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.patch('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const npc = await narrative.updateNpc(String(req.params.id), { name: req.body?.name, role: req.body?.role, defaultPose: req.body?.defaultPose })
|
||||
npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.delete('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const outcome = await narrative.deleteNpc(String(req.params.id))
|
||||
if (outcome === 'deleted') return res.json({ ok: true })
|
||||
res.status(outcome === 'in_use' ? 409 : 404).json({ error: outcome === 'in_use' ? 'NPC is used by a cutscene' : 'NPC not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/admin/npcs/:id/poses', requireAdmin, upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
if (!req.file) return res.status(400).json({ error: 'An image file is required' })
|
||||
if (!req.body?.poseKey) return res.status(400).json({ error: 'A pose key is required' })
|
||||
const npc = await narrative.addPose(String(req.params.id), String(req.body.poseKey), req.file)
|
||||
npc ? res.status(201).json(npc) : res.status(404).json({ error: 'NPC not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.delete('/api/admin/npcs/:id/poses/:poseKey', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const npc = await narrative.deletePose(String(req.params.id), String(req.params.poseKey))
|
||||
npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
// Story flow graph authoring (Phase 1): nodes, terminals, wiring, entrypoint.
|
||||
app.get('/api/admin/level-templates', requireAdmin, async (_req, res, next) => {
|
||||
try { res.json(await storyGraph.listLevelTemplates()) } catch (error) { next(error) }
|
||||
})
|
||||
app.get('/api/admin/mysteries/:id/graph', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const graph = await storyGraph.getGraph(String(req.params.id))
|
||||
graph ? res.json(graph) : res.status(404).json({ error: 'Mystery not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/admin/mysteries/:id/nodes', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const nodeType = String(req.body?.nodeType) as StoryNodeType
|
||||
if (!STORY_NODE_TYPES.includes(nodeType)) return res.status(400).json({ error: 'Unknown node type' })
|
||||
const node = await storyGraph.createNode(String(req.params.id), { nodeType, xpos: Number(req.body?.xpos) || 0, ypos: Number(req.body?.ypos) || 0, label: req.body?.label })
|
||||
node ? res.status(201).json(node) : res.status(404).json({ error: 'Mystery not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.patch('/api/admin/story-nodes/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const node = await storyGraph.updateNode(String(req.params.id), req.body || {})
|
||||
node ? res.json(node) : res.status(404).json({ error: 'Node not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.delete('/api/admin/story-nodes/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const ok = await storyGraph.deleteNode(String(req.params.id))
|
||||
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Node not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/admin/story-nodes/:id/terminals', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
if (!req.body?.terminalKey) return res.status(400).json({ error: 'A terminal key is required' })
|
||||
const node = await storyGraph.addTerminal(String(req.params.id), { terminalKey: String(req.body.terminalKey), label: req.body?.label })
|
||||
node ? res.status(201).json(node) : res.status(404).json({ error: 'Node not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.patch('/api/admin/story-terminals/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const result = await storyGraph.updateTerminal(String(req.params.id), req.body || {})
|
||||
result.ok ? res.json({ ok: true }) : res.status(result.error === 'Terminal not found' ? 404 : 400).json({ error: result.error })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.delete('/api/admin/story-terminals/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const ok = await storyGraph.deleteTerminal(String(req.params.id))
|
||||
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Terminal not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.put('/api/admin/mysteries/:id/entry', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const result = await storyGraph.setEntryNode(String(req.params.id), req.body?.nodeId ?? null)
|
||||
result.ok ? res.json({ ok: true }) : res.status(400).json({ error: result.error })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/admin/mysteries/:id/graph', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
if (!req.body?.entry || !Array.isArray(req.body?.nodes)) return res.status(400).json({ error: 'A graph spec needs entry and nodes' })
|
||||
const result = await storyGraph.authorGraph(String(req.params.id), req.body)
|
||||
result ? res.status(201).json(result) : res.status(404).json({ error: 'Mystery not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
// Utterance sub-graph (dialogue crafter).
|
||||
app.get('/api/admin/story-nodes/:id/utterances', requireAdmin, async (req, res, next) => {
|
||||
try { res.json(await storyGraph.listUtterances(String(req.params.id))) } catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/admin/story-nodes/:id/utterances', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const utterer = req.body?.utterer === 'player' ? 'player' : 'npc'
|
||||
const utterance = await storyGraph.createUtterance(String(req.params.id), { utterer, xpos: Number(req.body?.xpos) || 0, ypos: Number(req.body?.ypos) || 0, text: req.body?.text })
|
||||
utterance ? res.status(201).json(utterance) : res.status(404).json({ error: 'Node not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.patch('/api/admin/utterances/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const result = await storyGraph.updateUtterance(String(req.params.id), req.body || {})
|
||||
result.ok ? res.json({ ok: true }) : res.status(result.error === 'Utterance not found' ? 404 : 400).json({ error: result.error })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.delete('/api/admin/utterances/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!requireEditing(res)) return
|
||||
const ok = await storyGraph.deleteUtterance(String(req.params.id))
|
||||
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Utterance not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
// Narrative authoring: create a mystery and its NPC cast. The flow (cutscenes,
|
||||
// dialogue, levels) lives in the story graph, seeded separately.
|
||||
app.post('/api/mysteries', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
|
||||
const body = req.body || {}
|
||||
if (!body.slug || !body.title) return res.status(400).json({ error: 'A mystery requires slug and title' })
|
||||
const created = await narrative.authorMystery({ slug: slug(body.slug, body.slug), title: String(body.title), cast: Array.isArray(body.cast) ? body.cast : [] })
|
||||
res.status(201).json(created)
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
// New Game creates a playthrough bound to the caller's identity.
|
||||
app.post('/api/playthroughs', async (req, res, next) => {
|
||||
try {
|
||||
const result = await narrative.createPlaythrough(resolveUserId(req), req.body?.mystery ? slug(req.body.mystery, req.body.mystery) : undefined)
|
||||
result ? res.status(201).json(result) : res.status(404).json({ error: 'No mystery available' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.get('/api/playthroughs/current', async (req, res, next) => {
|
||||
try {
|
||||
const result = await narrative.getCurrentPlaythrough(resolveUserId(req))
|
||||
result ? res.json(result) : res.status(204).end()
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
// Advance the playthrough through the story graph (follows a terminal; auto-skips
|
||||
// gates; instantiates the board when entering a level node).
|
||||
app.post('/api/playthroughs/:id/advance', async (req, res, next) => {
|
||||
try {
|
||||
const result = await narrative.advancePlaythrough(resolveUserId(req), String(req.params.id), req.body?.terminalKey)
|
||||
result.ok ? res.json(result.state ?? null) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
if (error instanceof multer.MulterError) {
|
||||
return res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ error: error.code === 'LIMIT_FILE_SIZE' ? 'Document exceeds the upload limit' : error.message })
|
||||
|
||||
@@ -33,7 +33,7 @@ suite('PostgreSQL migrations', () => {
|
||||
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
||||
const firstRun: string[] = []
|
||||
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
|
||||
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(12)
|
||||
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(21)
|
||||
|
||||
const client = new Client({ connectionString: testDatabaseUrl })
|
||||
await client.connect()
|
||||
@@ -43,11 +43,13 @@ suite('PostgreSQL migrations', () => {
|
||||
'boards', 'levels', 'level_templates', 'level_template_versions', 'exhibits', 'folder_exhibits',
|
||||
'document_exhibits', 'folder_memberships', 'exhibit_connections', 'metadata_fields', 'assets', 'schema_migrations',
|
||||
'party_exhibits', 'person_parties', 'organization_parties', 'brief_concepts', 'level_briefs',
|
||||
'board_timeline_settings',
|
||||
'board_views', 'timeline_views',
|
||||
'mysteries', 'npcs', 'npc_poses', 'playthroughs',
|
||||
'story_nodes', 'story_node_terminals', 'utterances',
|
||||
]))
|
||||
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'playthroughs']))
|
||||
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue']))
|
||||
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
|
||||
expect(ledger.rows[0].count).toBe('12')
|
||||
expect(ledger.rows[0].count).toBe('21')
|
||||
const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`)
|
||||
expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset']))
|
||||
const eventOccurrence = await client.query<{ is_nullable: string }>(`SELECT is_nullable FROM information_schema.columns WHERE table_schema='osint' AND table_name='event_exhibits' AND column_name='occurred_at'`)
|
||||
@@ -56,7 +58,7 @@ suite('PostgreSQL migrations', () => {
|
||||
|
||||
const secondRun: string[] = []
|
||||
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
|
||||
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(12)
|
||||
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(21)
|
||||
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createServer } from 'node:net'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import pg from 'pg'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { PlaythroughState } from './narrativeRepository.js'
|
||||
import { runMigrations } from './migrations.js'
|
||||
|
||||
const { Client } = pg
|
||||
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
|
||||
const suite = baseDatabaseUrl ? describe : describe.skip
|
||||
const databaseName = `osint_narrative_test_${process.pid}_${Date.now()}`
|
||||
let adminClient: InstanceType<typeof Client>
|
||||
let appServer: Awaited<typeof import('./index.js')>['server']
|
||||
let appPool: Awaited<typeof import('./index.js')>['pool']
|
||||
let baseUrl = ''
|
||||
let adminAuthorization = ''
|
||||
|
||||
function authFetch(url: string, authorization?: string, init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers)
|
||||
if (authorization) headers.set('authorization', authorization)
|
||||
return fetch(url, { ...init, headers })
|
||||
}
|
||||
|
||||
async function availablePort() {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const probe = createServer()
|
||||
probe.once('error', reject)
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const address = probe.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
probe.close(error => error ? reject(error) : resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
suite('narrative graph runtime', () => {
|
||||
beforeAll(async () => {
|
||||
const adminUrl = new URL(baseDatabaseUrl!)
|
||||
adminUrl.pathname = '/postgres'
|
||||
adminClient = new Client({ connectionString: adminUrl.toString() })
|
||||
await adminClient.connect()
|
||||
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
|
||||
const testUrl = new URL(baseDatabaseUrl!)
|
||||
testUrl.pathname = `/${databaseName}`
|
||||
const databaseUrl = testUrl.toString()
|
||||
await runMigrations(databaseUrl, path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations'), () => undefined)
|
||||
|
||||
const port = await availablePort()
|
||||
process.env.DATABASE_URL = databaseUrl
|
||||
process.env.LEVEL_EDITING_ENABLED = 'true'
|
||||
process.env.JWT_SECRET = 'osint-narrative-jwt-secret'
|
||||
process.env.ASSET_STORAGE_DRIVER = 'memory'
|
||||
process.env.PORT = String(port)
|
||||
const serverModule = await import('./index.js')
|
||||
appServer = serverModule.server
|
||||
appPool = serverModule.pool
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
adminAuthorization = `Bearer ${jwt.sign({ sub: 'integration-admin', role: 'admin' }, process.env.JWT_SECRET)}`
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (appServer) await new Promise<void>((resolve, reject) => appServer.close(error => error ? reject(error) : resolve()))
|
||||
if (appPool) await appPool.end()
|
||||
if (!adminClient) return
|
||||
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
|
||||
await adminClient.end()
|
||||
})
|
||||
|
||||
it('New Game walks the seeded graph: cutscene → dialogue → level → finished', async () => {
|
||||
const json = { 'content-type': 'application/json' }
|
||||
// A frozen level template to back the level node.
|
||||
await authFetch(`${baseUrl}/api/levels`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({ id: 'gr-src', title: 'Runtime Source' }) })
|
||||
await authFetch(`${baseUrl}/api/levels/gr-src/templates?edit=1`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({ name: 'Runtime Chapter', slug: 'gr-mystery-chapter' }) })
|
||||
// Mystery + cast, then seed a linear graph.
|
||||
await authFetch(`${baseUrl}/api/mysteries?edit=1`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({ slug: 'gr-mystery', title: 'Runtime Mystery', cast: [{ key: 'prof', name: 'Prof. Test', role: 'GU' }] }) })
|
||||
const mysteries = await (await authFetch(`${baseUrl}/api/admin/mysteries`, adminAuthorization)).json() as { id: string; slug: string }[]
|
||||
const mysteryId = mysteries.find(m => m.slug === 'gr-mystery')!.id
|
||||
const seed = await authFetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({
|
||||
entry: 'intro',
|
||||
nodes: [
|
||||
{ key: 'intro', type: 'cutscene', label: 'Title', componentKey: 'runtime-title', x: 0, y: 0, terminals: [{ key: 'continue', to: 'brief' }] },
|
||||
{ key: 'brief', type: 'dialogue', label: 'Briefing', x: 200, y: 0, terminals: [{ key: 'continue', to: 'level' }], utterances: [{ npc: 'prof', text: 'Welcome.' }, { npc: 'prof', text: 'Investigate.' }] },
|
||||
{ key: 'level', type: 'level', label: 'Board', templateSlug: 'gr-mystery-chapter', x: 400, y: 0, terminals: [{ key: 'report_back', to: 'debrief' }] },
|
||||
{ key: 'debrief', type: 'dialogue', label: 'Debrief', x: 600, y: 0, terminals: [{ key: 'continue', to: null }], utterances: [{ npc: 'prof', text: 'Case closed.' }] },
|
||||
],
|
||||
}) })
|
||||
expect(seed.status).toBe(201)
|
||||
|
||||
// New Game lands on the entry cutscene.
|
||||
const created = await authFetch(`${baseUrl}/api/playthroughs`, undefined, { method: 'POST', headers: json, body: '{}' })
|
||||
expect(created.status).toBe(201)
|
||||
const start = await created.json() as PlaythroughState
|
||||
expect(start.node?.kind).toBe('cutscene')
|
||||
expect(start.node?.componentKey).toBe('runtime-title')
|
||||
const id = start.playthrough.id
|
||||
|
||||
// Advance into the briefing dialogue (NPC utterances become steps).
|
||||
const brief = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState
|
||||
expect(brief.node?.kind).toBe('dialogue')
|
||||
expect(brief.node?.utterances).toHaveLength(2)
|
||||
const root = brief.node?.utterances?.find(u => u.id === brief.node?.rootId)
|
||||
expect(root).toMatchObject({ text: 'Welcome.', speaker: { name: 'Prof. Test' } })
|
||||
expect(root?.childIds).toHaveLength(1) // linear parent-chain
|
||||
|
||||
// Advance into the level (a board is instantiated and loadable).
|
||||
const level = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState
|
||||
expect(level.node?.kind).toBe('level')
|
||||
expect(level.node?.levelSlug).toMatch(/^gr-mystery-play-/)
|
||||
expect((await fetch(`${baseUrl}/api/levels/${level.node!.levelSlug}`)).status).toBe(200)
|
||||
|
||||
// Report back → debrief dialogue.
|
||||
const debrief = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState
|
||||
expect(debrief.node?.kind).toBe('dialogue')
|
||||
|
||||
// Final advance → finished; current returns nothing active.
|
||||
const done = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState
|
||||
expect(done.playthrough.status).toBe('finished')
|
||||
expect(done.node).toBeNull()
|
||||
expect((await authFetch(`${baseUrl}/api/playthroughs/current`, undefined)).status).toBe(204)
|
||||
|
||||
// Identity scoping: another user has no playthrough and cannot advance this one.
|
||||
const playerTwo = `Bearer ${jwt.sign({ sub: 'player-two' }, process.env.JWT_SECRET!)}`
|
||||
expect((await authFetch(`${baseUrl}/api/playthroughs/current`, playerTwo)).status).toBe(204)
|
||||
expect((await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, playerTwo, { method: 'POST', headers: json, body: '{}' })).status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolvePoseAssetId } from './narrativeRepository.js'
|
||||
|
||||
describe('resolvePoseAssetId', () => {
|
||||
const poses = { neutral: 'asset-neutral', concerned: 'asset-concerned', missing: null }
|
||||
|
||||
it('returns the requested pose asset when present', () => {
|
||||
expect(resolvePoseAssetId(poses, 'concerned', 'neutral')).toBe('asset-concerned')
|
||||
})
|
||||
it('falls back to the NPC default pose when the requested pose is absent', () => {
|
||||
expect(resolvePoseAssetId(poses, 'pointing', 'neutral')).toBe('asset-neutral')
|
||||
})
|
||||
it('falls back to the default when the requested pose exists but has no artwork', () => {
|
||||
expect(resolvePoseAssetId(poses, 'missing', 'neutral')).toBe('asset-neutral')
|
||||
})
|
||||
it('returns null (no artwork) when neither requested nor default resolves', () => {
|
||||
expect(resolvePoseAssetId(poses, 'pointing', 'also-missing')).toBeNull()
|
||||
expect(resolvePoseAssetId(poses, null, null)).toBeNull()
|
||||
expect(resolvePoseAssetId({}, 'neutral', 'neutral')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
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 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
|
||||
componentKey?: string | null; levelSlug?: string | null
|
||||
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 }>
|
||||
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[]>
|
||||
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>
|
||||
}
|
||||
|
||||
type GraphNodeRow = { id: string; node_type: string; label: string; component_key: string | null; level_template_version_id: string | null }
|
||||
|
||||
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> {
|
||||
const node = (await pool.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0]
|
||||
if (!node) return null
|
||||
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key }
|
||||
if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug }
|
||||
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, ...(await resolveDialogueGraph(node.id)) }
|
||||
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 }
|
||||
},
|
||||
|
||||
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) }))
|
||||
},
|
||||
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { createServer } from 'node:net'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import pg from 'pg'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { StoryGraphDto, StoryNodeDto, LevelTemplateOption, UtteranceDto } from './storyGraphRepository.js'
|
||||
import { runMigrations } from './migrations.js'
|
||||
|
||||
const { Client } = pg
|
||||
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
|
||||
const suite = baseDatabaseUrl ? describe : describe.skip
|
||||
const databaseName = `osint_storygraph_test_${process.pid}_${Date.now()}`
|
||||
let adminClient: InstanceType<typeof Client>
|
||||
let appServer: Awaited<typeof import('./index.js')>['server']
|
||||
let appPool: Awaited<typeof import('./index.js')>['pool']
|
||||
let baseUrl = ''
|
||||
let auth = ''
|
||||
|
||||
async function availablePort() {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const probe = createServer()
|
||||
probe.once('error', reject)
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const address = probe.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
probe.close(error => error ? reject(error) : resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
const json = { 'content-type': 'application/json' }
|
||||
function admin(url: string, init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers); headers.set('authorization', auth)
|
||||
return fetch(url, { ...init, headers })
|
||||
}
|
||||
|
||||
suite('story graph authoring API', () => {
|
||||
beforeAll(async () => {
|
||||
const adminUrl = new URL(baseDatabaseUrl!); adminUrl.pathname = '/postgres'
|
||||
adminClient = new Client({ connectionString: adminUrl.toString() }); await adminClient.connect()
|
||||
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
|
||||
const testUrl = new URL(baseDatabaseUrl!); testUrl.pathname = `/${databaseName}`
|
||||
const databaseUrl = testUrl.toString()
|
||||
await runMigrations(databaseUrl, path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations'), () => undefined)
|
||||
const port = await availablePort()
|
||||
process.env.DATABASE_URL = databaseUrl
|
||||
process.env.LEVEL_EDITING_ENABLED = 'true'
|
||||
process.env.JWT_SECRET = 'osint-storygraph-jwt'
|
||||
process.env.ASSET_STORAGE_DRIVER = 'memory'
|
||||
process.env.PORT = String(port)
|
||||
const serverModule = await import('./index.js')
|
||||
appServer = serverModule.server; appPool = serverModule.pool
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
auth = `Bearer ${jwt.sign({ sub: 'sg-admin', role: 'admin' }, process.env.JWT_SECRET)}`
|
||||
})
|
||||
afterAll(async () => {
|
||||
if (appServer) await new Promise<void>((resolve, reject) => appServer.close(error => error ? reject(error) : resolve()))
|
||||
if (appPool) await appPool.end()
|
||||
if (!adminClient) return
|
||||
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
|
||||
await adminClient.end()
|
||||
})
|
||||
|
||||
async function makeMystery(slug: string) {
|
||||
await admin(`${baseUrl}/api/levels`, { method: 'POST', headers: json, body: JSON.stringify({ id: `${slug}-src`, title: 'SG Source' }) })
|
||||
await admin(`${baseUrl}/api/levels/${slug}-src/templates?edit=1`, { method: 'POST', headers: json, body: JSON.stringify({ name: `${slug} chapter` }) })
|
||||
await admin(`${baseUrl}/api/mysteries?edit=1`, { method: 'POST', headers: json, body: JSON.stringify({ slug, title: slug, chapters: [{ templateSlug: `${slug}-chapter` }], cast: [], cutscenes: [] }) })
|
||||
const list = await (await admin(`${baseUrl}/api/admin/mysteries`)).json() as { id: string; slug: string }[]
|
||||
return list.find(m => m.slug === slug)!.id
|
||||
}
|
||||
|
||||
it('builds a node graph: create, wire, configure, set entry', async () => {
|
||||
const mysteryId = await makeMystery('sg-mystery')
|
||||
const templates = await (await admin(`${baseUrl}/api/admin/level-templates`)).json() as LevelTemplateOption[]
|
||||
const chapter = templates.find(t => t.slug === 'sg-mystery-chapter')!
|
||||
|
||||
const cutscene = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 40, ypos: 40 }) })).json() as StoryNodeDto
|
||||
const level = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'level', xpos: 320, ypos: 40 }) })).json() as StoryNodeDto
|
||||
expect(cutscene.terminals).toHaveLength(1)
|
||||
expect(cutscene.terminals[0].terminalKey).toBe('continue')
|
||||
expect(level.terminals[0].terminalKey).toBe('report_back')
|
||||
|
||||
// Wire cutscene → level; configure level template; set entrypoint.
|
||||
expect((await admin(`${baseUrl}/api/admin/story-terminals/${cutscene.terminals[0].id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ toNodeId: level.id }) })).status).toBe(200)
|
||||
await admin(`${baseUrl}/api/admin/story-nodes/${level.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ levelTemplateVersionId: chapter.versionId }) })
|
||||
await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/entry`, { method: 'PUT', headers: json, body: JSON.stringify({ nodeId: cutscene.id }) })
|
||||
|
||||
const graph = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`)).json() as StoryGraphDto
|
||||
expect(graph.nodes).toHaveLength(2)
|
||||
expect(graph.entryNodeId).toBe(cutscene.id)
|
||||
expect(graph.nodes.find(n => n.id === cutscene.id)!.terminals[0].toNodeId).toBe(level.id)
|
||||
expect(graph.nodes.find(n => n.id === level.id)!.levelTemplateVersionId).toBe(chapter.versionId)
|
||||
|
||||
// Deleting the target node unwires (SET NULL) rather than deleting the source's port.
|
||||
await admin(`${baseUrl}/api/admin/story-nodes/${level.id}`, { method: 'DELETE' })
|
||||
const after = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`)).json() as StoryGraphDto
|
||||
expect(after.nodes).toHaveLength(1)
|
||||
expect(after.nodes[0].terminals[0].toNodeId).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects wiring a terminal across mysteries', async () => {
|
||||
const mysteryA = await makeMystery('sg-a')
|
||||
const mysteryB = await makeMystery('sg-b')
|
||||
const nodeA = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryA}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 0, ypos: 0 }) })).json() as StoryNodeDto
|
||||
const nodeB = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryB}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 0, ypos: 0 }) })).json() as StoryNodeDto
|
||||
const cross = await admin(`${baseUrl}/api/admin/story-terminals/${nodeA.terminals[0].id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ toNodeId: nodeB.id }) })
|
||||
expect(cross.status).toBe(400)
|
||||
})
|
||||
|
||||
it('crafts utterances: NPC prompt, player option, wiring and same-node validation', async () => {
|
||||
const mysteryId = await makeMystery('sg-utt')
|
||||
const dialogue = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'dialogue', xpos: 0, ypos: 0 }) })).json() as StoryNodeDto
|
||||
const terminalId = dialogue.terminals[0].id
|
||||
const otherNode = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'level', xpos: 400, ypos: 0 }) })).json() as StoryNodeDto
|
||||
|
||||
const prompt = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`, { method: 'POST', headers: json, body: JSON.stringify({ utterer: 'npc', xpos: 40, ypos: 40, text: 'Are you ready?' }) })).json() as UtteranceDto
|
||||
const yes = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`, { method: 'POST', headers: json, body: JSON.stringify({ utterer: 'player', xpos: 300, ypos: 40, text: 'Yes' }) })).json() as UtteranceDto
|
||||
|
||||
// 'Yes' hangs under the prompt (parent); and exits via the node's terminal.
|
||||
expect((await admin(`${baseUrl}/api/admin/utterances/${yes.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ parentUtteranceId: prompt.id }) })).status).toBe(200)
|
||||
expect((await admin(`${baseUrl}/api/admin/utterances/${yes.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ terminalId }) })).status).toBe(200)
|
||||
|
||||
const list = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`)).json() as UtteranceDto[]
|
||||
const savedYes = list.find(u => u.id === yes.id)!
|
||||
expect(savedYes.parentUtteranceId).toBe(prompt.id)
|
||||
expect(savedYes.terminalId).toBe(terminalId)
|
||||
|
||||
// A terminal from another node cannot be used as this utterance's exit.
|
||||
const foreign = await admin(`${baseUrl}/api/admin/utterances/${yes.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ terminalId: otherNode.terminals[0].id }) })
|
||||
expect(foreign.status).toBe(400)
|
||||
|
||||
// Deleting the NPC prompt cascades its player options.
|
||||
await admin(`${baseUrl}/api/admin/utterances/${prompt.id}`, { method: 'DELETE' })
|
||||
const after = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`)).json() as UtteranceDto[]
|
||||
expect(after).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses graph writes without an admin claim', async () => {
|
||||
const mysteryId = await makeMystery('sg-guard')
|
||||
expect((await fetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 0, ypos: 0 }) })).status).toBe(403)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Pool, PoolClient } from 'pg'
|
||||
|
||||
export type GraphSpecNode = {
|
||||
key: string; type: StoryNodeType; label?: string; x: number; y: number
|
||||
componentKey?: string; templateSlug?: string; version?: number
|
||||
terminals?: { key: string; label?: string; to?: string | null }[]
|
||||
utterances?: { npc?: string; pose?: string; text: string; utterer?: Utterer }[]
|
||||
}
|
||||
export type GraphSpec = { entry: string; nodes: GraphSpecNode[] }
|
||||
|
||||
export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
|
||||
export type TerminalDto = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number }
|
||||
export type StoryNodeDto = {
|
||||
id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean
|
||||
xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null
|
||||
terminals: TerminalDto[]
|
||||
}
|
||||
export type StoryGraphDto = { mysteryId: string; entryNodeId: string | null; nodes: StoryNodeDto[] }
|
||||
export type LevelTemplateOption = { versionId: string; slug: string; name: string; version: number }
|
||||
export type Utterer = 'npc' | 'player'
|
||||
export type UtteranceDto = {
|
||||
id: string; nodeId: string; utterer: Utterer; npcId: string | null; poseKey: string | null; text: string
|
||||
parentUtteranceId: string | null; advancesToUtteranceId: string | null; terminalId: string | null
|
||||
effect: string | null; xpos: number; ypos: number; sortOrder: number
|
||||
}
|
||||
|
||||
// A sensible starter terminal set so a freshly dropped node is immediately wireable.
|
||||
const DEFAULT_TERMINALS: Record<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' }],
|
||||
}
|
||||
|
||||
export interface StoryGraphRepository {
|
||||
getGraph(mysteryId: string): Promise<StoryGraphDto | null>
|
||||
createNode(mysteryId: string, input: { nodeType: StoryNodeType; xpos: number; ypos: number; label?: string }): Promise<StoryNodeDto | null>
|
||||
updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null }>): Promise<StoryNodeDto | null>
|
||||
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>
|
||||
updateUtterance(id: string, input: Partial<{ text: string; utterer: Utterer; npcId: string | null; poseKey: string | null; xpos: number; ypos: number; parentUtteranceId: string | null; advancesToUtteranceId: string | null; terminalId: string | null; effect: string | null }>): Promise<{ ok: boolean; error?: string }>
|
||||
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([
|
||||
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 }>(
|
||||
'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key FROM osint.story_nodes WHERE mystery_id=$1 ORDER BY created_at', [mysteryId]),
|
||||
pool.query<{ id: string; parent_node_id: string; terminal_key: string; label: string; to_node_id: string | null; sort_order: number }>(
|
||||
`SELECT t.id,t.parent_node_id,t.terminal_key,t.label,t.to_node_id,t.sort_order FROM osint.story_node_terminals t
|
||||
JOIN osint.story_nodes n ON n.id=t.parent_node_id WHERE n.mystery_id=$1 ORDER BY t.sort_order,t.terminal_key`, [mysteryId]),
|
||||
])
|
||||
const byNode = new Map<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,
|
||||
xpos: row.xpos, ypos: row.ypos, levelTemplateVersionId: row.level_template_version_id, componentKey: row.component_key,
|
||||
terminals: byNode.get(row.id) || [],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getGraph: loadGraph,
|
||||
|
||||
async createNode(mysteryId, input) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const mystery = await client.query('SELECT 1 FROM osint.mysteries WHERE id=$1', [mysteryId])
|
||||
if (!mystery.rows[0]) { await client.query('ROLLBACK'); return null }
|
||||
const nodeId = randomUUID()
|
||||
const label = input.label?.trim() || input.nodeType
|
||||
await client.query('INSERT INTO osint.story_nodes (id,mystery_id,node_type,label,xpos,ypos,has_utterances) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||
[nodeId, mysteryId, input.nodeType, label, input.xpos, input.ypos, input.nodeType === 'dialogue' || input.nodeType === 'cutscene'])
|
||||
for (const [index, terminal] of DEFAULT_TERMINALS[input.nodeType].entries())
|
||||
await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5)',
|
||||
[randomUUID(), nodeId, terminal.key, terminal.label, index])
|
||||
await client.query('COMMIT')
|
||||
const graph = await loadGraph(mysteryId)
|
||||
return graph?.nodes.find(node => node.id === nodeId) ?? null
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
},
|
||||
|
||||
async updateNode(nodeId, input) {
|
||||
const mysteryId = await mysteryOfNode(nodeId)
|
||||
if (!mysteryId) return null
|
||||
const sets: string[] = []
|
||||
const values: unknown[] = [nodeId]
|
||||
const set = (column: string, value: unknown) => { values.push(value); sets.push(`${column}=$${values.length}`) }
|
||||
if (input.label !== undefined) set('label', input.label.trim())
|
||||
if (input.xpos !== undefined) set('xpos', input.xpos)
|
||||
if (input.ypos !== undefined) set('ypos', input.ypos)
|
||||
if (input.hasUtterances !== undefined) set('has_utterances', input.hasUtterances)
|
||||
if (input.componentKey !== undefined) set('component_key', input.componentKey || null)
|
||||
if (input.levelTemplateVersionId !== undefined) set('level_template_version_id', input.levelTemplateVersionId || null)
|
||||
if (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>(
|
||||
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,advances_to_utterance_id,terminal_id,effect,xpos,ypos,sort_order
|
||||
FROM osint.utterances WHERE node_id=$1 ORDER BY sort_order,id`, [nodeId])
|
||||
return result.rows.map(mapUtterance)
|
||||
},
|
||||
|
||||
async createUtterance(nodeId, input) {
|
||||
const node = await pool.query('SELECT 1 FROM osint.story_nodes WHERE id=$1', [nodeId])
|
||||
if (!node.rows[0]) return null
|
||||
const id = randomUUID()
|
||||
const order = await pool.query<{ next: number }>('SELECT COALESCE(MAX(sort_order),-1)+1 AS next FROM osint.utterances WHERE node_id=$1', [nodeId])
|
||||
await pool.query('INSERT INTO osint.utterances (id,node_id,utterer,text,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||
[id, nodeId, input.utterer, input.text || '', input.xpos, input.ypos, order.rows[0].next])
|
||||
const created = await pool.query<UtteranceRow>(
|
||||
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,advances_to_utterance_id,terminal_id,effect,xpos,ypos,sort_order FROM osint.utterances WHERE id=$1`, [id])
|
||||
return mapUtterance(created.rows[0])
|
||||
},
|
||||
|
||||
async updateUtterance(id, input) {
|
||||
const owner = await pool.query<{ node_id: string }>('SELECT node_id FROM osint.utterances WHERE id=$1', [id])
|
||||
if (!owner.rows[0]) return { ok: false, error: 'Utterance not found' }
|
||||
const nodeId = owner.rows[0].node_id
|
||||
// Same-node integrity for the three links.
|
||||
for (const link of ['parentUtteranceId', 'advancesToUtteranceId'] as const) {
|
||||
const value = input[link]
|
||||
if (value) {
|
||||
const target = await pool.query<{ node_id: string }>('SELECT node_id FROM osint.utterances WHERE id=$1', [value])
|
||||
if (target.rows[0]?.node_id !== nodeId) return { ok: false, error: 'Linked utterance must be in the same node' }
|
||||
}
|
||||
}
|
||||
if (input.terminalId) {
|
||||
const terminal = await pool.query<{ parent_node_id: string }>('SELECT parent_node_id FROM osint.story_node_terminals WHERE id=$1', [input.terminalId])
|
||||
if (terminal.rows[0]?.parent_node_id !== nodeId) return { ok: false, error: 'Terminal must belong to this node' }
|
||||
}
|
||||
const columns: Record<string, string> = {
|
||||
text: 'text', utterer: 'utterer', npcId: 'npc_id', poseKey: 'pose_key', xpos: 'xpos', ypos: 'ypos',
|
||||
parentUtteranceId: 'parent_utterance_id', advancesToUtteranceId: 'advances_to_utterance_id', terminalId: 'terminal_id', effect: 'effect',
|
||||
}
|
||||
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)',
|
||||
[utteranceId, nodeIds.get(node.key), utterance.utterer || 'npc', npcId, utterance.pose || null, utterance.text, 40 + index * 250, 60, index])
|
||||
}
|
||||
// Chain via parent: each line follows the previous one (one child = linear).
|
||||
for (let i = 1; i < created.length; i++)
|
||||
await client.query('UPDATE osint.utterances SET parent_utterance_id=$2 WHERE id=$1', [created[i], created[i - 1]])
|
||||
const firstTerminalKey = node.terminals?.[0]?.key
|
||||
const exitTerminalId = firstTerminalKey ? terminalIds.get(`${node.key}:${firstTerminalKey}`) : undefined
|
||||
if (created.length && exitTerminalId)
|
||||
await client.query('UPDATE osint.utterances SET terminal_id=$2 WHERE id=$1', [created[created.length - 1], exitTerminalId])
|
||||
}
|
||||
const entryId = nodeIds.get(spec.entry)
|
||||
if (!entryId) throw new Error(`Graph entry node ${spec.entry} not found`)
|
||||
await client.query('UPDATE osint.mysteries SET entry_node_id=$2 WHERE id=$1', [mysteryId, entryId])
|
||||
await client.query('COMMIT')
|
||||
return { nodes: spec.nodes.length }
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type UtteranceRow = {
|
||||
id: string; node_id: string; utterer: Utterer; npc_id: string | null; pose_key: string | null; text: string
|
||||
parent_utterance_id: string | null; advances_to_utterance_id: string | null; terminal_id: string | null
|
||||
effect: string | null; xpos: number; ypos: number; sort_order: number
|
||||
}
|
||||
function mapUtterance(row: UtteranceRow): UtteranceDto {
|
||||
return {
|
||||
id: row.id, nodeId: row.node_id, utterer: row.utterer, npcId: row.npc_id, poseKey: row.pose_key, text: row.text,
|
||||
parentUtteranceId: row.parent_utterance_id, advancesToUtteranceId: row.advances_to_utterance_id, terminalId: row.terminal_id,
|
||||
effect: row.effect, xpos: row.xpos, ypos: row.ypos, sortOrder: row.sort_order,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user