Add case adjudication cutscene
This commit is contained in:
+12
-3
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-2
@@ -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)
|
||||||
@@ -527,7 +528,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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -811,6 +830,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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+60
-5
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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) }}
|
||||||
|
|||||||
+51
-1
@@ -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
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user