Adding fixes that lets us pull a myster in the db into a json file

This commit is contained in:
2026-08-23 21:37:06 +02:00
parent 3c099a5daa
commit 7dec2994b4
8 changed files with 719 additions and 290 deletions
+8
View File
@@ -397,6 +397,14 @@ app.delete('/api/admin/npcs/:id/poses/:poseKey', requireAdmin, async (req, res,
app.get('/api/admin/level-templates', requireAdmin, async (_req, res, next) => {
try { res.json(await storyGraph.listLevelTemplates()) } catch (error) { next(error) }
})
// Read a frozen template version's board (exhibits, goals, rules) for the mystery:pull
// exporter — read-only, so no editing gate.
app.get('/api/admin/level-templates/:versionId/export', requireAdmin, async (req, res, next) => {
try {
const board = await levels.getTemplateBoard(String(req.params.versionId))
board ? res.json(board) : res.status(404).json({ error: 'Template version not found' })
} catch (error) { next(error) }
})
app.get('/api/admin/mysteries/:id/graph', requireAdmin, async (req, res, next) => {
try {
const graph = await storyGraph.getGraph(String(req.params.id))
+29
View File
@@ -15,6 +15,9 @@ export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype:
export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer | null; storage_provider: 'postgres' | 's3'; object_key: string | null }
export type AssetResponse = { originalName: string; mimeType: string; byteSize: number; stream: NodeJS.ReadableStream }
export type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string }
// A frozen template version's board, read back for round-trip export. `state.goals`
// carries the goals (in author mode); match/semantic rules are read alongside.
export type TemplateBoardExport = { slug: string; name: string; version: number; state: CaseState; matchRules: EvidenceMatchRuleDefinition[]; semanticRules: EvidenceSemanticRuleDefinition[] }
export type EvidenceMatchRuleInput = {
name: string
sourceLabel?: string
@@ -51,6 +54,7 @@ export interface LevelRepository {
listTemplates(): Promise<TemplateSummary[]>
instantiateTemplate(templateSlug: string, input: { id: string; title?: string; version?: number }): Promise<CaseState | null>
saveLevelAsTemplate(levelId: string, input: { slug: string; name: string }): Promise<TemplateSummary | null>
getTemplateBoard(versionId: string): Promise<TemplateBoardExport | null>
getLevel(levelId: string, authorMode?: boolean): Promise<CaseState | null>
saveLevel(state: CaseState, authorMode: boolean): Promise<void>
resetLevel(levelId: string): Promise<CaseState | null>
@@ -347,6 +351,14 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> {
const level = await findLevel(pool, slug)
if (!level) return null
return assembleBoard(level, authorMode)
}
// Assemble a board's CaseState from a level row (id + board_id). Split out from
// assembleLevel so a frozen template version's board can be read the same way (its
// board_id backs the same tables); player-state reads (flags/seen) return empty for
// a template board that no player has instantiated.
async function assembleBoard(level: LevelRow, authorMode = false): Promise<CaseState> {
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult, requirementsResult, flagsResult, seenResult] = await Promise.all([
pool.query<ExhibitRow>(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden,
@@ -739,6 +751,23 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
return templateSummary(input.slug)
},
async getTemplateBoard(versionId) {
const version = (await pool.query<{ board_id: string; title: string; subtitle: string; version: number; slug: string; name: string }>(
`SELECT v.board_id,v.title,v.subtitle,v.version,t.slug,t.name FROM osint.level_template_versions v
JOIN osint.level_templates t ON t.id=v.template_id WHERE v.id=$1`, [versionId])).rows[0]
if (!version) return null
// A synthetic level row over the version's board: id has no player rows (flags/seen
// resolve empty); viewport/revision/status are placeholders the exporter discards.
const level: LevelRow = {
id: versionId, slug: version.slug, board_id: version.board_id, title: version.title, subtitle: version.subtitle,
status: 'template', viewport_x: 0, viewport_y: 28, viewport_zoom: 0.7, updated_at: new Date(), revision: '0',
source_template_version_id: null,
}
const [state, matchRules, semanticRules] = await Promise.all([
assembleBoard(level, true), evidenceMatchRules(pool, version.board_id, true), evidenceSemanticRules(pool, version.board_id, true),
])
return { slug: version.slug, name: version.name, version: version.version, state, matchRules, semanticRules }
},
getLevel(levelId, authorMode = false) { return assembleLevel(levelId, authorMode) },
async saveLevel(state, authorMode) {
let persistedState = state
+5 -4
View File
@@ -25,7 +25,7 @@ export type LevelTemplateOption = { versionId: string; slug: string; name: strin
export type Utterer = 'npc' | 'player'
export type UtteranceDto = {
id: string; nodeId: string; utterer: Utterer; npcId: string | null; poseKey: string | null; text: string
parentUtteranceId: string | null; terminalId: string | null
parentUtteranceId: string | null; terminalId: string | null; awardsFlag: string | null; requiresFlag: string | null
xpos: number; ypos: number; sortOrder: number
}
@@ -190,7 +190,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
async listUtterances(nodeId) {
const result = await pool.query<UtteranceRow>(
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,terminal_id,xpos,ypos,sort_order
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,terminal_id,awards_flag,requires_flag,xpos,ypos,sort_order
FROM osint.utterances WHERE node_id=$1 ORDER BY sort_order,id`, [nodeId])
return result.rows.map(mapUtterance)
},
@@ -203,7 +203,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
await pool.query('INSERT INTO osint.utterances (id,node_id,utterer,text,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7)',
[id, nodeId, input.utterer, input.text || '', input.xpos, input.ypos, order.rows[0].next])
const created = await pool.query<UtteranceRow>(
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,terminal_id,xpos,ypos,sort_order FROM osint.utterances WHERE id=$1`, [id])
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,terminal_id,awards_flag,requires_flag,xpos,ypos,sort_order FROM osint.utterances WHERE id=$1`, [id])
return mapUtterance(created.rows[0])
},
@@ -333,13 +333,14 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
type UtteranceRow = {
id: string; node_id: string; utterer: Utterer; npc_id: string | null; pose_key: string | null; text: string
parent_utterance_id: string | null; terminal_id: string | null
parent_utterance_id: string | null; terminal_id: string | null; awards_flag: string | null; requires_flag: string | null
xpos: number; ypos: number; sort_order: number
}
function mapUtterance(row: UtteranceRow): UtteranceDto {
return {
id: row.id, nodeId: row.node_id, utterer: row.utterer, npcId: row.npc_id, poseKey: row.pose_key, text: row.text,
parentUtteranceId: row.parent_utterance_id, terminalId: row.terminal_id,
awardsFlag: row.awards_flag, requiresFlag: row.requires_flag,
xpos: row.xpos, ypos: row.ypos, sortOrder: row.sort_order,
}
}