diff --git a/docs/story-graph.md b/docs/story-graph.md index 6680e01..b6a91e3 100644 --- a/docs/story-graph.md +++ b/docs/story-graph.md @@ -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; diff --git a/e2e/adjudication.spec.ts b/e2e/adjudication.spec.ts new file mode 100644 index 0000000..e961bb0 --- /dev/null +++ b/e2e/adjudication.spec.ts @@ -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() +}) diff --git a/e2e/board.smoke.spec.ts b/e2e/board.smoke.spec.ts index 2cfbbca..0e5bbc3 100644 --- a/e2e/board.smoke.spec.ts +++ b/e2e/board.smoke.spec.ts @@ -51,11 +51,15 @@ test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch, await page.getByRole('button', { name: 'EVIDENCE', exact: true }).click() const documentRow = page.locator('[data-document-row-id="22222222-2222-4222-8222-222222222222"]') - await documentRow.click() + await documentRow.locator('.doc-row-main').click() await expect(documentRow).toHaveClass(/\bselected\b/) await expect(page.locator('[data-temporal-id="widget:11111111-1111-4111-8111-111111111111"]')).toHaveClass(/\bdocument-located\b/) await expect(page.locator('.document-locator-beam .document-locator-ray')).toBeVisible() - await documentRow.dblclick() + const transformBeforeLocate = await page.locator('.board').getAttribute('style') + await waitForSave(page, () => page.getByRole('button', { name: 'Locate Dated source image on board', exact: true }).click()) + await expect.poll(() => page.locator('.board').getAttribute('style')).not.toEqual(transformBeforeLocate) + await expect(documentRow).toHaveClass(/\bselected\b/) + await documentRow.locator('.doc-row-main').dblclick() await expect(page.getByRole('button', { name: 'Close document', exact: true })).toBeVisible() await page.getByRole('button', { name: 'Close document', exact: true }).click() await page.getByRole('button', { name: 'Close documents', exact: true }).click() diff --git a/mysteries/barricelli-files/mystery.json b/mysteries/barricelli-files/mystery.json index 06c0d0a..91263d4 100644 --- a/mysteries/barricelli-files/mystery.json +++ b/mysteries/barricelli-files/mystery.json @@ -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" } ] }, diff --git a/mysteries/barricelli-scene-7/mystery.json b/mysteries/barricelli-scene-7/mystery.json index a6e9453..a403053 100644 --- a/mysteries/barricelli-scene-7/mystery.json +++ b/mysteries/barricelli-scene-7/mystery.json @@ -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 } ] diff --git a/server/api.integration.test.ts b/server/api.integration.test.ts index 131f10a..30454e9 100644 --- a/server/api.integration.test.ts +++ b/server/api.integration.test.ts @@ -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') diff --git a/server/narrativeRepository.test.ts b/server/narrativeRepository.test.ts index 73e27c1..a4439e5 100644 --- a/server/narrativeRepository.test.ts +++ b/server/narrativeRepository.test.ts @@ -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) + }) +}) diff --git a/server/narrativeRepository.ts b/server/narrativeRepository.ts index 74ffde7..8d84437 100644 --- a/server/narrativeRepository.ts +++ b/server/narrativeRepository.ts @@ -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): Promise { + async function resolveAdjudicationPresentation(levelId: string | null): Promise { + 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): Promise { const node = (await pool.query('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 { - 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) } diff --git a/src/App.tsx b/src/App.tsx index 4bcc9e6..373095f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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([]) const [activePlaythroughId, setActivePlaythroughId] = useState(null) const [completedGoal, setCompletedGoal] = useState(null) + const [narrativeOverlay, setNarrativeOverlay] = useState(null) const [advancing, setAdvancing] = useState(false) const saveTimer = useRef(undefined) const boardRef = useRef(null) @@ -248,6 +249,23 @@ export function App() { : s.viewport })) } + const focusDocument = (id: string) => { + if (!caseState) return + const document = caseState.exhibits.find((exhibit): exhibit is CaseDocument => exhibit.id === id && exhibit.type === 'document') + if (!document) return + const membership = caseState.relations.find(relation => relation.type === 'contains' && relation.toExhibitId === document.id) + const folder = membership ? caseState.exhibits.find((exhibit): exhibit is FolderExhibit => exhibit.id === membership.fromExhibitId && exhibit.type === 'folder') : undefined + const visibleTarget = folder && !folder.isOpen ? folder : document + const bounds = boardRef.current?.getBoundingClientRect() + setSelected(document.id) + update(state => { + if (!bounds) return state + const focusViewport = { ...state.viewport, zoom: Math.max(state.viewport.zoom, .9) } + return { ...state, viewport: viewportCenteredOnExhibit(focusViewport, visibleTarget, { width: bounds.width, height: bounds.height }) } + }) + setStatus(`LOCATED EXHIBIT ${document.displayNumber || ''}`.trim()) + } + const extract = (doc: CaseDocument, regionId: string) => { if (!caseState) return const region = doc.regions.find(r => r.id === regionId)! @@ -527,7 +545,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) } } @@ -649,13 +685,15 @@ export function App() { <> { if (e.target.files) void uploadFiles(e.target.files); e.target.value = '' }} />
- {filteredDocuments.map((doc, index) => )} + {filteredDocuments.map((doc, index) =>
+ + +
)} {normalizedDocumentQuery && filteredDocuments.length === 0 &&
NO MATCHING DOCUMENTSSearches titles, contents, extracts, and metadata.
}
-
ARCHIVE MOUNTED {requestedEditMode && caseState.editingAllowed ? 'AUTHORING' : 'READ ONLY'}
{ if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) void uploadFiles(e.dataTransfer.files) }}> @@ -811,6 +849,8 @@ export function App() { {flagsOpen && setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />} {matchRulesOpen && setMatchRulesOpen(false)}/>} {completedGoal && void continueAfterGoal()}/>} + {narrativeOverlay?.kind === 'cutscene' && { if (!advancing) void continueAfterNarrativeOverlay() }}/>} } diff --git a/src/boardDomain.test.ts b/src/boardDomain.test.ts index 411be41..e3cd2f9 100644 --- a/src/boardDomain.test.ts +++ b/src/boardDomain.test.ts @@ -55,6 +55,7 @@ describe('red thread geometry', () => { it('draws a short foreground thread segment into the eyelet from the visual left', () => { const leftToRight = threadTagLeadIn({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, 50) expect(leftToRight.start.x).toBeLessThan(leftToRight.end.x) + expect(leftToRight.end.x - leftToRight.start.x).toBeCloseTo(54) expect(leftToRight.end).toEqual({ x: 100, y: 0 }) expect(leftToRight.path).toMatch(/^M .* C .* 100 0$/) diff --git a/src/boardDomain.ts b/src/boardDomain.ts index 88e0b22..233834a 100644 --- a/src/boardDomain.ts +++ b/src/boardDomain.ts @@ -99,7 +99,7 @@ export function threadTagLeadIn(from: BoardPoint, to: BoardPoint, tightness = 65 const tagT = position / 100 const tangent = cubicTangent(from, c1, c2, to, tagT) const speed = Math.max(1, Math.hypot(tangent.x, tangent.y)) - const leadLength = Math.max(.035, Math.min(.18, 42 / speed)) + const leadLength = Math.max(.0525, Math.min(.27, 63 / speed)) const entryT = Math.max(0, Math.min(1, tagT + (tangent.x < 0 ? leadLength : -leadLength))) const [start, leadC1, leadC2] = cubicSegment([from, c1, c2, to], entryT, tagT) const end = cubicPoint(from, c1, c2, to, tagT) diff --git a/src/narrative.tsx b/src/narrative.tsx index 4895244..803a5ff 100644 --- a/src/narrative.tsx +++ b/src/narrative.tsx @@ -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 = ({ onComplete }) => (
Greyhaven file 87-10 @@ -46,7 +49,59 @@ const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) =
) -const CUTSCENE_REGISTRY: Record void }>> = { 'glass-harbour-diversion': GlassHarbourDiversion } + +const CaseAdjudication: FC = ({ 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
+ +} + +const CUTSCENE_REGISTRY: Record> = { + '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
} -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 + if (Component) return return

{label}

diff --git a/src/narrativeContract.ts b/src/narrativeContract.ts new file mode 100644 index 0000000..34a8e30 --- /dev/null +++ b/src/narrativeContract.ts @@ -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 diff --git a/src/play.tsx b/src/play.tsx index 6a4c56d..9885c74 100644 --- a/src/play.tsx +++ b/src/play.tsx @@ -115,7 +115,7 @@ export function Play() { const node = state?.node if (!node) return
GU

GLITCH UNIVERSITY NETWORK TERMINAL

{status || 'OPENING CASE FILE…'}
- if (node.kind === 'cutscene') return { void advance() }} /> + if (node.kind === 'cutscene') return { void advance() }} /> if (node.kind === 'merit') return { void advance() }} /> if (node.kind === 'dialogue' && node.utterances) return { void advance(terminalKey) }} diff --git a/src/styles.css b/src/styles.css index 3b7ba8e..d32c4ca 100644 --- a/src/styles.css +++ b/src/styles.css @@ -34,19 +34,19 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .import-document { margin: 0 15px 12px; height: 34px; border: 1px dashed #a26d3d; background: #19322c; color: #d79754; display: flex; justify-content: center; align-items: center; gap: 8px; font: 600 9px IBM Plex Mono; letter-spacing: .08em; cursor: pointer; }.import-document:hover { background: #244039; border-style: solid; }.file-input { display: none; } .doc-list { overflow: auto; border-top: 1px solid #243d36; } .no-document-results { min-height: 150px; padding: 32px 20px; display: grid; place-items: center; align-content: center; gap: 9px; text-align: center; color: #617d75; }.no-document-results b { color: #91a59f; font: 600 9px IBM Plex Mono; letter-spacing: .08em; }.no-document-results span { font: 8px/1.5 IBM Plex Mono; } -.doc-row { width: 100%; min-height: 76px; border: 0; border-bottom: 1px solid #243d36; background: transparent; padding: 11px 13px; display: grid; grid-template-columns: 45px 1fr 18px; text-align: left; align-items: center; gap: 10px; cursor: pointer; } +.doc-row { position: relative; width: 100%; min-height: 76px; border-bottom: 1px solid #243d36; background: transparent; display: grid; grid-template-columns: minmax(0, 1fr) 38px; text-align: left; align-items: stretch; } .doc-row:hover { background: #18342e; } -.doc-row.selected { position: relative; background: #304534; box-shadow: inset 4px 0 #f1cf55, inset 0 0 20px #d8b94722; } +.doc-row.selected { background: #304534; box-shadow: inset 4px 0 #f1cf55, inset 0 0 20px #d8b94722; } .doc-row.selected::after { content: 'LOCATED'; position: absolute; top: 7px; right: 9px; color: #f0ce57; font: 600 6px IBM Plex Mono; letter-spacing: .12em; } .doc-row.selected .doc-icon { border-color: #e7c750; box-shadow: 0 0 12px #efd44f66, 3px 3px #081a16; } +.doc-row-main { min-width: 0; min-height: 76px; padding: 11px 7px 11px 13px; display: grid; grid-template-columns: 45px minmax(0, 1fr); align-items: center; gap: 10px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; } +.doc-row-locate { display: grid; place-items: center; margin: 25px 5px 8px 0; padding: 0; border: 1px solid transparent; background: transparent; color: #6f8f85; cursor: pointer; } +.doc-row-locate:hover { border-color: #9a683d; background: #102722; color: #e0a25d; } .doc-row strong { display: block; color: #d8dfda; font-size: 12px; margin-bottom: 6px; } .doc-row span { display: block; font: 9px IBM Plex Mono; color: #708b83; } -.doc-row > svg { color: #527068; } .doc-icon { height: 49px; border: 1px solid #61736d; display: grid; place-items: center; position: relative; color: #ccd2cd; background: #263c36; box-shadow: 3px 3px #081a16; } .doc-icon b { position: absolute; bottom: 2px; right: 2px; background: #d58e42; color: #14231f; font: 600 7px IBM Plex Mono; padding: 1px 3px; } .tint-1 { background: #38352d; } .tint-2 { background: #263c3f; } -.panel-foot { height: 42px; border-top: 1px solid #354b45; padding: 0 16px; display: flex; align-items: center; gap: 8px; font: 9px IBM Plex Mono; color: #718d84; } -.panel-foot span { margin-left: auto; color: #b27b43; } .board-shell { flex: 1; min-width: 0; position: relative; background: #0b1d19; } .case-heading { position:absolute;top:20px;left:27px;z-index:2;color:#dde2de;pointer-events:none; } .case-heading h1 { margin:0;font:500 24px Special Elite,serif;letter-spacing:.02em; } @@ -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; } diff --git a/tsconfig.node.json b/tsconfig.node.json index fe3865e..f22607f 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -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"] }