Add case adjudication cutscene

This commit is contained in:
2026-08-23 23:21:24 +02:00
parent 0c4e0db118
commit 31a4b52832
13 changed files with 330 additions and 33 deletions
+2 -1
View File
@@ -309,8 +309,9 @@ suite('normalized level persistence API', () => {
const mysteries = await (await adminFetch(`${baseUrl}/api/admin/mysteries`)).json() as { id:string;slug:string }[]
const mysteryId = mysteries.find(mystery => mystery.slug === 'barricelli-inventor-proof')!.id
const graph = await (await adminFetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`)).json() as StoryGraphDto
expect(graph.nodes).toHaveLength(2)
expect(graph.nodes).toHaveLength(3)
expect(graph.nodes.find(node => node.nodeType === 'level')).toMatchObject({ label:'Demonstrate OSINT skill',levelTemplateVersionId:expect.any(String) })
expect(graph.nodes.find(node => node.nodeType === 'cutscene')).toMatchObject({ label:'Case adjudication',componentKey:'case-adjudication' })
expect(graph.nodes.find(node => node.nodeType === 'merit')).toMatchObject({ label:'The Barricelli Luggage',awardsFlag:'barricelli_luggage' })
const fixtureDir = path.join(path.dirname(manifestPath), 'fixtures')
+23 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { resolvePoseAssetId } from './narrativeRepository.js'
import { buildAdjudicationPresentation, resolvePoseAssetId } from './narrativeRepository.js'
describe('resolvePoseAssetId', () => {
const poses = { neutral: 'asset-neutral', concerned: 'asset-concerned', missing: null }
@@ -19,3 +19,25 @@ describe('resolvePoseAssetId', () => {
expect(resolvePoseAssetId({}, 'neutral', 'neutral')).toBeNull()
})
})
describe('buildAdjudicationPresentation', () => {
it('presents only evidence accepted by the submitted report', () => {
const presentation = buildAdjudicationPresentation({
title: 'Barricelli Inventor Finding', investigatorName: 'Mutable byline', requiredForCompletion: true,
status: 'accepted', issues: [], claims: [{ claimExhibitId: 'claim-1', statement: 'Nils Aall Barricelli was an inventor.', evidence: [
{ connectionId: 'connection-1', documentExhibitId: 'document-1', displayNumber: 1, documentTitle: 'Patent record', fileType: 'image',
relationText: 'Proves that Barricelli filed a patent.', publishedAt: '1953-08-19T00:00:00.000Z', sourceCitation: 'Google Patents', sourceUri: 'https://patents.example/1',
evidenceAccepted: true, verification: { status: 'accepted', detail: 'Matched.' } },
{ connectionId: 'connection-2', documentExhibitId: 'document-2', displayNumber: 2, documentTitle: 'Unverified image', fileType: 'image',
relationText: 'Maybe related.', evidenceAccepted: false, verification: { status: 'not_evaluated', detail: 'Not checked.' } },
] }],
}, { investigatorName: 'Ada Investigator', submittedAt: new Date('2026-08-23T12:00:00.000Z') })
expect(presentation).toMatchObject({
kind: 'case-adjudication', investigatorName: 'Ada Investigator', submittedAt: '2026-08-23T12:00:00.000Z',
finding: 'SUPPORTED BY THE SUBMITTED EVIDENCE', stampText: 'CASE VERIFIED',
claims: [{ statement: 'Nils Aall Barricelli was an inventor.', evidence: [{ displayNumber: 1, title: 'Patent record' }] }],
})
expect(presentation.claims[0].evidence).toHaveLength(1)
})
})
+60 -10
View File
@@ -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)
}