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:
@@ -13,6 +13,17 @@ type MysteryDocument = {
|
||||
metadata?: Record<string, string>
|
||||
asset?: string
|
||||
}
|
||||
type MysteryGraph = {
|
||||
entry: string
|
||||
nodes: { key: string; type: 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'; 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?: 'npc' | 'player' }[] }[]
|
||||
}
|
||||
type MysteryNarrative = {
|
||||
cast: { key: string; name: string; role?: string; defaultPose?: string; poses?: { poseKey: string; assetId: string }[] }[]
|
||||
graph?: MysteryGraph
|
||||
}
|
||||
type MysteryManifest = {
|
||||
slug: string
|
||||
name: string
|
||||
@@ -22,6 +33,7 @@ type MysteryManifest = {
|
||||
brief: { body: string; concepts: { label: string; context: string; expectedPartyKind: PartyKind }[] }
|
||||
documents: MysteryDocument[]
|
||||
folders: { key: string; title: string; content: string; x: number; y: number; width: number; members: string[] }[]
|
||||
narrative?: MysteryNarrative
|
||||
}
|
||||
|
||||
function requireOk(response: Response, action: string) {
|
||||
@@ -92,12 +104,32 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
|
||||
method: 'POST', headers, body: JSON.stringify({ slug: manifest.slug, name: manifest.name }),
|
||||
}), 'Freeze mystery template')
|
||||
const template = await templateResponse.json() as { slug: string; currentVersion: number }
|
||||
|
||||
// Author the mystery and its NPC cast; the flow lives in the story graph, seeded below.
|
||||
let mystery: { slug: string } | undefined
|
||||
if (manifest.narrative) {
|
||||
const mysteryResponse = await requireOk(await fetch(`${baseUrl}/api/mysteries?edit=1`, {
|
||||
method: 'POST', headers, body: JSON.stringify({ slug: manifest.slug, title: manifest.title, cast: manifest.narrative.cast }),
|
||||
}), 'Author narrative mystery')
|
||||
mystery = await mysteryResponse.json() as { slug: string }
|
||||
|
||||
// Seed the story flow graph (default authored content that survives re-imports).
|
||||
if (manifest.narrative.graph) {
|
||||
const listResponse = await requireOk(await fetch(`${baseUrl}/api/admin/mysteries`, { headers: authorization ? { authorization } : undefined }), 'List mysteries')
|
||||
const mysteries = await listResponse.json() as { id: string; slug: string }[]
|
||||
const mysteryId = mysteries.find(m => m.slug === manifest.slug)?.id
|
||||
if (mysteryId) await requireOk(await fetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, {
|
||||
method: 'POST', headers, body: JSON.stringify(manifest.narrative.graph),
|
||||
}), 'Seed story graph')
|
||||
}
|
||||
}
|
||||
|
||||
const playableId = `${manifest.slug}-case-${Date.now()}`
|
||||
const playableResponse = await requireOk(await fetch(`${baseUrl}/api/templates/${manifest.slug}/levels?edit=1`, {
|
||||
method: 'POST', headers, body: JSON.stringify({ id: playableId, title: manifest.title }),
|
||||
}), 'Instantiate playable mystery')
|
||||
const playable = await playableResponse.json() as CaseState
|
||||
return { manifest, template, authoringLevelId: state.id, playableLevel: playable }
|
||||
return { manifest, template, mystery, authoringLevelId: state.id, playableLevel: playable }
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ''
|
||||
@@ -107,8 +139,9 @@ if (invokedPath === fileURLToPath(import.meta.url)) {
|
||||
const result = await importMysteryTemplate(manifestPath, process.env.OSINT_BOARD_URL)
|
||||
console.log(JSON.stringify({
|
||||
template: `${result.template.slug}@v${result.template.currentVersion}`,
|
||||
mystery: result.mystery ? result.mystery.slug : undefined,
|
||||
authoringLevelId: result.authoringLevelId,
|
||||
playableLevelId: result.playableLevel.id,
|
||||
playUrl: `${process.env.OSINT_BOARD_URL || 'http://localhost:8787'}/?level=${encodeURIComponent(result.playableLevel.id)}`,
|
||||
playUrl: `${process.env.OSINT_BOARD_URL || 'http://localhost:8787'}/`,
|
||||
}, null, 2))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user