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:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user