import { randomUUID } from 'node:crypto' import { readFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import type { CaseDocument, CaseState, ClaimExhibit, PartyKind, SourceFileType } from '../src/types.js' type MysteryDocument = { key: string title: string fileType: SourceFileType publishedAt: string body?: string[] metadata?: Record asset?: string requiredFlags?: 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 MysteryGoal = { key:string; title:string; instructions?:string; completionMessage?:string; enabled?:boolean; requiredFlags:string[] } type MysteryEvidenceMatchRule = { name:string; sourceLabel?:string; sourceUri?:string; flagKey:string; minimumAnchorMatches?:number; enabled?:boolean anchors:{ phrase:string; minimumSimilarity?:number }[] } type MysterySemanticRule = { goalKey:string; name:string; targetSubject:string; relatedSubject?:string; assertion:string; successFlagKey:string relatedFlagKey?:string; minimumConfidence?:number; evaluatorVersion?:string; enabled?:boolean } type MysteryManifest = { slug: string name: string title: string subtitle: string timelineRange?: { start: string; end: string } 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[] }[] claims?: { key:string;statement:string;x:number;y:number;width?:number;height?:number }[] report?: { title?:string;requiredForCompletion?:boolean } goals?: MysteryGoal[] evidenceMatchRules?: MysteryEvidenceMatchRule[] evidenceSemanticRules?: MysterySemanticRule[] narrative?: MysteryNarrative } function requireOk(response: Response, action: string) { if (response.ok) return response return response.text().then(body => { throw new Error(`${action} failed (${response.status}): ${body}`) }) } const MIME_BY_EXT: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4', txt: 'text/plain', } function mimeFor(filename: string) { return MIME_BY_EXT[filename.split('.').pop()?.toLowerCase() || ''] || 'application/octet-stream' } async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string, document: MysteryDocument, authorization?: string) { if (!document.asset) return undefined const assetPath = path.resolve(manifestDir, document.asset) const filename = path.basename(assetPath) const form = new FormData() form.append('file', new Blob([await readFile(assetPath)], { type: mimeFor(filename) }), filename) const response = await requireOk(await fetch(`${baseUrl}/api/levels/${levelId}/documents?edit=1`, { method: 'POST', headers: authorization ? { authorization } : undefined, body: form }), `Upload ${document.asset}`) 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()}` const authorization = adminJwt ? `Bearer ${adminJwt}` : undefined const headers = { 'content-type': 'application/json', ...(authorization ? { authorization } : {}) } const createdResponse = await requireOk(await fetch(`${baseUrl}/api/levels`, { method: 'POST', headers, body: JSON.stringify({ id: authoringId, title: manifest.title, subtitle: manifest.subtitle }), }), 'Create authoring level') const state = await createdResponse.json() as CaseState const documentPositions = new Map() manifest.folders.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) 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, width: uploaded?.width || 174, height: uploaded?.height || 145, rotation: 0, zIndex: uploaded?.zIndex || 1, hidden: false, body: source.body || [], regions: [], assetId: uploaded?.assetId, fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize, fileType: source.fileType, metadata: source.metadata || {}, requiredFlags: source.requiredFlags || [], }) } const folderIds = new Map(manifest.folders.map(folder => [folder.key, randomUUID()])) 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 = manifest.folders.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, x:claim.x,y:claim.y,width:claim.width || 310,height:claim.height || 180,rotation:0,zIndex:2,hidden:false })) state.exhibits = [...documents.values(), ...folders,...claims] state.relations = manifest.folders.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 { id: `contains:${folderIds.get(folder.key)}:${document.id}`, fromExhibitId: folderIds.get(folder.key)!, toExhibitId: document.id, type: 'contains' as const, sortOrder: memberIndex, } })) state.connections = [] state.report=manifest.report ? { title:manifest.report.title || 'Case Report',investigatorName:'',requiredForCompletion:manifest.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') const goalIds = new Map() for (const goal of manifest.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( `${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 || []) { 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`, { method:'POST',headers,body:JSON.stringify({ ...rule,goalKey:undefined,goalId }), }), `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') 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, mystery, authoringLevelId: state.id, playableLevel: playable } } 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)) }