feat: author first playable mystery
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
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, PartyKind, SourceFileType } from '../src/types.js'
|
||||
|
||||
type MysteryDocument = {
|
||||
key: string
|
||||
title: string
|
||||
fileType: SourceFileType
|
||||
publishedAt: string
|
||||
body?: string[]
|
||||
metadata?: Record<string, string>
|
||||
asset?: string
|
||||
}
|
||||
type MysteryManifest = {
|
||||
slug: string
|
||||
name: string
|
||||
title: string
|
||||
subtitle: 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[] }[]
|
||||
}
|
||||
|
||||
function requireOk(response: Response, action: string) {
|
||||
if (response.ok) return response
|
||||
return response.text().then(body => { throw new Error(`${action} failed (${response.status}): ${body}`) })
|
||||
}
|
||||
|
||||
function documentKind(type: SourceFileType) {
|
||||
return type === 'web_capture' ? 'WEB CAPTURE' : type.toUpperCase()
|
||||
}
|
||||
|
||||
async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string, document: MysteryDocument) {
|
||||
if (!document.asset) return undefined
|
||||
const assetPath = path.resolve(manifestDir, document.asset)
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([await readFile(assetPath)]), path.basename(assetPath))
|
||||
const response = await requireOk(await fetch(`${baseUrl}/api/levels/${levelId}/documents?edit=1`, { method: 'POST', body: form }), `Upload ${document.asset}`)
|
||||
return await response.json() as CaseDocument
|
||||
}
|
||||
|
||||
export async function importMysteryTemplate(manifestPath: string, baseUrl = 'http://localhost:8787') {
|
||||
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 createdResponse = await requireOk(await fetch(`${baseUrl}/api/levels`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: authoringId, title: manifest.title, subtitle: manifest.subtitle }),
|
||||
}), 'Create authoring level')
|
||||
const state = await createdResponse.json() as CaseState
|
||||
|
||||
const documents = new Map<string, CaseDocument>()
|
||||
for (const source of manifest.documents) {
|
||||
const uploaded = await uploadAsset(baseUrl, state.id, manifestDir, source)
|
||||
documents.set(source.key, {
|
||||
id: uploaded?.id || randomUUID(), title: source.title, kind: documentKind(source.fileType),
|
||||
date: source.publishedAt.slice(0, 10), publishedAt: source.publishedAt,
|
||||
body: source.body || [], regions: [], assetId: uploaded?.assetId,
|
||||
fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize,
|
||||
fileType: source.fileType, metadata: source.metadata || {},
|
||||
})
|
||||
}
|
||||
|
||||
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.documents = [...documents.values()]
|
||||
state.evidence = 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, config: { open: false },
|
||||
containedDocumentIds: folder.members.map(key => documents.get(key)!.id),
|
||||
}))
|
||||
state.relations = manifest.folders.flatMap((folder, folderIndex) => 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}`,
|
||||
fromWidgetId: folderIds.get(folder.key)!, toWidgetId: document.id, type: 'contains', sortOrder: memberIndex,
|
||||
config: { x: folder.x + 70 + memberIndex * 205, y: folder.y + 230 + folderIndex * 35 },
|
||||
}
|
||||
}))
|
||||
state.connections = []
|
||||
state.viewport = { x: 0, y: 28, zoom: 0.7 }
|
||||
|
||||
await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(state),
|
||||
}), 'Save authored mystery')
|
||||
const templateResponse = await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slug: manifest.slug, name: manifest.name }),
|
||||
}), 'Freeze mystery template')
|
||||
const template = await templateResponse.json() as { slug: string; currentVersion: number }
|
||||
const playableId = `${manifest.slug}-case-${Date.now()}`
|
||||
const playableResponse = await requireOk(await fetch(`${baseUrl}/api/templates/${manifest.slug}/levels?edit=1`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, 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 }
|
||||
}
|
||||
|
||||
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 -- <manifest.json>')
|
||||
const result = await importMysteryTemplate(manifestPath, process.env.OSINT_BOARD_URL)
|
||||
console.log(JSON.stringify({
|
||||
template: `${result.template.slug}@v${result.template.currentVersion}`,
|
||||
authoringLevelId: result.authoringLevelId,
|
||||
playableLevelId: result.playableLevel.id,
|
||||
playUrl: `${process.env.OSINT_BOARD_URL || 'http://localhost:8787'}/?level=${encodeURIComponent(result.playableLevel.id)}`,
|
||||
}, null, 2))
|
||||
}
|
||||
Reference in New Issue
Block a user