From 625f28b00b7295e0832ebb555afad101a2b5b0da Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Sun, 23 Aug 2026 01:15:49 +0200 Subject: [PATCH] In the middle of refactoring the mystery seeding --- scripts/importMysteryTemplate.ts | 159 +++++++++++++++++++------------ 1 file changed, 97 insertions(+), 62 deletions(-) diff --git a/scripts/importMysteryTemplate.ts b/scripts/importMysteryTemplate.ts index 12d448f..a5a601a 100644 --- a/scripts/importMysteryTemplate.ts +++ b/scripts/importMysteryTemplate.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import { readFile } from 'node:fs/promises' +import { readFile, stat } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import type { CaseDocument, CaseState, ClaimExhibit, DocumentCaptureKind, NoteExhibit, NotePresentation, PartyKind, SourceFileType } from '../src/types.js' @@ -37,7 +37,9 @@ type MysterySemanticRule = { goalKey:string; name:string; targetSubject:string; relatedSubject?:string; assertion:string; successFlagKey:string relatedFlagKey?:string; minimumConfidence?:number; evaluatorVersion?:string; enabled?:boolean } -type MysteryManifest = { +// A single playable board (level template). Assets are filenames resolved against +// the mystery folder (e.g. "assets/photo.png"), matching the on-disk layout. +type MysteryLevel = { slug: string name: string title: string @@ -52,8 +54,11 @@ type MysteryManifest = { goals?: MysteryGoal[] evidenceMatchRules?: MysteryEvidenceMatchRule[] evidenceSemanticRules?: MysterySemanticRule[] - narrative?: MysteryNarrative } +// Legacy single-manifest: one level plus an optional narrative in the same file. +type MysteryManifest = MysteryLevel & { narrative?: MysteryNarrative } +// New self-contained format: a mystery with every level it uses embedded. +type MysteryFile = { slug: string; title: string; levels: MysteryLevel[]; narrative?: MysteryNarrative } function requireOk(response: Response, action: string) { if (response.ok) return response @@ -76,25 +81,30 @@ async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string return await response.json() as CaseDocument } -export async function importMysteryTemplate(manifestPath: string, baseUrl = 'http://localhost:8787', adminJwt = process.env.OSINT_ADMIN_JWT) { - const absoluteManifest = path.resolve(manifestPath) - const manifest = JSON.parse(await readFile(absoluteManifest, 'utf8')) as MysteryManifest - const manifestDir = path.dirname(absoluteManifest) - const authoringId = `${manifest.slug}-authoring-${Date.now()}` +type ImportHeaders = Record +function authHeaders(adminJwt?: string): { authorization?: string; headers: ImportHeaders } { const authorization = adminJwt ? `Bearer ${adminJwt}` : undefined - const headers = { 'content-type': 'application/json', ...(authorization ? { authorization } : {}) } + return { authorization, headers: { 'content-type': 'application/json', ...(authorization ? { authorization } : {}) } } +} + +type LevelResult = { template: { slug: string; currentVersion: number }; authoringLevelId: string; playableLevel: CaseState } + +// Import one board: create a mutable authoring level, upload assets, save exhibits, +// goals and evidence rules, freeze an immutable template, and instantiate a playable copy. +async function importLevel(baseUrl: string, folderDir: string, level: MysteryLevel, headers: ImportHeaders, authorization?: string): Promise { + const authoringId = `${level.slug}-authoring-${Date.now()}` const createdResponse = await requireOk(await fetch(`${baseUrl}/api/levels`, { method: 'POST', headers, - body: JSON.stringify({ id: authoringId, title: manifest.title, subtitle: manifest.subtitle || '' }), + body: JSON.stringify({ id: authoringId, title: level.title, subtitle: level.subtitle || '' }), }), 'Create authoring level') const state = await createdResponse.json() as CaseState const documentPositions = new Map() - const manifestFolders = manifest.folders || [] - manifestFolders.forEach((folder, folderIndex) => folder.members.forEach((key, memberIndex) => documentPositions.set(key, { x: folder.x + 70 + memberIndex * 205, y: folder.y + 230 + folderIndex * 35 }))) + const levelFolders = level.folders || [] + levelFolders.forEach((folder, folderIndex) => folder.members.forEach((key, memberIndex) => documentPositions.set(key, { x: folder.x + 70 + memberIndex * 205, y: folder.y + 230 + folderIndex * 35 }))) const documents = new Map() - for (const source of manifest.documents || []) { - const uploaded = await uploadAsset(baseUrl, state.id, manifestDir, source, authorization) + for (const source of level.documents || []) { + const uploaded = await uploadAsset(baseUrl, state.id, folderDir, source, authorization) documents.set(source.key, { id: uploaded?.id || randomUUID(), type: 'document', title: source.title, publishedAt: source.publishedAt, x: documentPositions.get(source.key)?.x || 100, y: documentPositions.get(source.key)?.y || 100, @@ -105,19 +115,19 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt }) } - const folderIds = new Map(manifestFolders.map(folder => [folder.key, randomUUID()])) - if (manifest.brief) state.brief = { body: manifest.brief.body, concepts: manifest.brief.concepts.map(concept => ({ id: randomUUID(), ...concept })) } - state.views = state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: manifest.timelineRange ? 'fixed' : 'auto', range: manifest.timelineRange } : view) - const folders = manifestFolders.map(folder => ({ + const folderIds = new Map(levelFolders.map(folder => [folder.key, randomUUID()])) + if (level.brief) state.brief = { body: level.brief.body, concepts: level.brief.concepts.map(concept => ({ id: randomUUID(), ...concept })) } + state.views = state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: level.timelineRange ? 'fixed' : 'auto', range: level.timelineRange } : view) + const folders = levelFolders.map(folder => ({ id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content, x: folder.x, y: folder.y, width: folder.width, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false, } as const)) - const claims:ClaimExhibit[]=(manifest.claims || []).map(claim => ({ id:randomUUID(),type:'claim',title:claim.statement,statement:claim.statement, + const claims:ClaimExhibit[]=(level.claims || []).map(claim => ({ id:randomUUID(),type:'claim',title:claim.statement,statement:claim.statement, x:claim.x,y:claim.y,width:claim.width || 310,height:claim.height || 180,rotation:0,zIndex:2,hidden:false })) - const notes:NoteExhibit[]=(manifest.notes || []).map(note => ({ id:randomUUID(),type:'note',title:note.title || 'NOTE',content:note.content, + const notes:NoteExhibit[]=(level.notes || []).map(note => ({ id:randomUUID(),type:'note',title:note.title || 'NOTE',content:note.content, presentation:note.presentation || 'luggage',x:note.x,y:note.y,width:note.width || 230,height:note.height || 180,rotation:0,zIndex:2,hidden:false })) state.exhibits = [...documents.values(), ...folders,...claims,...notes] - state.relations = manifestFolders.flatMap(folder => folder.members.map((key, memberIndex) => { + state.relations = levelFolders.flatMap(folder => folder.members.map((key, memberIndex) => { const document = documents.get(key) if (!document) throw new Error(`Folder ${folder.key} refers to unknown document ${key}`) return { @@ -126,26 +136,26 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt } })) state.connections = [] - state.report=manifest.report ? { title:manifest.report.title || 'Case Report',investigatorName:'',requiredForCompletion:manifest.report.requiredForCompletion !== false, + state.report=level.report ? { title:level.report.title || 'Case Report',investigatorName:'',requiredForCompletion:level.report.requiredForCompletion !== false, status:'draft',issues:[],claims:[] } : undefined state.viewport = { x: 0, y: 28, zoom: 0.7 } await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { method: 'PUT', headers, body: JSON.stringify(state), - }), 'Save authored mystery') + }), 'Save authored level') const goalIds = new Map() - for (const goal of manifest.goals || []) { + for (const goal of level.goals || []) { const response = await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/goals`, { method:'POST',headers,body:JSON.stringify(goal), }), `Create goal ${goal.key}`) const created = await response.json() as { id:string;key:string } goalIds.set(created.key,created.id) } - for (const rule of manifest.evidenceMatchRules || []) await requireOk(await fetch( + for (const rule of level.evidenceMatchRules || []) await requireOk(await fetch( `${baseUrl}/api/levels/${state.id}/evidence-match-rules`, { method:'POST',headers,body:JSON.stringify(rule) }), `Create evidence match rule ${rule.name}`) - for (const rule of manifest.evidenceSemanticRules || []) { + for (const rule of level.evidenceSemanticRules || []) { const goalId = goalIds.get(rule.goalKey) if (!goalId) throw new Error(`Semantic evidence rule ${rule.name} refers to unknown goal ${rule.goalKey}`) await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/evidence-semantic-rules`, { @@ -153,47 +163,72 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt }), `Create semantic evidence rule ${rule.name}`) } const templateResponse = await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { - method: 'POST', headers, body: JSON.stringify({ slug: manifest.slug, name: manifest.name }), - }), 'Freeze mystery template') + method: 'POST', headers, body: JSON.stringify({ slug: level.slug, name: level.name || level.title }), + }), 'Freeze level 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 playableId = `${level.slug}-case-${Date.now()}` + const playableResponse = await requireOk(await fetch(`${baseUrl}/api/templates/${level.slug}/levels?edit=1`, { + method: 'POST', headers, body: JSON.stringify({ id: playableId, title: level.title }), + }), 'Instantiate playable level') const playable = await playableResponse.json() as CaseState - return { manifest, template, mystery, authoringLevelId: state.id, playableLevel: playable } + return { template, authoringLevelId: state.id, playableLevel: playable } +} + +// Author the narrative mystery (NPC cast) and seed its story flow graph. +async function importNarrative(baseUrl: string, slug: string, title: string, narrative: MysteryNarrative, headers: ImportHeaders, authorization?: string): Promise<{ slug: string }> { + const mysteryResponse = await requireOk(await fetch(`${baseUrl}/api/mysteries?edit=1`, { + method: 'POST', headers, body: JSON.stringify({ slug, title, cast: narrative.cast }), + }), 'Author narrative mystery') + const mystery = await mysteryResponse.json() as { slug: string } + if (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 === slug)?.id + if (mysteryId) await requireOk(await fetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, { + method: 'POST', headers, body: JSON.stringify(narrative.graph), + }), 'Seed story graph') + } + return mystery +} + +// Legacy single-manifest import: one level plus an optional narrative in the same file. +export async function importMysteryTemplate(manifestPath: string, baseUrl = 'http://localhost:8787', adminJwt = process.env.OSINT_ADMIN_JWT) { + const absoluteManifest = path.resolve(manifestPath) + const manifest = JSON.parse(await readFile(absoluteManifest, 'utf8')) as MysteryManifest + const folderDir = path.dirname(absoluteManifest) + const { headers, authorization } = authHeaders(adminJwt) + const { narrative, ...level } = manifest + const result = await importLevel(baseUrl, folderDir, level, headers, authorization) + const mystery = narrative ? await importNarrative(baseUrl, manifest.slug, manifest.title, narrative, headers, authorization) : undefined + return { manifest, template: result.template, mystery, authoringLevelId: result.authoringLevelId, playableLevel: result.playableLevel } +} + +// New self-contained import: a mystery folder (or single-file) with every level embedded. +// Accepts a directory (uses /mystery.json) or a manifest path; falls back to the +// legacy path when the file has no top-level `levels` array. +export async function importMystery(inputPath: string, baseUrl = 'http://localhost:8787', adminJwt = process.env.OSINT_ADMIN_JWT) { + const resolved = path.resolve(inputPath) + const manifestPath = (await stat(resolved)).isDirectory() ? path.join(resolved, 'mystery.json') : resolved + const parsed = JSON.parse(await readFile(manifestPath, 'utf8')) as MysteryFile | MysteryManifest + if (!Array.isArray((parsed as MysteryFile).levels)) return importMysteryTemplate(manifestPath, baseUrl, adminJwt) + const file = parsed as MysteryFile + const folderDir = path.dirname(manifestPath) + const { headers, authorization } = authHeaders(adminJwt) + const levels: LevelResult[] = [] + for (const level of file.levels) levels.push(await importLevel(baseUrl, folderDir, level, headers, authorization)) + const mystery = file.narrative ? await importNarrative(baseUrl, file.slug, file.title, file.narrative, headers, authorization) : undefined + return { slug: file.slug, title: file.title, levels, mystery } } const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '' if (invokedPath === fileURLToPath(import.meta.url)) { - const manifestPath = process.argv[2] - if (!manifestPath) throw new Error('Usage: npm run mystery:import -- ') - 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'}/`, - }, null, 2)) + const inputPath = process.argv[2] + if (!inputPath) throw new Error('Usage: npm run mystery:import -- ') + const result = await importMystery(inputPath, process.env.OSINT_BOARD_URL) + const playUrl = `${process.env.OSINT_BOARD_URL || 'http://localhost:8787'}/` + const summary = 'levels' in result + ? { mystery: result.mystery?.slug, levels: result.levels.map(l => `${l.template.slug}@v${l.template.currentVersion}`), playUrl } + : { template: `${result.template.slug}@v${result.template.currentVersion}`, mystery: result.mystery?.slug, authoringLevelId: result.authoringLevelId, playableLevelId: result.playableLevel.id, playUrl } + console.log(JSON.stringify(summary, null, 2)) }