Add case adjudication cutscene
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import type { Pool, PoolClient } from 'pg'
|
||||
import type { CaseReport } from '../src/types.js'
|
||||
import type { CaseAdjudicationPresentation, CutscenePresentation } from '../src/narrativeContract.js'
|
||||
import { cloneBoard } from './boardClone.js'
|
||||
import { loadCaseReport } from './caseReports.js'
|
||||
import type { ObjectStorage } from './objectStorage.js'
|
||||
|
||||
export type UploadedFile = { buffer: Buffer; originalname: string; mimetype: string; size: number }
|
||||
@@ -17,6 +20,7 @@ export type RuntimeUtterance = {
|
||||
export type RuntimeNode = {
|
||||
id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string
|
||||
componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number
|
||||
presentation?: CutscenePresentation | null
|
||||
awardsFlag?: string | null
|
||||
utterances?: RuntimeUtterance[]; rootId?: string | null
|
||||
}
|
||||
@@ -49,6 +53,32 @@ export function resolvePoseAssetId(
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildAdjudicationPresentation(
|
||||
report: CaseReport,
|
||||
submission: { investigatorName: string; submittedAt: Date | string },
|
||||
): CaseAdjudicationPresentation {
|
||||
return {
|
||||
kind: 'case-adjudication',
|
||||
reportTitle: report.title,
|
||||
investigatorName: submission.investigatorName,
|
||||
submittedAt: submission.submittedAt instanceof Date ? submission.submittedAt.toISOString() : submission.submittedAt,
|
||||
finding: 'SUPPORTED BY THE SUBMITTED EVIDENCE',
|
||||
stampText: 'CASE VERIFIED',
|
||||
claims: report.claims.map(claim => ({
|
||||
statement: claim.statement,
|
||||
evidence: claim.evidence.filter(item => item.evidenceAccepted).map(item => ({
|
||||
displayNumber: item.displayNumber,
|
||||
title: item.documentTitle,
|
||||
fileType: item.fileType,
|
||||
relationText: item.relationText,
|
||||
publishedAt: item.publishedAt,
|
||||
sourceCitation: item.sourceCitation,
|
||||
sourceUri: item.sourceUri,
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export interface NarrativeRepository {
|
||||
authorMystery(input: MysteryAuthoring): Promise<{ slug: string }>
|
||||
resolveDialogue(nodeId: string): Promise<{ utterances: RuntimeUtterance[]; rootId: string | null }>
|
||||
@@ -126,12 +156,29 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveNodeForPlay(nodeId: string, levelSlug: string | null, earnedFlags?: Set<string>): Promise<RuntimeNode | null> {
|
||||
async function resolveAdjudicationPresentation(levelId: string | null): Promise<CaseAdjudicationPresentation | null> {
|
||||
if (!levelId) return null
|
||||
const [level, submission] = await Promise.all([
|
||||
pool.query<{ id: string; board_id: string }>('SELECT id,board_id FROM osint.levels WHERE id=$1', [levelId]),
|
||||
pool.query<{ investigator_name: string; submitted_at: Date }>(`SELECT investigator_name,submitted_at
|
||||
FROM osint.case_report_submissions WHERE level_id=$1 AND status='accepted'
|
||||
ORDER BY submitted_at DESC,id DESC LIMIT 1`, [levelId]),
|
||||
])
|
||||
if (!level.rows[0] || !submission.rows[0]) return null
|
||||
const report = await loadCaseReport(pool, level.rows[0])
|
||||
return report ? buildAdjudicationPresentation(report, {
|
||||
investigatorName: submission.rows[0].investigator_name,
|
||||
submittedAt: submission.rows[0].submitted_at,
|
||||
}) : null
|
||||
}
|
||||
|
||||
async function resolveNodeForPlay(nodeId: string, levelId: string | null, levelSlug: string | null, earnedFlags?: Set<string>): Promise<RuntimeNode | null> {
|
||||
const node = (await pool.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id,music_asset_id,music_volume,awards_flag FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0]
|
||||
if (!node) return null
|
||||
const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null
|
||||
const musicVolume = node.music_volume / 100
|
||||
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key, musicUrl, musicVolume }
|
||||
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key, musicUrl, musicVolume,
|
||||
presentation: node.component_key === 'case-adjudication' ? await resolveAdjudicationPresentation(levelId) : null }
|
||||
if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug, musicUrl, musicVolume }
|
||||
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, musicVolume, ...(await resolveDialogueGraph(node.id, earnedFlags)) }
|
||||
if (node.node_type === 'merit') return { id: node.id, kind: 'merit', label: node.label, componentKey: node.component_key, awardsFlag: node.awards_flag, musicUrl, musicVolume }
|
||||
@@ -165,13 +212,13 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
||||
}
|
||||
|
||||
async function stateForPlaythrough(playthroughId: string): Promise<PlaythroughState | null> {
|
||||
const row = (await pool.query<{ id: string; mystery_slug: string; current_node_id: string | null; level_slug: string | null; status: 'active' | 'finished' }>(
|
||||
`SELECT p.id,m.slug AS mystery_slug,p.current_node_id,l.slug AS level_slug,p.status
|
||||
const row = (await pool.query<{ id: string; mystery_slug: string; current_node_id: string | null; current_level_id: string | null; level_slug: string | null; status: 'active' | 'finished' }>(
|
||||
`SELECT p.id,m.slug AS mystery_slug,p.current_node_id,p.current_level_id,l.slug AS level_slug,p.status
|
||||
FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id LEFT JOIN osint.levels l ON l.id=p.current_level_id
|
||||
WHERE p.id=$1`, [playthroughId])).rows[0]
|
||||
if (!row) return null
|
||||
const earned = new Set((await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1', [playthroughId])).rows.map(r => r.flag_key))
|
||||
const node = row.current_node_id ? await resolveNodeForPlay(row.current_node_id, row.level_slug, earned) : null
|
||||
const node = row.current_node_id ? await resolveNodeForPlay(row.current_node_id, row.current_level_id, row.level_slug, earned) : null
|
||||
return { playthrough: { id: row.id, mysterySlug: row.mystery_slug, levelSlug: row.level_slug, status: row.status }, node }
|
||||
}
|
||||
|
||||
@@ -336,7 +383,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
||||
`SELECT t.to_node_id, npc.name FROM osint.story_node_terminals t JOIN osint.npcs npc ON npc.id=t.npc_id
|
||||
WHERE t.parent_node_id=$1 AND regexp_replace(npc.phone_number,'\\D','','g')=$2 LIMIT 1`, [phoneNode.id, number])).rows[0]
|
||||
if (term?.to_node_id) {
|
||||
await pool.query('UPDATE osint.playthroughs SET current_node_id=$2,current_level_id=NULL,updated_at=NOW() WHERE id=$1', [playthroughId, term.to_node_id])
|
||||
await pool.query('UPDATE osint.playthroughs SET current_node_id=$2,updated_at=NOW() WHERE id=$1', [playthroughId, term.to_node_id])
|
||||
const state = await stateForPlaythrough(playthroughId)
|
||||
return { outcome: 'connect', name: term.name, state: state ?? undefined }
|
||||
}
|
||||
@@ -374,15 +421,15 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const playthrough = (await client.query<{ mystery_id: string; mystery_slug: string }>(
|
||||
`SELECT p.mystery_id, m.slug AS mystery_slug FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id
|
||||
const playthrough = (await client.query<{ mystery_id: string; mystery_slug: string; current_level_id: string | null }>(
|
||||
`SELECT p.mystery_id,p.current_level_id,m.slug AS mystery_slug FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id
|
||||
WHERE p.id=$1 AND p.user_id=$2 FOR UPDATE OF p`, [playthroughId, userId])).rows[0]
|
||||
if (!playthrough) { await client.query('ROLLBACK'); return { ok: false, error: 'Playthrough not found' } }
|
||||
const node = (await client.query<{ id: string; node_type: string; level_template_version_id: string | null; awards_flag: string | null }>(
|
||||
'SELECT id,node_type,level_template_version_id,awards_flag FROM osint.story_nodes WHERE id=$1 AND mystery_id=$2', [nodeId, playthrough.mystery_id])).rows[0]
|
||||
if (!node) { await client.query('ROLLBACK'); return { ok: false, error: 'Node not found' } }
|
||||
const levelId = node.node_type === 'level' && node.level_template_version_id
|
||||
? await instantiateLevel(client, node.level_template_version_id, playthrough.mystery_slug) : null
|
||||
? await instantiateLevel(client, node.level_template_version_id, playthrough.mystery_slug) : playthrough.current_level_id
|
||||
await client.query(`UPDATE osint.playthroughs SET status='active',current_node_id=$2,current_level_id=$3,updated_at=NOW() WHERE id=$1`, [playthroughId, node.id, levelId])
|
||||
await awardMeritWithin(client, playthroughId, node)
|
||||
await client.query('COMMIT')
|
||||
@@ -447,8 +494,11 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
||||
if (!target) {
|
||||
await client.query(`UPDATE osint.playthroughs SET status='finished',current_node_id=NULL,current_level_id=NULL,updated_at=NOW() WHERE id=$1`, [playthroughId])
|
||||
} else {
|
||||
// Keep the completed level attached while presentation/dialogue nodes run.
|
||||
// A cutscene such as case-adjudication can therefore resolve the accepted
|
||||
// report after navigation or reload. Entering a later level replaces it.
|
||||
const levelId = target.node_type === 'level' && target.level_template_version_id
|
||||
? await instantiateLevel(client, target.level_template_version_id, playthrough.mystery_slug) : null
|
||||
? await instantiateLevel(client, target.level_template_version_id, playthrough.mystery_slug) : playthrough.current_level_id
|
||||
await client.query('UPDATE osint.playthroughs SET current_node_id=$2,current_level_id=$3,updated_at=NOW() WHERE id=$1', [playthroughId, target.id, levelId])
|
||||
await awardMeritWithin(client, playthroughId, target)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user