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
+12 -3
View File
@@ -107,11 +107,18 @@ Mirrors the exhibit registry: `component_key → React component`. Each owns its
presentation and signals completion with an optional terminal key.
```ts
type CutsceneComponent = React.FC<{ onComplete: (terminalKey?: string) => void }>
type CutsceneComponent = React.FC<{
label: string
presentation?: CutscenePresentation | null
onComplete: (terminalKey?: string) => void
}>
// registry: { 'glass-harbour-diversion': GlassHarbourDiversion }
```
For a single-terminal cutscene, `onComplete()` follows the only terminal.
Report-aware cutscenes receive server-resolved, read-only presentation data. The
`case-adjudication` component uses the accepted report associated with the most
recent level; it never evaluates or changes the verdict itself.
## Editing UX
@@ -136,8 +143,10 @@ output port:
## Runtime traversal
The playthrough tracks position with `current_node_id` (and `current_level_id`,
set while on a level node); the slot fields were dropped.
The playthrough tracks position with `current_node_id`. `current_level_id` points
to the most recently instantiated level until another level replaces it or the
playthrough ends. Retaining that reference lets immediately downstream cutscenes
and dialogue reload presentation data from the completed investigation.
```sql
ALTER TABLE osint.playthroughs ADD COLUMN current_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL;
+37
View File
@@ -0,0 +1,37 @@
import { expect, test } from '@playwright/test'
test('accepted report is adjudicated before the merit node', async ({ page }) => {
const cutsceneState = {
playthrough: { id: 'adjudication-playthrough', mysterySlug: 'barricelli-files', levelSlug: 'completed-level', status: 'active' },
node: {
id: 'adjudication-node', kind: 'cutscene', label: 'Scene 8 · Case adjudication', componentKey: 'case-adjudication',
presentation: {
kind: 'case-adjudication', reportTitle: 'Barricelli Inventor Finding', investigatorName: 'Test 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: 'GB695913A Improved chest of drawers', fileType: 'image',
relationText: 'Proves that Barricelli filed a patent.', publishedAt: '1953-08-19T00:00:00.000Z',
sourceCitation: 'Google Patents', sourceUri: 'https://patents.google.com/patent/GB695913A/en',
}] }],
},
},
}
await page.route('**/api/playthroughs/current', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(cutsceneState) }))
await page.route('**/api/playthroughs/adjudication-playthrough/advance', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
playthrough: cutsceneState.playthrough,
node: { id: 'merit-node', kind: 'merit', label: 'The Barricelli Luggage', awardsFlag: 'barricelli_luggage' },
}) }))
await page.goto('/?resume=1')
const adjudication = page.getByRole('dialog', { name: 'Case adjudication' })
await expect(adjudication).toContainText('Nils Aall Barricelli was an inventor.')
await expect(adjudication).toContainText('EXHIBIT 1 — ACCEPTED')
await expect(adjudication).toContainText('Google Patents')
await expect(adjudication).toContainText('SUPPORTED BY THE SUBMITTED EVIDENCE', { timeout: 5_000 })
await expect(adjudication).toHaveClass(/phase-stamped/, { timeout: 5_000 })
expect(await adjudication.locator('.adjudication-stamp').evaluate(element => Number.parseFloat(getComputedStyle(element).opacity))).toBeGreaterThan(0)
await page.getByRole('button', { name: 'RECEIVE MERIT' }).click()
await expect(page.getByText('MERIT AWARDED', { exact: false })).toBeVisible()
await expect(page.getByRole('heading', { name: 'The Barricelli Luggage' })).toBeVisible()
})
+21 -6
View File
@@ -145,14 +145,14 @@
{
"key": "scene-8-glitch-hunter",
"type": "dialogue",
"label": "Scene 8 · Glitch Hunter",
"label": "Scene 10 · Glitch Hunter",
"x": 200,
"y": 1140,
"y": 1500,
"terminals": [
{
"key": "continue",
"label": "Continue",
"to": "scene-9-the-barricelli-luggage"
"to": "scene-10-the-deep-web"
}
],
"utterances": [
@@ -165,6 +165,21 @@
}
]
},
{
"key": "scene-8-case-adjudication",
"type": "cutscene",
"label": "Scene 8 · Case adjudication",
"componentKey": "case-adjudication",
"x": 200,
"y": 1140,
"terminals": [
{
"key": "continue",
"label": "Receive merit",
"to": "scene-9-the-barricelli-luggage"
}
]
},
{
"key": "scene-9-the-barricelli-luggage",
"type": "merit",
@@ -177,7 +192,7 @@
{
"key": "continue",
"label": "Continue",
"to": "scene-10-the-deep-web"
"to": "scene-8-glitch-hunter"
}
]
},
@@ -186,7 +201,7 @@
"type": "dialogue",
"label": "Scene 10 · The deep web",
"x": 200,
"y": 1500,
"y": 1680,
"terminals": [
{
"key": "done",
@@ -241,7 +256,7 @@
{
"key": "report_back",
"label": "Report back",
"to": "scene-8-glitch-hunter"
"to": "scene-8-case-adjudication"
}
]
},
+13 -2
View File
@@ -20,7 +20,18 @@
"x": 200,
"y": 80,
"terminals": [
{ "key": "report_back", "label": "Submit finding", "to": "barricelli-luggage" }
{ "key": "report_back", "label": "Submit finding", "to": "case-adjudication" }
]
},
{
"key": "case-adjudication",
"type": "cutscene",
"label": "Case adjudication",
"componentKey": "case-adjudication",
"x": 200,
"y": 300,
"terminals": [
{ "key": "continue", "label": "Receive merit", "to": "barricelli-luggage" }
]
},
{
@@ -29,7 +40,7 @@
"label": "The Barricelli Luggage",
"awardsFlag": "barricelli_luggage",
"x": 200,
"y": 300,
"y": 520,
"terminals": [
{ "key": "continue", "label": "Accept", "to": null }
]
+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)
}
+23 -2
View File
@@ -5,7 +5,7 @@ import { AdminPanel } from './admin'
import { audio } from './audio'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLeadIn, threadTagPlacement, timelinePositionPercent, timelineRange, viewportCenteredOnExhibit, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
import type { PlaythroughState } from './narrative'
import { CutsceneHost, type PlaythroughState, type RuntimeNode } from './narrative'
// three.js stays out of the board bundle until the player opens the inventory.
const Inventory = lazy(() => import('./inventory').then(m => ({ default: m.Inventory })))
@@ -89,6 +89,7 @@ export function App() {
const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
const [activePlaythroughId, setActivePlaythroughId] = useState<string | null>(null)
const [completedGoal, setCompletedGoal] = useState<LevelGoal | null>(null)
const [narrativeOverlay, setNarrativeOverlay] = useState<RuntimeNode | null>(null)
const [advancing, setAdvancing] = useState(false)
const saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null)
@@ -527,7 +528,25 @@ export function App() {
if (!response.ok) { setStatus('OBJECTIVE COMPLETE · STORY ADVANCE UNAVAILABLE'); return }
const next = await response.json() as PlaythroughState
if (next.node?.kind === 'level' && next.node.levelSlug) window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`)
else window.location.assign('/?resume=1') // resume straight into the next node, not the splash
else if (next.node?.kind === 'cutscene') {
setReportOpen(false)
setCompletedGoal(null)
setNarrativeOverlay(next.node)
} else window.location.assign('/?resume=1') // resume straight into the next node, not the splash
} finally { setAdvancing(false) }
}
const continueAfterNarrativeOverlay = async () => {
if (!activePlaythroughId) return
setAdvancing(true)
try {
const response = await fetch(`/api/playthroughs/${encodeURIComponent(activePlaythroughId)}/advance`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
})
if (!response.ok) { setStatus('STORY ADVANCE UNAVAILABLE'); return }
const next = await response.json() as PlaythroughState
if (next.node?.kind === 'level' && next.node.levelSlug) window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`)
else window.location.assign('/?resume=1')
} finally { setAdvancing(false) }
}
@@ -811,6 +830,8 @@ export function App() {
{flagsOpen && <LevelFlagsEditor levelId={caseState.id} onClose={() => setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />}
{matchRulesOpen && <EvidenceMatchRulesEditor levelId={caseState.id} onClose={() => setMatchRulesOpen(false)}/>}
{completedGoal && <GoalComplete goal={completedGoal} hasNext={Boolean(activePlaythroughId)} busy={advancing} onContinue={() => void continueAfterGoal()}/>}
{narrativeOverlay?.kind === 'cutscene' && <CutsceneHost componentKey={narrativeOverlay.componentKey} label={narrativeOverlay.label}
presentation={narrativeOverlay.presentation} onComplete={() => { if (!advancing) void continueAfterNarrativeOverlay() }}/>}
</main>
}
+60 -5
View File
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState, type FC } from 'react'
import { audio } from './audio'
import { BarricelliLuggageMerit } from './merits'
import type { CutscenePresentation } from './narrativeContract'
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null; awardsFlag?: string | null }
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number; awardsFlag?: string | null; utterances?: RuntimeUtterance[]; rootId?: string | null }
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number; awardsFlag?: string | null; presentation?: CutscenePresentation | null; utterances?: RuntimeUtterance[]; rootId?: string | null }
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
@@ -37,7 +38,9 @@ export function SplashScreen({ hasResume, busy, status, onNewGame, onResume }: {
}
// Bespoke cutscene components, keyed by a node's component_key (mirrors the exhibit registry).
const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) => (
type CutsceneComponentProps = { label: string; presentation?: CutscenePresentation | null; onComplete: () => void }
const GlassHarbourDiversion: FC<CutsceneComponentProps> = ({ onComplete }) => (
<div className="cutscene-card title-card" onClick={onComplete}>
<div className="title-card-inner">
<small>Greyhaven file 87-10</small>
@@ -46,7 +49,59 @@ const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) =
</div>
</div>
)
const CUTSCENE_REGISTRY: Record<string, FC<{ onComplete: () => void }>> = { 'glass-harbour-diversion': GlassHarbourDiversion }
const CaseAdjudication: FC<CutsceneComponentProps> = ({ label, presentation, onComplete }) => {
const reducedMotion = usePrefersReducedMotion()
const [phase, setPhase] = useState<'reviewing' | 'finding' | 'stamped'>(reducedMotion ? 'stamped' : 'reviewing')
const finding = presentation?.kind === 'case-adjudication' ? presentation.finding : 'SUPPORTED BY THE SUBMITTED EVIDENCE'
const [findingCharacters, setFindingCharacters] = useState(reducedMotion ? finding.length : 0)
const claims = presentation?.kind === 'case-adjudication' ? presentation.claims : [{ statement: label, evidence: [] }]
const stampText = presentation?.kind === 'case-adjudication' ? presentation.stampText : 'CASE VERIFIED'
useEffect(() => {
if (reducedMotion) return
const findingTimer = window.setTimeout(() => setPhase('finding'), 650)
const stampTimer = window.setTimeout(() => { setPhase('stamped'); audio.sfx('sting') }, 2850)
return () => { window.clearTimeout(findingTimer); window.clearTimeout(stampTimer) }
}, [reducedMotion])
useEffect(() => {
if (phase === 'reviewing' || findingCharacters >= finding.length) return
if (reducedMotion) { setFindingCharacters(finding.length); return }
const timer = window.setTimeout(() => {
setFindingCharacters(count => Math.min(finding.length, count + 1))
if (findingCharacters % 3 === 0) audio.type()
}, 22)
return () => window.clearTimeout(timer)
}, [phase, finding, findingCharacters, reducedMotion])
return <div className={`case-adjudication phase-${phase}`} role="dialog" aria-label="Case adjudication">
<div className="adjudication-veil" aria-hidden="true"/>
<article className="adjudication-paper">
<header><span>GLITCH UNIVERSITY</span><b>GUPI EVIDENCE REVIEW</b><small>{presentation?.kind === 'case-adjudication' ? presentation.reportTitle : label}</small></header>
<div className="adjudication-rule"/>
{claims.map((claim, claimIndex) => <section className="adjudication-claim" key={`${claimIndex}:${claim.statement}`}>
<h2>CLAIM {String(claimIndex + 1).padStart(2, '0')}</h2>
<blockquote>{claim.statement}</blockquote>
<h3>EVIDENCE REVIEWED</h3>
{claim.evidence.length ? claim.evidence.map(item => <div className="adjudication-evidence" key={`${item.displayNumber}:${item.title}`}>
<b>EXHIBIT {item.displayNumber} ACCEPTED</b>
<span>{item.title}{item.publishedAt ? ` · ${item.publishedAt.slice(0, 10)}` : ''}</span>
{item.sourceCitation && <small>{item.sourceCitation}</small>}
</div>) : <div className="adjudication-evidence"><b>ACCEPTED REPORT ON FILE</b></div>}
</section>)}
<div className="adjudication-finding"><span>FINDING</span><strong>{finding.slice(0, findingCharacters)}{phase === 'finding' && findingCharacters < finding.length ? '▍' : ''}</strong></div>
{presentation?.kind === 'case-adjudication' && <footer><span>INVESTIGATOR: {presentation.investigatorName}</span><span>FILED: {presentation.submittedAt.slice(0, 10)}</span></footer>}
<div className="adjudication-stamp" aria-hidden={phase !== 'stamped'}>{stampText}</div>
{phase === 'stamped' && <button type="button" onClick={onComplete}>RECEIVE MERIT <span></span></button>}
</article>
</div>
}
const CUTSCENE_REGISTRY: Record<string, FC<CutsceneComponentProps>> = {
'glass-harbour-diversion': GlassHarbourDiversion,
'case-adjudication': CaseAdjudication,
}
export const CUTSCENE_COMPONENT_KEYS = Object.keys(CUTSCENE_REGISTRY)
// Merit ceremony components, keyed by a merit node's component_key (e.g. a 3D
@@ -68,9 +123,9 @@ export function MeritHost({ componentKey, label, awardsFlag, onComplete }: { com
</div>
}
export function CutsceneHost({ componentKey, label, onComplete }: { componentKey: string | null | undefined; label: string; onComplete: () => void }) {
export function CutsceneHost({ componentKey, label, presentation, onComplete }: { componentKey: string | null | undefined; label: string; presentation?: CutscenePresentation | null; onComplete: () => void }) {
const Component = componentKey ? CUTSCENE_REGISTRY[componentKey] : undefined
if (Component) return <Component onComplete={onComplete} />
if (Component) return <Component label={label} presentation={presentation} onComplete={onComplete} />
return <div className="cutscene-card title-card" onClick={onComplete}>
<div className="title-card-inner">
<h1>{label}</h1>
+26
View File
@@ -0,0 +1,26 @@
export type CaseAdjudicationEvidence = {
displayNumber: number
title: string
fileType: string
relationText: string
publishedAt?: string
sourceCitation?: string
sourceUri?: string
}
export type CaseAdjudicationClaim = {
statement: string
evidence: CaseAdjudicationEvidence[]
}
export type CaseAdjudicationPresentation = {
kind: 'case-adjudication'
reportTitle: string
investigatorName: string
submittedAt: string
finding: string
stampText: string
claims: CaseAdjudicationClaim[]
}
export type CutscenePresentation = CaseAdjudicationPresentation
+1 -1
View File
@@ -115,7 +115,7 @@ export function Play() {
const node = state?.node
if (!node) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status || 'OPENING CASE FILE…'}</small></div>
if (node.kind === 'cutscene') return <CutsceneHost componentKey={node.componentKey} label={node.label} onComplete={() => { void advance() }} />
if (node.kind === 'cutscene') return <CutsceneHost componentKey={node.componentKey} label={node.label} presentation={node.presentation} onComplete={() => { void advance() }} />
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} onComplete={() => { void advance() }} />
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }}
onExit={terminalKey => { void advance(terminalKey) }}
+51 -1
View File
@@ -656,9 +656,59 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.cutscene-missing { color: #b56d30 !important; letter-spacing: .08em !important; }
.cutscene-begin { margin-top: 10px; background: #a2551f; border: 1px solid #d58a46; color: #f6ead9; padding: 11px 24px; font: 11px IBM Plex Mono; letter-spacing: .16em; cursor: pointer; }
.cutscene-begin:hover { background: #b8622a; }
.case-adjudication { position: fixed; z-index: 260; inset: 0; display: grid; place-items: center; padding: clamp(14px, 4vw, 46px); overflow: auto; cursor: default; }
.adjudication-veil { position: fixed; inset: 0; background: #020706bd; backdrop-filter: blur(2px) brightness(.54); animation: adjudication-veil-in .65s ease both; }
.adjudication-paper { position: relative; width: min(690px, 100%); min-height: min(760px, calc(100dvh - 40px)); padding: clamp(28px, 5vw, 54px); overflow: hidden; color: #25231e; background:
linear-gradient(90deg, transparent 46px, #aa66552c 47px, transparent 48px),
repeating-linear-gradient(#eee9da 0 31px, #9aacaa30 32px, #eee9da 33px);
border: 1px solid #a29b89; box-shadow: 0 2px 0 #fff9 inset, 14px 18px 32px #000a; transform-origin: 50% 85%; animation: adjudication-paper-in .72s cubic-bezier(.16,.8,.2,1) both; }
.adjudication-paper > header { display: grid; gap: 6px; padding: 0 0 17px; border-bottom: 3px double #4f514c; text-align: center; }
.adjudication-paper > header span { color: #745232; font: 600 9px IBM Plex Mono; letter-spacing: .28em; }
.adjudication-paper > header b { font: 28px/1.1 Special Elite; letter-spacing: .05em; }
.adjudication-paper > header small { color: #55564f; font: 9px IBM Plex Mono; letter-spacing: .08em; text-transform: uppercase; }
.adjudication-rule { height: 1px; margin: 4px 0 20px; background: #55564f; }
.adjudication-claim { margin-bottom: 20px; }
.adjudication-claim h2, .adjudication-claim h3 { margin: 0 0 7px; color: #5d4e3d; font: 600 8px IBM Plex Mono; letter-spacing: .14em; }
.adjudication-claim blockquote { margin: 0 0 18px; padding: 0; border: 0; font: 21px/1.45 Special Elite; }
.adjudication-evidence { display: grid; gap: 3px; margin: 0 0 8px; padding: 8px 10px; border-left: 3px solid #527160; background: #f4f0e4b8; }
.adjudication-evidence b { color: #385546; font: 600 8px IBM Plex Mono; letter-spacing: .1em; }
.adjudication-evidence span { font: 12px/1.35 Special Elite; }
.adjudication-evidence small { color: #66655d; font: 8px/1.35 IBM Plex Mono; }
.adjudication-finding { min-height: 76px; margin-top: 25px; padding: 13px 14px; border: 1px solid #696a63; background: #e3dececc; display: grid; align-content: center; gap: 7px; }
.adjudication-finding span { color: #67513b; font: 600 8px IBM Plex Mono; letter-spacing: .15em; }
.adjudication-finding strong { min-height: 1.4em; font: 17px/1.35 Special Elite; letter-spacing: .035em; }
.phase-reviewing .adjudication-finding { opacity: .42; }
.adjudication-paper > footer { display: flex; justify-content: space-between; gap: 15px; margin-top: 17px; color: #66645d; font: 7px IBM Plex Mono; letter-spacing: .07em; }
.adjudication-stamp { position: absolute; right: clamp(25px, 7vw, 74px); bottom: clamp(70px, 10vw, 102px); max-width: 235px; padding: 11px 14px 8px; transform: rotate(-7deg) scale(2.4); border: 5px double #9d2826; color: #9d2826; opacity: 0; font: 600 25px/1 Special Elite; letter-spacing: .08em; text-align: center; text-transform: uppercase; mix-blend-mode: multiply; }
.phase-stamped .adjudication-stamp { animation: adjudication-stamp .28s cubic-bezier(.1,.85,.2,1.15) forwards; }
.adjudication-paper > button { position: relative; z-index: 2; float: right; margin-top: 82px; padding: 11px 17px; border: 1px solid #694f37; background: #273f35; color: #f2eddf; box-shadow: 3px 4px #281f18; cursor: pointer; font: 600 9px IBM Plex Mono; letter-spacing: .12em; animation: adjudication-button-in .35s .25s ease both; }
.adjudication-paper > button:hover { background: #36594a; }
.desktop:has(.case-adjudication) .connections path, .desktop:has(.case-adjudication) .thread-lead-ins path { stroke-width: 3.8; animation: adjudication-thread-tighten .65s cubic-bezier(.2,.8,.2,1) both; }
@keyframes adjudication-veil-in { from { opacity: 0; } }
@keyframes adjudication-paper-in { from { opacity: 0; transform: translateY(42px) rotate(.7deg) scale(.96); } }
@keyframes adjudication-thread-tighten { from { stroke-dasharray: 2 8; opacity: .5; } to { stroke-dasharray: 1000 0; opacity: 1; } }
@keyframes adjudication-stamp { 0% { opacity: 0; transform: rotate(-7deg) scale(2.4); } 72% { opacity: .9; transform: rotate(-7deg) scale(.91); } 100% { opacity: .82; transform: rotate(-7deg) scale(1); } }
@keyframes adjudication-button-in { from { opacity: 0; transform: translateY(8px); } }
.menubar nav button.report-back { color: #f6ead9; background: #a2551f; }
.menubar nav button.report-back:hover { background: #b8622a; }
@media (max-width: 900px) { .title-card-inner h1 { font-size: 30px; } }
@media (max-width: 900px) {
.title-card-inner h1 { font-size: 30px; }
.case-adjudication { align-items: start; padding: 0; }
.adjudication-paper { width: 100%; min-height: 100dvh; padding: 28px 24px 38px; border: 0; }
.adjudication-paper > header b { font-size: 23px; }
.adjudication-claim blockquote { font-size: 19px; }
.adjudication-evidence span { font-size: 14px; }
.adjudication-finding strong { font-size: 16px; }
.adjudication-paper > footer { display: grid; }
.adjudication-stamp { right: 24px; bottom: 92px; max-width: 205px; font-size: 21px; }
.adjudication-paper > button { margin-top: 78px; min-height: 46px; }
}
@media (prefers-reduced-motion: reduce) {
.adjudication-veil, .adjudication-paper, .phase-stamped .adjudication-stamp, .adjudication-paper > button,
.desktop:has(.case-adjudication) .connections path, .desktop:has(.case-adjudication) .thread-lead-ins path { animation: none; }
.phase-stamped .adjudication-stamp { opacity: .82; transform: rotate(-7deg); }
}
/* Utterance cards: expand to full content, ports anchored to top, colour by speaker */
.ucard { height: auto; min-height: 58px; }
+1 -1
View File
@@ -9,5 +9,5 @@
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts", "playwright.config.ts", "server/**/*.ts", "scripts/**/*.ts", "e2e/**/*.ts", "src/types.ts"]
"include": ["vite.config.ts", "playwright.config.ts", "server/**/*.ts", "scripts/**/*.ts", "e2e/**/*.ts", "src/types.ts", "src/narrativeContract.ts"]
}