Merge branch 'main' of ssh://ramanujan.glitch.university:2222/glitch-university/gupi-osint-board

This commit is contained in:
2026-08-23 23:34:13 +02:00
16 changed files with 367 additions and 46 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. presentation and signals completion with an optional terminal key.
```ts ```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 } // registry: { 'glass-harbour-diversion': GlassHarbourDiversion }
``` ```
For a single-terminal cutscene, `onComplete()` follows the only terminal. 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 ## Editing UX
@@ -136,8 +143,10 @@ output port:
## Runtime traversal ## Runtime traversal
The playthrough tracks position with `current_node_id` (and `current_level_id`, The playthrough tracks position with `current_node_id`. `current_level_id` points
set while on a level node); the slot fields were dropped. 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 ```sql
ALTER TABLE osint.playthroughs ADD COLUMN current_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL; 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()
})
+6 -2
View File
@@ -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() await page.getByRole('button', { name: 'EVIDENCE', exact: true }).click()
const documentRow = page.locator('[data-document-row-id="22222222-2222-4222-8222-222222222222"]') 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(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('[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 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 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 document', exact: true }).click()
await page.getByRole('button', { name: 'Close documents', exact: true }).click() await page.getByRole('button', { name: 'Close documents', exact: true }).click()
+21 -6
View File
@@ -145,14 +145,14 @@
{ {
"key": "scene-8-glitch-hunter", "key": "scene-8-glitch-hunter",
"type": "dialogue", "type": "dialogue",
"label": "Scene 8 · Glitch Hunter", "label": "Scene 10 · Glitch Hunter",
"x": 200, "x": 200,
"y": 1140, "y": 1500,
"terminals": [ "terminals": [
{ {
"key": "continue", "key": "continue",
"label": "Continue", "label": "Continue",
"to": "scene-9-the-barricelli-luggage" "to": "scene-10-the-deep-web"
} }
], ],
"utterances": [ "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", "key": "scene-9-the-barricelli-luggage",
"type": "merit", "type": "merit",
@@ -177,7 +192,7 @@
{ {
"key": "continue", "key": "continue",
"label": "Continue", "label": "Continue",
"to": "scene-10-the-deep-web" "to": "scene-8-glitch-hunter"
} }
] ]
}, },
@@ -186,7 +201,7 @@
"type": "dialogue", "type": "dialogue",
"label": "Scene 10 · The deep web", "label": "Scene 10 · The deep web",
"x": 200, "x": 200,
"y": 1500, "y": 1680,
"terminals": [ "terminals": [
{ {
"key": "done", "key": "done",
@@ -241,7 +256,7 @@
{ {
"key": "report_back", "key": "report_back",
"label": "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, "x": 200,
"y": 80, "y": 80,
"terminals": [ "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", "label": "The Barricelli Luggage",
"awardsFlag": "barricelli_luggage", "awardsFlag": "barricelli_luggage",
"x": 200, "x": 200,
"y": 300, "y": 520,
"terminals": [ "terminals": [
{ "key": "continue", "label": "Accept", "to": null } { "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 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 mysteryId = mysteries.find(mystery => mystery.slug === 'barricelli-inventor-proof')!.id
const graph = await (await adminFetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`)).json() as StoryGraphDto 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 === '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' }) expect(graph.nodes.find(node => node.nodeType === 'merit')).toMatchObject({ label:'The Barricelli Luggage',awardsFlag:'barricelli_luggage' })
const fixtureDir = path.join(path.dirname(manifestPath), 'fixtures') const fixtureDir = path.join(path.dirname(manifestPath), 'fixtures')
+23 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { resolvePoseAssetId } from './narrativeRepository.js' import { buildAdjudicationPresentation, resolvePoseAssetId } from './narrativeRepository.js'
describe('resolvePoseAssetId', () => { describe('resolvePoseAssetId', () => {
const poses = { neutral: 'asset-neutral', concerned: 'asset-concerned', missing: null } const poses = { neutral: 'asset-neutral', concerned: 'asset-concerned', missing: null }
@@ -19,3 +19,25 @@ describe('resolvePoseAssetId', () => {
expect(resolvePoseAssetId({}, 'neutral', 'neutral')).toBeNull() 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 { createHash, randomUUID } from 'node:crypto'
import type { Pool, PoolClient } from 'pg' 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 { cloneBoard } from './boardClone.js'
import { loadCaseReport } from './caseReports.js'
import type { ObjectStorage } from './objectStorage.js' import type { ObjectStorage } from './objectStorage.js'
export type UploadedFile = { buffer: Buffer; originalname: string; mimetype: string; size: number } export type UploadedFile = { buffer: Buffer; originalname: string; mimetype: string; size: number }
@@ -17,6 +20,7 @@ export type RuntimeUtterance = {
export type RuntimeNode = { export type RuntimeNode = {
id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string
componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number
presentation?: CutscenePresentation | null
awardsFlag?: string | null awardsFlag?: string | null
utterances?: RuntimeUtterance[]; rootId?: string | null utterances?: RuntimeUtterance[]; rootId?: string | null
} }
@@ -49,6 +53,32 @@ export function resolvePoseAssetId(
return null 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 { export interface NarrativeRepository {
authorMystery(input: MysteryAuthoring): Promise<{ slug: string }> authorMystery(input: MysteryAuthoring): Promise<{ slug: string }>
resolveDialogue(nodeId: string): Promise<{ utterances: RuntimeUtterance[]; rootId: string | null }> 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] 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 if (!node) return null
const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null
const musicVolume = node.music_volume / 100 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 === '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 === '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 } 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> { 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' }>( 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,l.slug AS level_slug,p.status `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 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] WHERE p.id=$1`, [playthroughId])).rows[0]
if (!row) return null 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 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 } 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 `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] 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) { 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) const state = await stateForPlaythrough(playthroughId)
return { outcome: 'connect', name: term.name, state: state ?? undefined } 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() const client = await pool.connect()
try { try {
await client.query('BEGIN') await client.query('BEGIN')
const playthrough = (await client.query<{ mystery_id: string; mystery_slug: string }>( const playthrough = (await client.query<{ mystery_id: string; mystery_slug: string; current_level_id: string | null }>(
`SELECT p.mystery_id, m.slug AS mystery_slug FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id `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] 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' } } 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 }>( 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] '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' } } if (!node) { await client.query('ROLLBACK'); return { ok: false, error: 'Node not found' } }
const levelId = node.node_type === 'level' && node.level_template_version_id 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 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 awardMeritWithin(client, playthroughId, node)
await client.query('COMMIT') await client.query('COMMIT')
@@ -447,8 +494,11 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
if (!target) { 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]) 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 { } 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 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 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) await awardMeritWithin(client, playthroughId, target)
} }
+46 -6
View File
@@ -5,7 +5,7 @@ import { AdminPanel } from './admin'
import { audio } from './audio' 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 { 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 { 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. // three.js stays out of the board bundle until the player opens the inventory.
const Inventory = lazy(() => import('./inventory').then(m => ({ default: m.Inventory }))) const Inventory = lazy(() => import('./inventory').then(m => ({ default: m.Inventory })))
@@ -89,6 +89,7 @@ export function App() {
const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([]) const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
const [activePlaythroughId, setActivePlaythroughId] = useState<string | null>(null) const [activePlaythroughId, setActivePlaythroughId] = useState<string | null>(null)
const [completedGoal, setCompletedGoal] = useState<LevelGoal | null>(null) const [completedGoal, setCompletedGoal] = useState<LevelGoal | null>(null)
const [narrativeOverlay, setNarrativeOverlay] = useState<RuntimeNode | null>(null)
const [advancing, setAdvancing] = useState(false) const [advancing, setAdvancing] = useState(false)
const saveTimer = useRef<number | undefined>(undefined) const saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null) const boardRef = useRef<HTMLDivElement>(null)
@@ -248,6 +249,23 @@ export function App() {
: s.viewport })) : 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) => { const extract = (doc: CaseDocument, regionId: string) => {
if (!caseState) return if (!caseState) return
const region = doc.regions.find(r => r.id === regionId)! 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 } if (!response.ok) { setStatus('OBJECTIVE COMPLETE · STORY ADVANCE UNAVAILABLE'); return }
const next = await response.json() as PlaythroughState const next = await response.json() as PlaythroughState
if (next.node?.kind === 'level' && next.node.levelSlug) window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`) 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) } } finally { setAdvancing(false) }
} }
@@ -649,13 +685,15 @@ export function App() {
<label className="search"><Search size={15}/><input type="search" aria-label="Search inside documents" placeholder="Search inside documents…" value={documentQuery} onChange={event => setDocumentQuery(event.target.value)}/>{documentQuery && <button type="button" aria-label="Clear document search" onClick={() => setDocumentQuery('')}><X size={13}/></button>}</label> <label className="search"><Search size={15}/><input type="search" aria-label="Search inside documents" placeholder="Search inside documents…" value={documentQuery} onChange={event => setDocumentQuery(event.target.value)}/>{documentQuery && <button type="button" aria-label="Clear document search" onClick={() => setDocumentQuery('')}><X size={13}/></button>}</label>
<><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}` : 'ADD DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) void uploadFiles(e.target.files); e.target.value = '' }} /></> <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}` : 'ADD DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) void uploadFiles(e.target.files); e.target.value = '' }} /></>
<div className="doc-list"> <div className="doc-list">
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''} ${arrivingExhibitIds.includes(doc.id) ? 'arriving' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}> {filteredDocuments.map((doc, index) => <div className={`doc-row ${selected === doc.id ? 'selected' : ''} ${arrivingExhibitIds.includes(doc.id) ? 'arriving' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id}>
<button className="doc-row-main" type="button" title="Click to highlight on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.fileType.slice(0, 3)}</b></div> <div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.fileType.slice(0, 3)}</b></div>
<div><strong>{doc.title}</strong><span>EXHIBIT {doc.displayNumber || index + 1} · {doc.captureKind === 'unclassified' ? documentWidget(doc.fileType).label : documentCapture(doc.captureKind).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/> <div><strong>{doc.title}</strong><span>EXHIBIT {doc.displayNumber || index + 1} · {doc.captureKind === 'unclassified' ? documentWidget(doc.fileType).label : documentCapture(doc.captureKind).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div>
</button>)} </button>
<button className="doc-row-locate" type="button" aria-label={`Locate ${doc.title || `Exhibit ${doc.displayNumber || index + 1}`} on board`} title="Zoom to document" onClick={() => focusDocument(doc.id)}><ChevronRight size={16}/></button>
</div>)}
{normalizedDocumentQuery && filteredDocuments.length === 0 && <div className="no-document-results"><Search size={20}/><b>NO MATCHING DOCUMENTS</b><span>Searches titles, contents, extracts, and metadata.</span></div>} {normalizedDocumentQuery && filteredDocuments.length === 0 && <div className="no-document-results"><Search size={20}/><b>NO MATCHING DOCUMENTS</b><span>Searches titles, contents, extracts, and metadata.</span></div>}
</div> </div>
<div className="panel-foot"><FolderOpen size={15}/> ARCHIVE MOUNTED <span>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING' : 'READ ONLY'}</span></div>
</aside> </aside>
<div className="board-shell" onDragEnter={e => { 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) }}> <div className="board-shell" onDragEnter={e => { 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 && <LevelFlagsEditor levelId={caseState.id} onClose={() => setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />} {flagsOpen && <LevelFlagsEditor levelId={caseState.id} onClose={() => setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />}
{matchRulesOpen && <EvidenceMatchRulesEditor levelId={caseState.id} onClose={() => setMatchRulesOpen(false)}/>} {matchRulesOpen && <EvidenceMatchRulesEditor levelId={caseState.id} onClose={() => setMatchRulesOpen(false)}/>}
{completedGoal && <GoalComplete goal={completedGoal} hasNext={Boolean(activePlaythroughId)} busy={advancing} onContinue={() => void continueAfterGoal()}/>} {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> </main>
} }
+1
View File
@@ -55,6 +55,7 @@ describe('red thread geometry', () => {
it('draws a short foreground thread segment into the eyelet from the visual left', () => { 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) const leftToRight = threadTagLeadIn({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, 50)
expect(leftToRight.start.x).toBeLessThan(leftToRight.end.x) 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.end).toEqual({ x: 100, y: 0 })
expect(leftToRight.path).toMatch(/^M .* C .* 100 0$/) expect(leftToRight.path).toMatch(/^M .* C .* 100 0$/)
+1 -1
View File
@@ -99,7 +99,7 @@ export function threadTagLeadIn(from: BoardPoint, to: BoardPoint, tightness = 65
const tagT = position / 100 const tagT = position / 100
const tangent = cubicTangent(from, c1, c2, to, tagT) const tangent = cubicTangent(from, c1, c2, to, tagT)
const speed = Math.max(1, Math.hypot(tangent.x, tangent.y)) 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 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 [start, leadC1, leadC2] = cubicSegment([from, c1, c2, to], entryT, tagT)
const end = cubicPoint(from, c1, c2, to, tagT) const end = cubicPoint(from, c1, c2, to, tagT)
+60 -5
View File
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState, type FC } from 'react' import { useEffect, useMemo, useRef, useState, type FC } from 'react'
import { audio } from './audio' import { audio } from './audio'
import { BarricelliLuggageMerit } from './merits' 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 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 PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null } 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). // 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="cutscene-card title-card" onClick={onComplete}>
<div className="title-card-inner"> <div className="title-card-inner">
<small>Greyhaven file 87-10</small> <small>Greyhaven file 87-10</small>
@@ -46,7 +49,59 @@ const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) =
</div> </div>
</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) export const CUTSCENE_COMPONENT_KEYS = Object.keys(CUTSCENE_REGISTRY)
// Merit ceremony components, keyed by a merit node's component_key (e.g. a 3D // 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> </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 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}> return <div className="cutscene-card title-card" onClick={onComplete}>
<div className="title-card-inner"> <div className="title-card-inner">
<h1>{label}</h1> <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 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) 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 === '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 }} if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }}
onExit={terminalKey => { void advance(terminalKey) }} onExit={terminalKey => { void advance(terminalKey) }}
+56 -6
View File
@@ -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; } .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; } .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; } .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: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::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.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 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 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 { 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; } .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; } .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; } .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 { 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; } .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-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 { 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; } .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 { color: #f6ead9; background: #a2551f; }
.menubar nav button.report-back:hover { background: #b8622a; } .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 */ /* Utterance cards: expand to full content, ports anchored to top, colour by speaker */
.ucard { height: auto; min-height: 58px; } .ucard { height: auto; min-height: 58px; }
+1 -1
View File
@@ -9,5 +9,5 @@
"strict": true, "strict": true,
"noEmit": 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"]
} }