Author SHA1 Message Date
gitprov 94ccbfd1b9 Adding migrations and lot of work. Starting work on the demo scope 2026-08-22 14:53:23 +02:00
29 changed files with 1280 additions and 122 deletions
+3
View File
@@ -8,6 +8,9 @@ RUN npm run build
FROM node:22-bookworm-slim FROM node:22-bookworm-slim
ENV NODE_ENV=production PORT=8787 ENV NODE_ENV=production PORT=8787
WORKDIR /app WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends tesseract-ocr tesseract-ocr-eng tesseract-ocr-nor \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
+16 -3
View File
@@ -5,6 +5,7 @@ A standalone, server-backed proof of concept for the Glitch University investiga
The accepted normalized domain model and terminology are specified in [`docs/exhibit-data-model.md`](docs/exhibit-data-model.md). PostgreSQL stores **exhibits**; frontend **widgets** visualize exhibit types. The accepted normalized domain model and terminology are specified in [`docs/exhibit-data-model.md`](docs/exhibit-data-model.md). PostgreSQL stores **exhibits**; frontend **widgets** visualize exhibit types.
Planned schema and exhibit work is tracked in [`docs/TODO.md`](docs/TODO.md). Planned schema and exhibit work is tracked in [`docs/TODO.md`](docs/TODO.md).
The intentionally narrow current demo is defined in [`docs/demo-scope.md`](docs/demo-scope.md): it opens directly on the board and focuses on investigation, flag-gated evidence reveals, and pasted screenshots.
## Run locally ## Run locally
@@ -74,7 +75,15 @@ The API surface is:
- `PUT /api/levels/:id` - `PUT /api/levels/:id`
- `POST /api/levels/:id/reset` - `POST /api/levels/:id/reset`
- `POST /api/levels/:id/templates` (save a new immutable version; editor only) - `POST /api/levels/:id/templates` (save a new immutable version; editor only)
- `POST /api/levels/:id/documents` (editor only) - `POST /api/levels/:id/documents` (player-uploaded evidence)
- `POST /api/levels/:id/reveals/seen`
- `GET /api/levels/:id/flags` (admin only)
- `PUT /api/levels/:id/flags/:key` (admin only)
- `DELETE /api/levels/:id/flags/:key` (admin only)
- `GET /api/levels/:id/evidence-match-rules` (admin only)
- `POST /api/levels/:id/evidence-match-rules` (editor only)
- `PUT /api/levels/:id/evidence-match-rules/:ruleId` (editor only)
- `DELETE /api/levels/:id/evidence-match-rules/:ruleId` (editor only)
- `GET /api/assets/:id` - `GET /api/assets/:id`
- `GET /api/session` (verified session and admin capability summary) - `GET /api/session` (verified session and admin capability summary)
- `GET /api/health` - `GET /api/health`
@@ -87,7 +96,11 @@ Set `LEVEL_EDITING_ENABLED=true` and open `/?edit=1` while signed in with a JWT
For the standalone development Compose stack, visit `/api/dev/admin-session?returnTo=/?edit=1` once to receive a local signed admin cookie. This helper does not exist in production. For the standalone development Compose stack, visit `/api/dev/admin-session?returnTo=/?edit=1` once to receive a local signed admin cookie. This helper does not exist in production.
In edit mode, files can be dragged from the desktop onto the board or selected with **Import Document**. Images, PDFs, and text files render inside document windows; unknown formats remain downloadable source files. Extracted evidence becomes an editable folder widget. Its editor controls the title, annotation, contained documents, and each source document's publication time. The default upload limit is 25 MB and can be changed with `MAX_DOCUMENT_BYTES`. Files can be dragged from the desktop onto the board or selected with **Add Document**. Pasting a clipboard image creates a persisted image Document, which supports ordinary macOS and Windows screenshot workflows. Images, PDFs, and text files render inside document windows; unknown formats remain downloadable source files. Extracted evidence becomes an editable folder widget. Its editor controls the title, annotation, contained documents, and each source document's publication time. The default upload limit is 25 MB and can be changed with `MAX_DOCUMENT_BYTES`.
Image uploads are OCRed by the Tesseract executable bundled into the application image; text-file uploads use their text directly. Extracted text is stored against the immutable asset, copied into the Document's searchable body, and evaluated against level-authored fuzzy passage rules. A successful rule awards its configured flag and immediately participates in ordinary document reveals. Rules, anchors, per-anchor scores, and evaluation provenance are normalized PostgreSQL data—no case text is compiled into the engine. OCR is time-limited and failure-tolerant: the source remains on the board even when text extraction fails. `OCR_LANGUAGES`, `OCR_TIMEOUT_MS`, `MAX_OCR_BYTES`, and `MAX_EXTRACTED_TEXT_CHARACTERS` tune the worker; `OCR_ENABLED=false` disables image OCR without disabling uploads. Authors configure passages under **Admin → Evidence Matching** while editing a level.
In author mode, a Document may be assigned comma-separated reveal flags in its metadata editor. Play-mode level responses omit gated Documents until all requirements are earned. Admins can exercise the demo through **Admin → Level Flags**; newly delivered evidence receives a one-time arrival animation.
Production defaults editing to disabled. Set `LEVEL_EDITING_ENABLED=true` in `/opt/gu_common/.env.prod` only when the authoring surface should be available. `JWT_SECRET` is inherited from that shared environment, and authoring endpoints additionally require a verified admin claim. Production defaults editing to disabled. Set `LEVEL_EDITING_ENABLED=true` in `/opt/gu_common/.env.prod` only when the authoring surface should be available. `JWT_SECRET` is inherited from that shared environment, and authoring endpoints additionally require a verified admin claim.
@@ -126,4 +139,4 @@ The deploy script builds and syncs the application, reads production database cr
## Deliberate POC boundaries ## Deliberate POC boundaries
Authentication is supplied by the shared Glitch University account system. There is no OSINT-specific account model, real-world web browsing, OCR, or collaboration yet. The server data model and provenance fields leave room for those later without making them part of the first playability test. Authentication is supplied by the shared Glitch University account system. There is no OSINT-specific account model, real-world web browsing, or collaboration yet. OCR deliberately recognizes only evidence the player brings onto the board; it does not fetch or search the web.
+5
View File
@@ -30,6 +30,11 @@ services:
JWT_SECRET: ${JWT_SECRET:-osint-local-dev-secret} JWT_SECRET: ${JWT_SECRET:-osint-local-dev-secret}
LEVEL_EDITING_ENABLED: "true" LEVEL_EDITING_ENABLED: "true"
MAX_DOCUMENT_BYTES: 26214400 MAX_DOCUMENT_BYTES: 26214400
OCR_ENABLED: "true"
OCR_LANGUAGES: nor+eng
OCR_TIMEOUT_MS: 20000
MAX_OCR_BYTES: 15728640
MAX_EXTRACTED_TEXT_CHARACTERS: 200000
S3_ENDPOINT: http://minio:9000 S3_ENDPOINT: http://minio:9000
S3_REGION: us-east-1 S3_REGION: us-east-1
S3_ACCESS_KEY: gupi S3_ACCESS_KEY: gupi
+5
View File
@@ -13,6 +13,11 @@ services:
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
LEVEL_EDITING_ENABLED: ${LEVEL_EDITING_ENABLED:-false} LEVEL_EDITING_ENABLED: ${LEVEL_EDITING_ENABLED:-false}
MAX_DOCUMENT_BYTES: ${MAX_DOCUMENT_BYTES:-26214400} MAX_DOCUMENT_BYTES: ${MAX_DOCUMENT_BYTES:-26214400}
OCR_ENABLED: ${OCR_ENABLED:-true}
OCR_LANGUAGES: ${OCR_LANGUAGES:-nor+eng}
OCR_TIMEOUT_MS: ${OCR_TIMEOUT_MS:-20000}
MAX_OCR_BYTES: ${MAX_OCR_BYTES:-15728640}
MAX_EXTRACTED_TEXT_CHARACTERS: ${MAX_EXTRACTED_TEXT_CHARACTERS:-200000}
expose: expose:
- "8787" - "8787"
networks: networks:
+45
View File
@@ -0,0 +1,45 @@
# Board demo scope
The active demo starts directly on a mutable OSINT board. Campaign navigation,
cutscenes, dialogue, NPC presentation, and story-graph progression are not part of
this slice. Their existing code and migrations remain isolated for possible later
work, but the board does not invoke them.
## Included gameplay
- A level loads its currently available normalized exhibits, board arrangement,
folders, threads, parties, events, and timeline.
- A document may require one or more boolean level flags. Play-mode responses omit
an unavailable document and every relation or thread touching it. Author mode
exposes the requirements in the document metadata editor.
- The first time an available document is delivered to a level, it receives a
short **NEW EVIDENCE** arrival animation. The acknowledgement is persisted so a
normal reload does not replay it.
- Administrators can inspect, award, and revoke demo flags from **Admin → Level
Flags**. This is a test/authoring control; future gameplay systems may award the
same flags without changing the document-gating model.
- Any player may add evidence by choosing a file, dragging a file onto the board,
or pasting an image from the clipboard. A pasted macOS or Windows screenshot is
uploaded to object storage and created as a normalized image Document near the
center of the current viewport.
- Uploaded images are OCRed and uploaded text files are read directly. Extracted
text becomes searchable Document content and is compared with board-owned fuzzy
passage rules. Matching enough distinctive anchors awards the rule's flag; an
OCR error never rejects or removes the player's source.
## Gate semantics
Flags are lowercase keys such as `lead.auction_catalogue`. A document with no
requirements is visible at level load. A document with several requirements is
visible only after **all** of them have been earned. Flags belong to the mutable
level; requirements belong to the board and therefore clone with template
versions. The server is the visibility authority.
Evidence recognition rules also belong to the board and clone with template
versions. Each rule names a flag, contains one or more reference passages, gives
each passage a similarity threshold, and states how many passages must match.
Evaluation rows and their per-anchor scores belong to the mutable level, providing
an audit trail for automatic victories and reveals.
The bundled Glass Harbor manifest gates the auction catalogue behind
`lead.auction_catalogue`, providing one small reveal for the demo.
+5 -1
View File
@@ -1,6 +1,10 @@
# Persistent boards, gated exhibits, and citation codes # Persistent boards, gated exhibits, and citation codes
Status: **proposed** (design locked, not implemented). Extends the story flow graph Status: **demo subset implemented**. Migration 025 implements level-local boolean
flags, normalized Document requirements, server-side filtering, persisted reveal
acknowledgements, and the arrival animation. Board-key reuse, narrative-triggered
live reveals, citation groups, and per-user playthrough overlays remain deferred.
The broader design extends the story flow graph
([story-graph.md](story-graph.md)) and the narrative layer ([story-graph.md](story-graph.md)) and the narrative layer
([narrative-todo.md](narrative-todo.md)); interlocks with the Claim/Case Report ([narrative-todo.md](narrative-todo.md)); interlocks with the Claim/Case Report
work in [TODO.md](TODO.md) Milestone 5. Depends on the **flags / case-state** work in [TODO.md](TODO.md) Milestone 5. Depends on the **flags / case-state**
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+39
View File
@@ -0,0 +1,39 @@
-- Level-local achievements and normalized document reveal requirements.
-- Requirements live on boards so they clone with immutable template versions;
-- earned/seen state lives on the mutable level instance.
ALTER TABLE osint.levels
ADD CONSTRAINT levels_id_board_unique UNIQUE (id,board_id);
CREATE TABLE osint.level_flags (
level_id UUID NOT NULL,
board_id UUID NOT NULL,
flag_key TEXT NOT NULL CHECK (flag_key ~ '^[a-z][a-z0-9_.-]{0,63}$'),
earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (level_id,flag_key),
FOREIGN KEY (level_id,board_id) REFERENCES osint.levels(id,board_id) ON DELETE CASCADE
);
CREATE INDEX level_flags_board_idx ON osint.level_flags (board_id,flag_key);
CREATE TABLE osint.document_flag_requirements (
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
flag_key TEXT NOT NULL CHECK (flag_key ~ '^[a-z][a-z0-9_.-]{0,63}$'),
PRIMARY KEY (document_exhibit_id,flag_key),
FOREIGN KEY (board_id,document_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE
);
CREATE INDEX document_flag_requirements_board_idx ON osint.document_flag_requirements (board_id,flag_key);
CREATE TABLE osint.level_seen_documents (
level_id UUID NOT NULL,
board_id UUID NOT NULL,
document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (level_id,document_exhibit_id),
FOREIGN KEY (level_id,board_id) REFERENCES osint.levels(id,board_id) ON DELETE CASCADE,
FOREIGN KEY (board_id,document_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE
);
COMMENT ON TABLE osint.level_flags IS 'Boolean achievements earned within one mutable level instance.';
COMMENT ON TABLE osint.document_flag_requirements IS 'All listed flags must be earned before a document is included in the play-mode level payload.';
COMMENT ON TABLE osint.level_seen_documents IS 'Documents whose first-arrival animation has already been acknowledged for this level.';
+16
View File
@@ -0,0 +1,16 @@
-- Achievements: the playthrough case-state. Boolean facts earned by a player, each
-- remembering the story node that awarded it. This is the NARRATIVE scope the phone
-- gate, node-enable requirements, and gates read; level-local flags (025) promote up
-- into it when a level node's achievement fires. A fact is true once (PK on player +
-- key); the awarding node is provenance and is nullable (New Game / manual grants).
CREATE TABLE osint.achievements (
playthrough_id UUID NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
flag_key TEXT NOT NULL CHECK (flag_key ~ '^[a-z][a-z0-9_.-]{0,63}$'),
awarded_by_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL,
earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (playthrough_id, flag_key)
);
CREATE INDEX achievements_playthrough_idx ON osint.achievements (playthrough_id);
COMMENT ON TABLE osint.achievements IS 'Boolean achievements earned by a player (playthrough), each remembering the awarding story node. The narrative case-state that gates the phone, node-enable requirements, and story gates.';
+79
View File
@@ -0,0 +1,79 @@
-- Data-defined evidence recognition for player-uploaded sources. Text extraction
-- belongs to the immutable asset; matching rules belong to a clonable board;
-- evaluations and awarded flags belong to the mutable level instance.
CREATE TABLE osint.asset_text_extractions (
id UUID PRIMARY KEY,
asset_id UUID NOT NULL REFERENCES osint.assets(id) ON DELETE CASCADE,
extractor TEXT NOT NULL,
extractor_version TEXT NOT NULL,
language TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('succeeded','unsupported','failed')),
extracted_text TEXT NOT NULL DEFAULT '',
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (asset_id,extractor,extractor_version,language)
);
CREATE INDEX asset_text_extractions_asset_idx ON osint.asset_text_extractions (asset_id,created_at DESC);
CREATE TABLE osint.evidence_match_rules (
id UUID PRIMARY KEY,
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
origin_rule_id UUID REFERENCES osint.evidence_match_rules(id) ON DELETE SET NULL,
name TEXT NOT NULL,
flag_key TEXT NOT NULL CHECK (flag_key ~ '^[a-z][a-z0-9_.-]{0,63}$'),
matcher_version TEXT NOT NULL DEFAULT 'char_trigram_v1' CHECK (matcher_version = 'char_trigram_v1'),
minimum_anchor_matches SMALLINT NOT NULL DEFAULT 1 CHECK (minimum_anchor_matches > 0),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (board_id,id),
UNIQUE (board_id,name)
);
CREATE INDEX evidence_match_rules_board_flag_idx ON osint.evidence_match_rules (board_id,flag_key) WHERE enabled;
CREATE TABLE osint.evidence_match_anchors (
id UUID PRIMARY KEY,
rule_id UUID NOT NULL REFERENCES osint.evidence_match_rules(id) ON DELETE CASCADE,
phrase_text TEXT NOT NULL CHECK (char_length(btrim(phrase_text)) >= 12),
minimum_similarity NUMERIC(4,3) NOT NULL DEFAULT 0.720 CHECK (minimum_similarity >= 0.500 AND minimum_similarity <= 1),
sort_order SMALLINT NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
UNIQUE (rule_id,sort_order)
);
CREATE TABLE osint.evidence_match_evaluations (
id UUID PRIMARY KEY,
level_id UUID NOT NULL,
board_id UUID NOT NULL,
document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
extraction_id UUID NOT NULL REFERENCES osint.asset_text_extractions(id) ON DELETE CASCADE,
rule_id UUID NOT NULL,
matched BOOLEAN NOT NULL,
matched_anchor_count SMALLINT NOT NULL CHECK (matched_anchor_count >= 0),
score NUMERIC(4,3) NOT NULL CHECK (score >= 0 AND score <= 1),
evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
FOREIGN KEY (level_id,board_id) REFERENCES osint.levels(id,board_id) ON DELETE CASCADE,
FOREIGN KEY (board_id,document_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE,
FOREIGN KEY (board_id,rule_id) REFERENCES osint.evidence_match_rules(board_id,id) ON DELETE CASCADE,
UNIQUE (level_id,document_exhibit_id,rule_id)
);
CREATE INDEX evidence_match_evaluations_level_idx ON osint.evidence_match_evaluations (level_id,evaluated_at DESC);
CREATE TABLE osint.evidence_match_anchor_evaluations (
evaluation_id UUID NOT NULL REFERENCES osint.evidence_match_evaluations(id) ON DELETE CASCADE,
anchor_id UUID NOT NULL REFERENCES osint.evidence_match_anchors(id) ON DELETE CASCADE,
similarity NUMERIC(4,3) NOT NULL CHECK (similarity >= 0 AND similarity <= 1),
matched BOOLEAN NOT NULL,
matched_text TEXT NOT NULL DEFAULT '',
PRIMARY KEY (evaluation_id,anchor_id)
);
ALTER TABLE osint.level_flags
ADD COLUMN awarded_by_evidence_match_id UUID REFERENCES osint.evidence_match_evaluations(id) ON DELETE SET NULL;
COMMENT ON TABLE osint.asset_text_extractions IS 'Cached OCR or direct-text extraction for one immutable uploaded asset.';
COMMENT ON TABLE osint.evidence_match_rules IS 'Clonable level-authored rule that awards a flag when enough distinctive text anchors match an uploaded source.';
COMMENT ON TABLE osint.evidence_match_anchors IS 'Alternative or cumulative reference passages used by one evidence match rule.';
COMMENT ON TABLE osint.evidence_match_evaluations IS 'Auditable result of applying one board rule to one player-uploaded document.';
COMMENT ON TABLE osint.evidence_match_anchor_evaluations IS 'Per-anchor fuzzy score and best normalized text window for an evidence evaluation.';
+1
View File
@@ -112,6 +112,7 @@
"key": "auction-catalogue", "key": "auction-catalogue",
"title": "Meridian Maritime Auction · Lot 117", "title": "Meridian Maritime Auction · Lot 117",
"fileType": "article", "fileType": "article",
"requiredFlags": ["lead.auction_catalogue"],
"publishedAt": "1987-10-24T12:00:00.000Z", "publishedAt": "1987-10-24T12:00:00.000Z",
"body": [ "body": [
"MERIDIAN MARITIME AUCTION — ADVANCE CATALOGUE · 24 OCTOBER 1987", "MERIDIAN MARITIME AUCTION — ADVANCE CATALOGUE · 24 OCTOBER 1987",
+2 -1
View File
@@ -12,6 +12,7 @@ type MysteryDocument = {
body?: string[] body?: string[]
metadata?: Record<string, string> metadata?: Record<string, string>
asset?: string asset?: string
requiredFlags?: string[]
} }
type MysteryGraph = { type MysteryGraph = {
entry: string entry: string
@@ -81,7 +82,7 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
width: uploaded?.width || 174, height: uploaded?.height || 145, rotation: 0, zIndex: uploaded?.zIndex || 1, hidden: false, width: uploaded?.width || 174, height: uploaded?.height || 145, rotation: 0, zIndex: uploaded?.zIndex || 1, hidden: false,
body: source.body || [], regions: [], assetId: uploaded?.assetId, body: source.body || [], regions: [], assetId: uploaded?.assetId,
fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize, fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize,
fileType: source.fileType, metadata: source.metadata || {}, fileType: source.fileType, metadata: source.metadata || {}, requiredFlags: source.requiredFlags || [],
}) })
} }
+51 -6
View File
@@ -84,11 +84,12 @@ suite('normalized level persistence API', () => {
state.viewport = { x: 91, y: -42, zoom: 0.85 } state.viewport = { x: 91, y: -42, zoom: 0.85 }
const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', metadata: {}, ...placed(1051, 417, 174, 145, 2) } const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', metadata: {}, ...placed(1051, 417, 174, 145, 2) }
const gatedDocument: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Later tip', body: [], regions: [], fileType: 'image', metadata: {}, requiredFlags: ['tip.received'], ...placed(1260, 417, 174, 145, 3) }
const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) } const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) }
const note: NoteExhibit = { id: randomUUID(), type: 'note', title: 'Extract', content: 'Date matters', ...placed(420, 300, 108, 154) } const note: NoteExhibit = { id: randomUUID(), type: 'note', title: 'Extract', content: 'Date matters', ...placed(420, 300, 108, 154) }
const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) } const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) }
const party: PartyExhibit = { id: randomUUID(), type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as correspondent.', aliases: ['A. A. L.'], ...placed(720, 250, 280, 190) } const party: PartyExhibit = { id: randomUUID(), type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as correspondent.', aliases: ['A. A. L.'], ...placed(720, 250, 280, 190) }
state.exhibits = [document, folder, note, event, party] state.exhibits = [document, gatedDocument, folder, note, event, party]
state.relations = [ state.relations = [
{ id: randomUUID(), fromExhibitId: folder.id, toExhibitId: document.id, type: 'contains', sortOrder: 0 }, { id: randomUUID(), fromExhibitId: folder.id, toExhibitId: document.id, type: 'contains', sortOrder: 0 },
{ id: randomUUID(), fromExhibitId: note.id, toExhibitId: document.id, type: 'source', sourceRegionId: 'stamp', sortOrder: 0 }, { id: randomUUID(), fromExhibitId: note.id, toExhibitId: document.id, type: 'source', sourceRegionId: 'stamp', sortOrder: 0 },
@@ -106,16 +107,55 @@ suite('normalized level persistence API', () => {
expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true }) expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true })
expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })])) expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })]))
expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' }) expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' })
expect(loaded.exhibits.find(item => item.id === gatedDocument.id)).toMatchObject({ requiredFlags: ['tip.received'] })
const beforeFlag = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
expect(beforeFlag.exhibits.map(item => item.id)).not.toContain(gatedDocument.id)
expect(beforeFlag.newlyVisibleDocumentIds).toContain(document.id)
expect(await (await adminFetch(`${baseUrl}/api/levels/${state.id}/flags`)).json()).toEqual([
{ key: 'tip.received', gatedDocumentCount: 1 },
])
expect((await adminFetch(`${baseUrl}/api/levels/${state.id}/flags/tip.received`, { method: 'PUT' })).status).toBe(200)
const afterFlag = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
expect(afterFlag.exhibits.map(item => item.id)).toContain(gatedDocument.id)
expect(afterFlag.newlyVisibleDocumentIds).toContain(gatedDocument.id)
expect((await fetch(`${baseUrl}/api/levels/${state.id}/reveals/seen`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: afterFlag.newlyVisibleDocumentIds }) })).status).toBe(200)
expect((await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState).newlyVisibleDocumentIds).toEqual([])
expect((await adminFetch(`${baseUrl}/api/levels/${state.id}/flags/tip.received`, { method: 'DELETE' })).status).toBe(200)
const matchRuleResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/evidence-match-rules`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({
name: 'Smoke source passage', flagKey: 'tip.received', minimumAnchorMatches: 1,
anchors: [{ phrase: 'OSINT smoke evidence from the archive', minimumSimilarity: 0.72 }],
}),
})
expect(matchRuleResponse.status).toBe(201)
expect(await matchRuleResponse.json()).toMatchObject({ name: 'Smoke source passage', flagKey: 'tip.received', anchors: [{ phrase: 'OSINT smoke evidence from the archive' }] })
expect(await (await adminFetch(`${baseUrl}/api/levels/${state.id}/evidence-match-rules`)).json()).toHaveLength(1)
const upload = new FormData() const upload = new FormData()
upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt') upload.append('file', new Blob(['OSINT smoke evidence from the archlve'], { type: 'text/plain' }), 'smoke-evidence.txt')
const uploadResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/documents?edit=1`, { method: 'POST', body: upload }) upload.append('x', '812')
upload.append('y', '438')
const uploadResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents`, { method: 'POST', body: upload })
expect(uploadResponse.status).toBe(201) expect(uploadResponse.status).toBe(201)
const uploaded = await uploadResponse.json() as DocumentExhibit const uploaded = await uploadResponse.json() as DocumentExhibit & { analysis: { extractionStatus: string; matchedFlags: string[]; awardedFlags: string[] } }
expect(uploaded).toMatchObject({ type: 'document', fileName: 'smoke-evidence.txt', fileType: 'text' }) expect(uploaded).toMatchObject({ type: 'document', fileName: 'smoke-evidence.txt', fileType: 'text', x: 812, y: 438,
expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence') body: ['OSINT smoke evidence from the archlve'], analysis: { extractionStatus: 'succeeded', matchedFlags: ['tip.received'], awardedFlags: ['tip.received'] } })
expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence from the archlve')
const assetRow = await appPool.query<{ storage_provider: string; content: Buffer | null; object_key: string | null }>('SELECT storage_provider,content,object_key FROM osint.assets WHERE id=$1', [uploaded.assetId]) const assetRow = await appPool.query<{ storage_provider: string; content: Buffer | null; object_key: string | null }>('SELECT storage_provider,content,object_key FROM osint.assets WHERE id=$1', [uploaded.assetId])
expect(assetRow.rows[0]).toMatchObject({ storage_provider: 's3', content: null, object_key: expect.stringMatching(/^assets\//) }) expect(assetRow.rows[0]).toMatchObject({ storage_provider: 's3', content: null, object_key: expect.stringMatching(/^assets\//) })
const automaticallyRevealed = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
expect(automaticallyRevealed.exhibits.map(item => item.id)).toContain(gatedDocument.id)
const evaluationRows = await appPool.query<{ matched: boolean; matched_anchor_count: number }>(
'SELECT matched,matched_anchor_count FROM osint.evidence_match_evaluations WHERE document_exhibit_id=$1', [uploaded.id])
expect(evaluationRows.rows).toEqual([{ matched: true, matched_anchor_count: 1 }])
const screenshot = new FormData()
screenshot.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), 'Screenshot 2026-08-22.png')
const screenshotResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents`, { method: 'POST', body: screenshot })
expect(screenshotResponse.status).toBe(201)
expect(await screenshotResponse.json()).toMatchObject({ type: 'document', fileType: 'image', fileName: 'Screenshot 2026-08-22.png' })
const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }) }) const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }) })
expect(templateResponse.status).toBe(201) expect(templateResponse.status).toBe(201)
@@ -128,5 +168,10 @@ suite('normalized level persistence API', () => {
expect(clone.relations.filter(relation => relation.type === 'supports')).toHaveLength(2) expect(clone.relations.filter(relation => relation.type === 'supports')).toHaveLength(2)
expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 }) expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 })
expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id) expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id)
const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
expect(authoredClone.exhibits.find(item => item.type === 'document' && item.title === 'Later tip')).toMatchObject({ requiredFlags: ['tip.received'] })
expect(await (await adminFetch(`${baseUrl}/api/levels/${clone.id}/evidence-match-rules`)).json()).toEqual([
expect.objectContaining({ name: 'Smoke source passage', flagKey: 'tip.received', anchors: [expect.objectContaining({ phrase: 'OSINT smoke evidence from the archive' })] }),
])
}) })
}) })
+24
View File
@@ -13,6 +13,7 @@ export async function clearBoard(client: PoolClient, boardId: string) {
await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.evidence_match_rules WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [boardId])
await client.query('UPDATE osint.boards SET revision=0,updated_at=NOW() WHERE id=$1', [boardId]) await client.query('UPDATE osint.boards SET revision=0,updated_at=NOW() WHERE id=$1', [boardId])
@@ -68,6 +69,29 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
(exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri) VALUES ($1,$2,$3,$4,$5,$6,$7)`, (exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[mapped(exhibitIds, row.exhibit_id, 'document'), row.document_type_id, row.asset_id, row.title, row.published_at, row.captured_at, row.source_uri]) [mapped(exhibitIds, row.exhibit_id, 'document'), row.document_type_id, row.asset_id, row.title, row.published_at, row.captured_at, row.source_uri])
const documentRequirements = await client.query<{ document_exhibit_id: string; flag_key: string }>(
'SELECT document_exhibit_id,flag_key FROM osint.document_flag_requirements WHERE board_id=$1 ORDER BY document_exhibit_id,flag_key', [sourceBoardId])
for (const row of documentRequirements.rows) await client.query(
'INSERT INTO osint.document_flag_requirements (board_id,document_exhibit_id,flag_key) VALUES ($1,$2,$3)',
[targetBoardId, mapped(exhibitIds, row.document_exhibit_id, 'document reveal requirement'), row.flag_key])
const ruleIds: IdMap = new Map()
const matchRules = await client.query<{
id: string; name: string; flag_key: string; matcher_version: string; minimum_anchor_matches: number; enabled: boolean
}>('SELECT id,name,flag_key,matcher_version,minimum_anchor_matches,enabled FROM osint.evidence_match_rules WHERE board_id=$1 ORDER BY created_at,id', [sourceBoardId])
for (const row of matchRules.rows) {
const id = randomUUID(); ruleIds.set(row.id, id)
await client.query(`INSERT INTO osint.evidence_match_rules
(id,board_id,origin_rule_id,name,flag_key,matcher_version,minimum_anchor_matches,enabled)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, [id,targetBoardId,row.id,row.name,row.flag_key,row.matcher_version,row.minimum_anchor_matches,row.enabled])
}
const matchAnchors = await client.query<{ rule_id: string; phrase_text: string; minimum_similarity: string; sort_order: number }>(
`SELECT a.rule_id,a.phrase_text,a.minimum_similarity::text,a.sort_order FROM osint.evidence_match_anchors a
JOIN osint.evidence_match_rules r ON r.id=a.rule_id WHERE r.board_id=$1 ORDER BY a.rule_id,a.sort_order,a.id`, [sourceBoardId])
for (const row of matchAnchors.rows) await client.query(`INSERT INTO osint.evidence_match_anchors
(id,rule_id,phrase_text,minimum_similarity,sort_order) VALUES ($1,$2,$3,$4,$5)`,
[randomUUID(), mapped(ruleIds, row.rule_id, 'evidence match rule'), row.phrase_text, row.minimum_similarity, row.sort_order])
const images = await client.query<{ exhibit_id: string; pixel_width: number | null; pixel_height: number | null; alt_text: string }>( const images = await client.query<{ exhibit_id: string; pixel_width: number | null; pixel_height: number | null; alt_text: string }>(
`SELECT i.* FROM osint.image_documents i JOIN osint.exhibits e ON e.id=i.exhibit_id WHERE e.board_id=$1`, [sourceBoardId]) `SELECT i.* FROM osint.image_documents i JOIN osint.exhibits e ON e.id=i.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
for (const row of images.rows) await client.query( for (const row of images.rows) await client.query(
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { evaluateEvidenceRules, normalizeEvidenceText, scoreEvidenceAnchor } from './evidenceMatching.js'
const barricelliRule = {
id: 'rule-barricelli',
name: 'Contemporary Barricelli fire report',
flagKey: 'barricelli.child-rescue-source',
minimumAnchorMatches: 1,
anchors: [{
id: 'parents',
phrase: 'den italienske maler og opfinder Barricelli og frue, født Aall',
minimumSimilarity: 0.72,
}, {
id: 'drink',
phrase: 'Han vækkede nemlig sin mor for at faa noget at drikke',
minimumSimilarity: 0.72,
}],
}
describe('evidence text matching', () => {
it('normalizes historical Norwegian characters and page layout noise', () => {
expect(normalizeEvidenceText('Født Aall — 2½ aar\n gammel')).toBe('fodt aall 2 1 2 aar gammel')
})
it('tolerates plausible OCR substitutions in a distinctive passage', () => {
const result = scoreEvidenceAnchor(
normalizeEvidenceText('I kvistleiligheden boede den italienske maler og opfinder Barrioelli og frue, født Aall, med sin lille søn.'),
barricelliRule.anchors[0].phrase,
)
expect(result.similarity).toBeGreaterThan(0.9)
})
it('awards the data-defined flag when one configured anchor is present', () => {
const evaluations = evaluateEvidenceRules('Han vækkede nemlig sin mor for at faa noget at drikke, og da ser hun huset brænder.', [barricelliRule])
expect(evaluations[0]).toMatchObject({ matched: true, matchedAnchorCount: 1, flagKey: 'barricelli.child-rescue-source' })
})
it('does not match generic words from an unrelated fire report', () => {
const evaluations = evaluateEvidenceRules('A family escaped from a boarding-house fire during the night.', [barricelliRule])
expect(evaluations[0]).toMatchObject({ matched: false, matchedAnchorCount: 0 })
})
it('can require multiple anchors for a stricter level rule', () => {
const rule = { ...barricelliRule, minimumAnchorMatches: 2 }
expect(evaluateEvidenceRules(barricelliRule.anchors[0].phrase, [rule])[0].matched).toBe(false)
expect(evaluateEvidenceRules(barricelliRule.anchors.map(anchor => anchor.phrase).join(' '), [rule])[0].matched).toBe(true)
})
})
+115
View File
@@ -0,0 +1,115 @@
export type EvidenceMatchAnchor = {
id: string
phrase: string
minimumSimilarity: number
}
export type EvidenceMatchRule = {
id: string
name: string
flagKey: string
minimumAnchorMatches: number
anchors: EvidenceMatchAnchor[]
}
export type EvidenceAnchorEvaluation = {
anchorId: string
similarity: number
matched: boolean
matchedText: string
}
export type EvidenceRuleEvaluation = {
ruleId: string
flagKey: string
matched: boolean
matchedAnchorCount: number
score: number
anchors: EvidenceAnchorEvaluation[]
}
const MAX_MATCH_TEXT_CHARACTERS = 200_000
/** Normalize historical spelling characters, punctuation, line breaks, and accents without changing word order. */
export function normalizeEvidenceText(value: string) {
return value.slice(0, MAX_MATCH_TEXT_CHARACTERS)
.toLocaleLowerCase('en')
.replace(/æ/g, 'ae')
.replace(/ø/g, 'o')
.replace(/å/g, 'aa')
.replace(/½/g, ' 1 2 ')
.normalize('NFKD')
.replace(/\p{Mark}/gu, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ')
}
function grams(value: string, size = 3) {
const compact = value.replace(/\s+/g, ' ')
if (compact.length <= size) return [compact]
const result: string[] = []
for (let index = 0; index <= compact.length - size; index += 1) result.push(compact.slice(index, index + size))
return result
}
function diceSimilarity(left: string, right: string) {
if (left === right) return 1
if (!left || !right) return 0
const leftGrams = grams(left)
const rightGrams = grams(right)
const rightCounts = new Map<string, number>()
for (const gram of rightGrams) rightCounts.set(gram, (rightCounts.get(gram) || 0) + 1)
let overlap = 0
for (const gram of leftGrams) {
const count = rightCounts.get(gram) || 0
if (!count) continue
overlap += 1
rightCounts.set(gram, count - 1)
}
return (2 * overlap) / (leftGrams.length + rightGrams.length)
}
export function scoreEvidenceAnchor(normalizedDocument: string, phrase: string) {
const normalizedPhrase = normalizeEvidenceText(phrase)
if (!normalizedDocument || !normalizedPhrase) return { similarity: 0, matchedText: '' }
if (normalizedDocument.includes(normalizedPhrase)) return { similarity: 1, matchedText: normalizedPhrase }
const documentTokens = normalizedDocument.split(' ')
const phraseTokens = normalizedPhrase.split(' ')
const spread = Math.max(2, Math.min(8, Math.ceil(phraseTokens.length * 0.2)))
const minimumWindow = Math.max(1, phraseTokens.length - spread)
const maximumWindow = Math.min(documentTokens.length, phraseTokens.length + spread)
let best = { similarity: 0, matchedText: '' }
for (let windowSize = minimumWindow; windowSize <= maximumWindow; windowSize += 1) {
for (let start = 0; start + windowSize <= documentTokens.length; start += 1) {
const candidate = documentTokens.slice(start, start + windowSize).join(' ')
const similarity = diceSimilarity(normalizedPhrase, candidate)
if (similarity > best.similarity) best = { similarity, matchedText: candidate }
}
}
return best
}
export function evaluateEvidenceRules(text: string, rules: EvidenceMatchRule[]): EvidenceRuleEvaluation[] {
const normalizedDocument = normalizeEvidenceText(text)
return rules.map(rule => {
const anchors = rule.anchors.map(anchor => {
const result = scoreEvidenceAnchor(normalizedDocument, anchor.phrase)
const similarity = Math.max(0, Math.min(1, result.similarity))
return { anchorId: anchor.id, similarity, matched: similarity >= anchor.minimumSimilarity, matchedText: result.matchedText }
})
const matchedAnchors = anchors.filter(anchor => anchor.matched)
const requiredScores = [...anchors].sort((left, right) => right.similarity - left.similarity).slice(0, rule.minimumAnchorMatches)
const score = requiredScores.length ? requiredScores.reduce((sum, anchor) => sum + anchor.similarity, 0) / requiredScores.length : 0
return {
ruleId: rule.id,
flagKey: rule.flagKey,
matched: matchedAnchors.length >= rule.minimumAnchorMatches,
matchedAnchorCount: matchedAnchors.length,
score,
anchors,
}
})
}
+78 -4
View File
@@ -11,6 +11,7 @@ import type { CaseState } from '../src/types.js'
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolveUserId } from './auth.js' import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolveUserId } from './auth.js'
import { createLevelRepository } from './levelRepository.js' import { createLevelRepository } from './levelRepository.js'
import { createNarrativeRepository } from './narrativeRepository.js' import { createNarrativeRepository } from './narrativeRepository.js'
import { createTextExtractorFromEnv } from './ocr.js'
import { createStoryGraphRepository, type StoryNodeType } from './storyGraphRepository.js' import { createStoryGraphRepository, type StoryNodeType } from './storyGraphRepository.js'
import { createObjectStorageFromEnv } from './objectStorage.js' import { createObjectStorageFromEnv } from './objectStorage.js'
@@ -25,6 +26,7 @@ export const pool = new Pool({ connectionString: databaseUrl })
const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true' const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
const objectStorage = createObjectStorageFromEnv() const objectStorage = createObjectStorageFromEnv()
await objectStorage.initialize() await objectStorage.initialize()
const textExtractor = createTextExtractorFromEnv()
const levels = createLevelRepository(pool, editingEnabled, objectStorage) const levels = createLevelRepository(pool, editingEnabled, objectStorage)
const narrative = createNarrativeRepository(pool, objectStorage) const narrative = createNarrativeRepository(pool, objectStorage)
const storyGraph = createStoryGraphRepository(pool) const storyGraph = createStoryGraphRepository(pool)
@@ -49,7 +51,7 @@ const upload = multer({
}) })
app.get('/api/health', async (_req, res) => { app.get('/api/health', async (_req, res) => {
try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', objectStorage: objectStorage.provider, schema: 'osint', editingEnabled }) } try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', objectStorage: objectStorage.provider, textExtraction: textExtractor.provider, schema: 'osint', editingEnabled }) }
catch { res.status(503).json({ ok: false, database: 'unavailable' }) } catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
}) })
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req) })) app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req) }))
@@ -106,14 +108,41 @@ app.get('/api/assets/:id', async (req, res, next) => {
asset.stream.pipe(res) asset.stream.pipe(res)
} catch (error) { next(error) } } catch (error) { next(error) }
}) })
app.post('/api/levels/:id/documents', requireAdmin, upload.single('file'), async (req, res, next) => { app.post('/api/levels/:id/documents', upload.single('file'), async (req, res, next) => {
try { try {
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
if (!req.file) return res.status(400).json({ error: 'A file is required' }) if (!req.file) return res.status(400).json({ error: 'A file is required' })
const document = await levels.uploadDocument(String(req.params.id), req.file) const extraction = await textExtractor.extract(req.file)
const x = Number(req.body?.x); const y = Number(req.body?.y)
const placement = Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined
const document = await levels.uploadDocument(String(req.params.id), req.file, extraction, placement)
document ? res.status(201).json(document) : res.status(404).json({ error: 'Level not found' }) document ? res.status(201).json(document) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) } } catch (error) { next(error) }
}) })
app.post('/api/levels/:id/reveals/seen', async (req, res, next) => {
try {
const ids = Array.isArray(req.body?.documentIds) ? req.body.documentIds.map(String) : []
const acknowledged = await levels.acknowledgeRevealedDocuments(String(req.params.id), ids)
acknowledged === null ? res.status(404).json({ error: 'Level not found' }) : res.json({ acknowledged })
} catch (error) { next(error) }
})
app.get('/api/levels/:id/flags', requireAdmin, async (req, res, next) => {
try {
const flags = await levels.listFlags(String(req.params.id))
flags ? res.json(flags) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
app.put('/api/levels/:id/flags/:key', requireAdmin, async (req, res, next) => {
try {
const updated = await levels.setFlag(String(req.params.id), String(req.params.key), true)
updated ? res.json({ ok: true }) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
app.delete('/api/levels/:id/flags/:key', requireAdmin, async (req, res, next) => {
try {
const updated = await levels.setFlag(String(req.params.id), String(req.params.key), false)
updated ? res.json({ ok: true }) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
app.get('/api/levels/:id', async (req, res, next) => { app.get('/api/levels/:id', async (req, res, next) => {
try { try {
const level = await levels.getLevel(req.params.id, wantsEdit(req)) const level = await levels.getLevel(req.params.id, wantsEdit(req))
@@ -142,6 +171,34 @@ function requireEditing(res: express.Response) {
if (!editingEnabled) { res.status(403).json({ error: 'Level editing is disabled' }); return false } if (!editingEnabled) { res.status(403).json({ error: 'Level editing is disabled' }); return false }
return true return true
} }
app.get('/api/levels/:id/evidence-match-rules', requireAdmin, async (req, res, next) => {
try {
const rules = await levels.listEvidenceMatchRules(String(req.params.id))
rules ? res.json(rules) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
app.post('/api/levels/:id/evidence-match-rules', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const rule = await levels.createEvidenceMatchRule(String(req.params.id), req.body)
rule ? res.status(201).json(rule) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
app.put('/api/levels/:id/evidence-match-rules/:ruleId', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const rule = await levels.updateEvidenceMatchRule(String(req.params.id), String(req.params.ruleId), req.body)
rule ? res.json(rule) : res.status(404).json({ error: 'Level or evidence match rule not found' })
} catch (error) { next(error) }
})
app.delete('/api/levels/:id/evidence-match-rules/:ruleId', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const removed = await levels.deleteEvidenceMatchRule(String(req.params.id), String(req.params.ruleId))
if (removed === null) return res.status(404).json({ error: 'Level not found' })
removed ? res.json({ ok: true }) : res.status(404).json({ error: 'Evidence match rule not found' })
} catch (error) { next(error) }
})
app.get('/api/admin/mysteries', requireAdmin, async (_req, res, next) => { app.get('/api/admin/mysteries', requireAdmin, async (_req, res, next) => {
try { res.json(await narrative.listMysteries()) } catch (error) { next(error) } try { res.json(await narrative.listMysteries()) } catch (error) { next(error) }
}) })
@@ -350,6 +407,23 @@ app.post('/api/playthroughs/:id/advance', async (req, res, next) => {
} catch (error) { next(error) } } catch (error) { next(error) }
}) })
// The playthrough case-state (achievements). Read is open; granting is a dev-only
// stand-in until the server-side achievement rule engine drives awards from play.
app.get('/api/playthroughs/:id/achievements', async (req, res, next) => {
try {
const flags = await narrative.listAchievements(String(req.params.id))
flags ? res.json(flags) : res.status(404).json({ error: 'Playthrough not found' })
} catch (error) { next(error) }
})
app.post('/api/playthroughs/:id/achievements', async (req, res, next) => {
try {
if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Manual grants are disabled' })
if (!req.body?.flagKey) return res.status(400).json({ error: 'A flagKey is required' })
const result = await narrative.awardAchievement(String(req.params.id), String(req.body.flagKey), req.body.nodeId ? String(req.body.nodeId) : null)
result.ok ? res.json({ earned: result.earned }) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
} catch (error) { next(error) }
})
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (error instanceof multer.MulterError) { if (error instanceof multer.MulterError) {
return res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ error: error.code === 'LIMIT_FILE_SIZE' ? 'Document exceeds the upload limit' : error.message }) return res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ error: error.code === 'LIMIT_FILE_SIZE' ? 'Document exceeds the upload limit' : error.message })
+235 -12
View File
@@ -1,15 +1,25 @@
import { createHash, randomUUID } from 'node:crypto' import { createHash, randomUUID } from 'node:crypto'
import { Readable } from 'node:stream' import { Readable } from 'node:stream'
import type { Pool, PoolClient } from 'pg' import type { Pool, PoolClient } from 'pg'
import type { BoardView, BriefConcept, CaseDocument, CaseState, Evidence, Exhibit, ExhibitRelation, OrganizationKind, PartyKind, SourceFileType } from '../src/types.js' import type { BoardView, BriefConcept, CaseDocument, CaseState, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, LevelFlag, OrganizationKind, PartyKind, SourceFileType, UploadedCaseDocument } from '../src/types.js'
import { isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js' import { isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js'
import { clearBoard, cloneBoard } from './boardClone.js' import { clearBoard, cloneBoard } from './boardClone.js'
import { evaluateEvidenceRules, type EvidenceMatchRule } from './evidenceMatching.js'
import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVisibility.js'
import type { ObjectStorage } from './objectStorage.js' import type { ObjectStorage } from './objectStorage.js'
import type { TextExtractionResult } from './ocr.js'
export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number } export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number }
export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer | null; storage_provider: 'postgres' | 's3'; object_key: string | null } export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer | null; storage_provider: 'postgres' | 's3'; object_key: string | null }
export type AssetResponse = { originalName: string; mimeType: string; byteSize: number; stream: NodeJS.ReadableStream } export type AssetResponse = { originalName: string; mimeType: string; byteSize: number; stream: NodeJS.ReadableStream }
export type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string } export type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string }
export type EvidenceMatchRuleInput = {
name: string
flagKey: string
minimumAnchorMatches?: number
enabled?: boolean
anchors: { phrase: string; minimumSimilarity?: number }[]
}
export interface LevelRepository { export interface LevelRepository {
listLevels(): Promise<unknown[]> listLevels(): Promise<unknown[]>
@@ -21,7 +31,14 @@ export interface LevelRepository {
saveLevel(state: CaseState, authorMode: boolean): Promise<void> saveLevel(state: CaseState, authorMode: boolean): Promise<void>
resetLevel(levelId: string): Promise<CaseState | null> resetLevel(levelId: string): Promise<CaseState | null>
getAsset(assetId: string): Promise<AssetResponse | null> getAsset(assetId: string): Promise<AssetResponse | null>
uploadDocument(levelId: string, file: UploadedDocument): Promise<CaseDocument | null> uploadDocument(levelId: string, file: UploadedDocument, extraction: TextExtractionResult, placement?: { x: number; y: number }): Promise<UploadedCaseDocument | null>
listFlags(levelId: string): Promise<LevelFlag[] | null>
setFlag(levelId: string, key: string, earned: boolean): Promise<boolean>
acknowledgeRevealedDocuments(levelId: string, documentIds: string[]): Promise<number | null>
listEvidenceMatchRules(levelId: string): Promise<EvidenceMatchRuleDefinition[] | null>
createEvidenceMatchRule(levelId: string, input: EvidenceMatchRuleInput): Promise<EvidenceMatchRuleDefinition | null>
updateEvidenceMatchRule(levelId: string, ruleId: string, input: EvidenceMatchRuleInput): Promise<EvidenceMatchRuleDefinition | null>
deleteEvidenceMatchRule(levelId: string, ruleId: string): Promise<boolean | null>
} }
type LevelRow = { type LevelRow = {
@@ -39,6 +56,7 @@ type ExhibitRow = {
} }
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const flagPattern = /^[a-z][a-z0-9_.-]{0,63}$/
function requireUuid(value: string, label: string) { function requireUuid(value: string, label: string) {
if (!uuidPattern.test(value)) throw new Error(`${label} must be a UUID`) if (!uuidPattern.test(value)) throw new Error(`${label} must be a UUID`)
return value return value
@@ -48,6 +66,26 @@ function timestamp(value: string | undefined) {
const date = new Date(value) const date = new Date(value)
return Number.isFinite(date.getTime()) ? date.toISOString() : null return Number.isFinite(date.getTime()) ? date.toISOString() : null
} }
function requireFlagKey(value: string) {
if (!flagPattern.test(value)) throw new Error('Flag keys must start with a lowercase letter and contain only lowercase letters, numbers, dots, dashes, or underscores')
return value
}
function requireRuleInput(input: EvidenceMatchRuleInput) {
const name = String(input.name || '').trim()
if (!name || name.length > 160) throw new Error('Evidence match rule names must be between 1 and 160 characters')
const flagKey = requireFlagKey(String(input.flagKey || '').trim())
if (!Array.isArray(input.anchors) || !input.anchors.length || input.anchors.length > 20) throw new Error('Evidence match rules require between 1 and 20 anchors')
const anchors = input.anchors.map(anchor => {
const phrase = String(anchor.phrase || '').trim()
if (phrase.length < 12 || phrase.length > 1_000) throw new Error('Evidence match anchors must be between 12 and 1000 characters')
const minimumSimilarity = anchor.minimumSimilarity === undefined ? 0.72 : Number(anchor.minimumSimilarity)
if (!Number.isFinite(minimumSimilarity) || minimumSimilarity < 0.5 || minimumSimilarity > 1) throw new Error('Anchor similarity must be between 0.5 and 1')
return { phrase, minimumSimilarity }
})
const minimumAnchorMatches = input.minimumAnchorMatches === undefined ? 1 : Number(input.minimumAnchorMatches)
if (!Number.isInteger(minimumAnchorMatches) || minimumAnchorMatches < 1 || minimumAnchorMatches > anchors.length) throw new Error('Required anchor matches must be between 1 and the number of anchors')
return { name, flagKey, minimumAnchorMatches, enabled: input.enabled !== false, anchors }
}
function documentType(document: CaseDocument): SourceFileType { function documentType(document: CaseDocument): SourceFileType {
const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file'] const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
return allowed.includes(document.fileType) ? document.fileType : 'file' return allowed.includes(document.fileType) ? document.fileType : 'file'
@@ -67,11 +105,49 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
await client.query("INSERT INTO osint.timeline_views (view_id,range_mode) VALUES ($1,'auto')", [viewId]) await client.query("INSERT INTO osint.timeline_views (view_id,range_mode) VALUES ($1,'auto')", [viewId])
} }
async function evidenceMatchRules(client: Pool | PoolClient, boardId: string, includeDisabled = false): Promise<EvidenceMatchRuleDefinition[]> {
const result = await client.query<{
rule_id: string; name: string; flag_key: string; matcher_version: 'char_trigram_v1'; minimum_anchor_matches: number; enabled: boolean
anchor_id: string; phrase_text: string; minimum_similarity: string; sort_order: number
}>(`SELECT r.id AS rule_id,r.name,r.flag_key,r.matcher_version,r.minimum_anchor_matches,r.enabled,
a.id AS anchor_id,a.phrase_text,a.minimum_similarity::text,a.sort_order
FROM osint.evidence_match_rules r
JOIN osint.evidence_match_anchors a ON a.rule_id=r.id
WHERE r.board_id=$1 ${includeDisabled ? '' : 'AND r.enabled'}
ORDER BY r.created_at,r.id,a.sort_order,a.id`, [boardId])
const rules = new Map<string, EvidenceMatchRuleDefinition>()
for (const row of result.rows) {
const rule = rules.get(row.rule_id) || { id: row.rule_id, name: row.name, flagKey: row.flag_key,
matcherVersion: row.matcher_version, minimumAnchorMatches: row.minimum_anchor_matches, enabled: row.enabled, anchors: [] }
rule.anchors.push({ id: row.anchor_id, phrase: row.phrase_text, minimumSimilarity: Number(row.minimum_similarity), sortOrder: row.sort_order })
rules.set(row.rule_id, rule)
}
return [...rules.values()]
}
async function writeEvidenceMatchRule(client: PoolClient, level: LevelRow, ruleId: string, rawInput: EvidenceMatchRuleInput, update: boolean) {
const input = requireRuleInput(rawInput)
if (update) {
const changed = await client.query(`UPDATE osint.evidence_match_rules SET name=$3,flag_key=$4,minimum_anchor_matches=$5,enabled=$6,updated_at=NOW()
WHERE id=$1 AND board_id=$2`, [ruleId, level.board_id, input.name, input.flagKey, input.minimumAnchorMatches, input.enabled])
if (!changed.rowCount) return null
await client.query('DELETE FROM osint.evidence_match_anchors WHERE rule_id=$1', [ruleId])
} else {
await client.query(`INSERT INTO osint.evidence_match_rules (id,board_id,name,flag_key,minimum_anchor_matches,enabled)
VALUES ($1,$2,$3,$4,$5,$6)`, [ruleId, level.board_id, input.name, input.flagKey, input.minimumAnchorMatches, input.enabled])
}
for (const [sortOrder, anchor] of input.anchors.entries()) await client.query(`INSERT INTO osint.evidence_match_anchors
(id,rule_id,phrase_text,minimum_similarity,sort_order) VALUES ($1,$2,$3,$4,$5)`,
[randomUUID(), ruleId, anchor.phrase, anchor.minimumSimilarity, sortOrder])
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
return (await evidenceMatchRules(client, level.board_id, true)).find(rule => rule.id === ruleId) || null
}
async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> { async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> {
const level = await findLevel(pool, slug) const level = await findLevel(pool, slug)
if (!level) return null if (!level) return null
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult, const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult] = await Promise.all([ aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult, requirementsResult, flagsResult, seenResult] = await Promise.all([
pool.query<ExhibitRow>(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden, pool.query<ExhibitRow>(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden,
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, '') AS title, COALESCE(f.title, d.title, n.title, ev.title, p.display_name, '') AS title,
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content, COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
@@ -122,6 +198,10 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
`SELECT v.id,v.view_type_id,v.placement_mode,v.dock_edge,v.xpos,v.ypos,v.width,v.height,v.z_index,v.visible, `SELECT v.id,v.view_type_id,v.placement_mode,v.dock_edge,v.xpos,v.ypos,v.width,v.height,v.z_index,v.visible,
t.range_mode,t.range_start::text,t.range_end::text FROM osint.board_views v t.range_mode,t.range_start::text,t.range_end::text FROM osint.board_views v
JOIN osint.timeline_views t ON t.view_id=v.id WHERE v.board_id=$1 ORDER BY v.z_index,v.created_at`, [level.board_id]), JOIN osint.timeline_views t ON t.view_id=v.id WHERE v.board_id=$1 ORDER BY v.z_index,v.created_at`, [level.board_id]),
pool.query<{ document_exhibit_id: string; flag_key: string }>(
'SELECT document_exhibit_id,flag_key FROM osint.document_flag_requirements WHERE board_id=$1 ORDER BY document_exhibit_id,flag_key', [level.board_id]),
pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.level_flags WHERE level_id=$1 ORDER BY flag_key', [level.id]),
pool.query<{ document_exhibit_id: string }>('SELECT document_exhibit_id FROM osint.level_seen_documents WHERE level_id=$1', [level.id]),
]) ])
const blocks = new Map<string, string[]>() const blocks = new Map<string, string[]>()
@@ -134,6 +214,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
for (const row of metadataResult.rows) metadata.set(row.exhibit_id, { ...(metadata.get(row.exhibit_id) || {}), [row.field_key]: row.value }) for (const row of metadataResult.rows) metadata.set(row.exhibit_id, { ...(metadata.get(row.exhibit_id) || {}), [row.field_key]: row.value })
const aliases = new Map<string, string[]>() const aliases = new Map<string, string[]>()
for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias]) for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias])
const requirements = new Map<string, string[]>()
for (const row of requirementsResult.rows) requirements.set(row.document_exhibit_id, [...(requirements.get(row.document_exhibit_id) || []), row.flag_key])
const relations: ExhibitRelation[] = [ const relations: ExhibitRelation[] = [
...membershipsResult.rows.map(row => ({ id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromExhibitId: row.folder_exhibit_id, ...membershipsResult.rows.map(row => ({ id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromExhibitId: row.folder_exhibit_id,
toExhibitId: row.child_exhibit_id, type: 'contains' as const, sortOrder: row.sort_order })), toExhibitId: row.child_exhibit_id, type: 'contains' as const, sortOrder: row.sort_order })),
@@ -149,13 +231,13 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
const documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => { const documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => {
const type = row.document_type_id || 'file' const type = row.document_type_id || 'file'
const publishedAt = row.published_at?.toISOString() const publishedAt = row.published_at?.toISOString()
return { ...base(row), type: 'document', publishedAt, return { ...base(row), type: 'document', publishedAt, requiredFlags: requirements.get(row.id) || [],
body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined, body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined,
fileName: row.original_name || undefined, mimeType: row.mime_type || undefined, fileName: row.original_name || undefined, mimeType: row.mime_type || undefined,
fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, metadata: metadata.get(row.id) || {} } fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, metadata: metadata.get(row.id) || {} }
}) })
const evidence: Evidence[] = [] const evidence: Evidence[] = []
for (const row of exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document' && !row.hidden)) { for (const row of exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document')) {
const common = { ...base(row), title: row.title, content: row.content } const common = { ...base(row), title: row.title, content: row.content }
if (row.exhibit_type_id === 'folder') evidence.push({ ...common, type:'folder', isOpen:Boolean(row.is_open) }) if (row.exhibit_type_id === 'folder') evidence.push({ ...common, type:'folder', isOpen:Boolean(row.is_open) })
else if (row.exhibit_type_id === 'event') evidence.push({ ...common, type:'event', eventDate:row.occurred_at?.toISOString() }) else if (row.exhibit_type_id === 'event') evidence.push({ ...common, type:'event', eventDate:row.occurred_at?.toISOString() })
@@ -170,7 +252,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
rangeMode: row.range_mode, range: row.range_start && row.range_end ? { start: row.range_start, end: row.range_end } : undefined })) rangeMode: row.range_mode, range: row.range_start && row.range_end ? { start: row.range_start, end: row.range_end } : undefined }))
const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text, const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text,
...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined })) ...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined }))
return { id: level.slug, title: level.title, subtitle: level.subtitle, exhibits: [...documents, ...evidence], relations, const fullState: CaseState = { id: level.slug, title: level.title, subtitle: level.subtitle, exhibits: [...documents, ...evidence], relations,
connections: connectionsResult.rows.map(row => ({ id: row.id, fromExhibitId: row.from_exhibit_id, toExhibitId: row.to_exhibit_id, connections: connectionsResult.rows.map(row => ({ id: row.id, fromExhibitId: row.from_exhibit_id, toExhibitId: row.to_exhibit_id,
label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style, label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style,
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })), tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
@@ -178,6 +260,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
views, revision: Number(level.revision), views, revision: Number(level.revision),
brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode, brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
sourceTemplateVersionId: level.source_template_version_id || undefined } sourceTemplateVersionId: level.source_template_version_id || undefined }
return filterLevelVisibility(fullState, flagsResult.rows.map(row => row.flag_key), seenResult.rows.map(row => row.document_exhibit_id), authorMode)
} }
async function templateSummary(slug: string): Promise<TemplateSummary | null> { async function templateSummary(slug: string): Promise<TemplateSummary | null> {
@@ -234,6 +317,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [document.id, documentType(document), document.assetId || null, document.title, VALUES ($1,$2,$3,$4,$5,$6,$7)`, [document.id, documentType(document), document.assetId || null, document.title,
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null]) timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null])
if (document.fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [document.id]) if (document.fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [document.id])
for (const flag of new Set((document.requiredFlags || []).map(value => value.trim()).filter(Boolean).map(requireFlagKey))) await client.query(
'INSERT INTO osint.document_flag_requirements (board_id,document_exhibit_id,flag_key) VALUES ($1,$2,$3)', [level.board_id, document.id, flag])
for (const [sortOrder, content] of document.body.entries()) await client.query( for (const [sortOrder, content] of document.body.entries()) await client.query(
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,$3,$4)', [randomUUID(), document.id, sortOrder, content]) 'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,$3,$4)', [randomUUID(), document.id, sortOrder, content])
for (const [sortOrder, region] of document.regions.entries()) await client.query( for (const [sortOrder, region] of document.regions.entries()) await client.query(
@@ -411,13 +496,19 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
return templateSummary(input.slug) return templateSummary(input.slug)
}, },
getLevel(levelId, authorMode = false) { return assembleLevel(levelId, authorMode) }, getLevel(levelId, authorMode = false) { return assembleLevel(levelId, authorMode) },
async saveLevel(state) { async saveLevel(state, authorMode) {
let persistedState = state
if (!authorMode) {
const [full, visible] = await Promise.all([assembleLevel(state.id, true), assembleLevel(state.id, false)])
if (!full || !visible) throw new Error('Level not found')
persistedState = mergePlayerStateForPersistence(full, visible, state)
}
const client = await pool.connect() const client = await pool.connect()
try { try {
await client.query('BEGIN') await client.query('BEGIN')
const level = await findLevel(client, state.id, true) const level = await findLevel(client, state.id, true)
if (!level) throw new Error('Level not found') if (!level) throw new Error('Level not found')
await replaceBoard(client, level, state) await replaceBoard(client, level, persistedState)
await client.query('COMMIT') await client.query('COMMIT')
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
}, },
@@ -454,7 +545,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
const object = await objectStorage.getObject(asset.object_key) const object = await objectStorage.getObject(asset.object_key)
return object ? { originalName: asset.original_name, mimeType: asset.mime_type, byteSize: Number(asset.byte_size), stream: object.stream } : null return object ? { originalName: asset.original_name, mimeType: asset.mime_type, byteSize: Number(asset.byte_size), stream: object.stream } : null
}, },
async uploadDocument(levelId, file) { async uploadDocument(levelId, file, extraction, placement) {
const client = await pool.connect() const client = await pool.connect()
try { try {
await client.query('BEGIN') await client.query('BEGIN')
@@ -474,17 +565,149 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
assetId = asset.rows[0].id assetId = asset.rows[0].id
} }
const fileType: SourceFileType = file.mimetype.startsWith('image/') ? 'image' : file.mimetype === 'application/pdf' ? 'pdf' : file.mimetype.startsWith('text/') ? 'text' : 'file' const fileType: SourceFileType = file.mimetype.startsWith('image/') ? 'image' : file.mimetype === 'application/pdf' ? 'pdf' : file.mimetype.startsWith('text/') ? 'text' : 'file'
const xpos = Number.isFinite(placement?.x) ? Math.max(0, Math.min(10_000, Number(placement?.x))) : 100
const ypos = Number.isFinite(placement?.y) ? Math.max(0, Math.min(10_000, Number(placement?.y))) : 100
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden) await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
VALUES ($1,$2,'document',100,100,174,145,(SELECT COUNT(*) FROM osint.exhibits WHERE board_id=$2),FALSE)`, [exhibitId, level.board_id]) VALUES ($1,$2,'document',$3,$4,174,145,(SELECT COUNT(*) FROM osint.exhibits WHERE board_id=$2),FALSE)`, [exhibitId, level.board_id, xpos, ypos])
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)`, await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)`,
[exhibitId, fileType, assetId, file.originalname]) [exhibitId, fileType, assetId, file.originalname])
if (fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [exhibitId]) if (fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [exhibitId])
if (extraction.status === 'succeeded' && extraction.text.trim()) await client.query(
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,0,$3)', [randomUUID(), exhibitId, extraction.text.trim()])
const extractionResult = await client.query<{ id: string }>(`INSERT INTO osint.asset_text_extractions
(id,asset_id,extractor,extractor_version,language,status,extracted_text,error_message)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (asset_id,extractor,extractor_version,language) DO UPDATE SET
status=EXCLUDED.status,extracted_text=EXCLUDED.extracted_text,error_message=EXCLUDED.error_message,updated_at=NOW()
RETURNING id`, [randomUUID(), assetId, extraction.extractor, extraction.extractorVersion, extraction.language,
extraction.status, extraction.text, extraction.error?.slice(0, 2_000) || null])
const extractionId = extractionResult.rows[0].id
const ruleDefinitions = extraction.status === 'succeeded' && extraction.text.trim()
? await evidenceMatchRules(client, level.board_id)
: []
const evaluations = evaluateEvidenceRules(extraction.text, ruleDefinitions as EvidenceMatchRule[])
const matchedFlags: string[] = []
const awardedFlags: string[] = []
for (const evaluation of evaluations) {
const evaluationId = randomUUID()
await client.query(`INSERT INTO osint.evidence_match_evaluations
(id,level_id,board_id,document_exhibit_id,extraction_id,rule_id,matched,matched_anchor_count,score)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [evaluationId, level.id, level.board_id, exhibitId, extractionId,
evaluation.ruleId, evaluation.matched, evaluation.matchedAnchorCount, evaluation.score])
for (const anchor of evaluation.anchors) await client.query(`INSERT INTO osint.evidence_match_anchor_evaluations
(evaluation_id,anchor_id,similarity,matched,matched_text) VALUES ($1,$2,$3,$4,$5)`,
[evaluationId, anchor.anchorId, anchor.similarity, anchor.matched, anchor.matchedText])
if (!evaluation.matched) continue
matchedFlags.push(evaluation.flagKey)
const awarded = await client.query(`INSERT INTO osint.level_flags (level_id,board_id,flag_key,awarded_by_evidence_match_id)
VALUES ($1,$2,$3,$4) ON CONFLICT (level_id,flag_key) DO NOTHING RETURNING flag_key`,
[level.id, level.board_id, evaluation.flagKey, evaluationId])
if (awarded.rowCount) awardedFlags.push(evaluation.flagKey)
}
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id]) await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id]) await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
await client.query('COMMIT') await client.query('COMMIT')
return { id: exhibitId,type:'document',title:file.originalname,x:100,y:100,width:174,height:145,rotation:0,zIndex:0,hidden:false, return { id: exhibitId,type:'document',title:file.originalname,x:xpos,y:ypos,width:174,height:145,rotation:0,zIndex:0,hidden:false,
fileType,metadata:{},body:[],regions:[],assetId,fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size } fileType,metadata:{},body:extraction.status === 'succeeded' && extraction.text.trim() ? [extraction.text.trim()] : [],regions:[],assetId,
fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size,
analysis:{ extractionStatus:extraction.status, matchedFlags:[...new Set(matchedFlags)], awardedFlags:[...new Set(awardedFlags)] } }
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
}, },
async listFlags(levelId) {
const level = await findLevel(pool, levelId)
if (!level) return null
const result = await pool.query<{ flag_key: string; earned_at: Date | null; gated_document_count: number }>(`
WITH keys AS (
SELECT flag_key FROM osint.level_flags WHERE level_id=$1
UNION
SELECT flag_key FROM osint.document_flag_requirements WHERE board_id=$2
UNION
SELECT flag_key FROM osint.evidence_match_rules WHERE board_id=$2
)
SELECT keys.flag_key,flags.earned_at,COUNT(requirements.document_exhibit_id)::int AS gated_document_count
FROM keys
LEFT JOIN osint.level_flags flags ON flags.level_id=$1 AND flags.flag_key=keys.flag_key
LEFT JOIN osint.document_flag_requirements requirements ON requirements.board_id=$2 AND requirements.flag_key=keys.flag_key
GROUP BY keys.flag_key,flags.earned_at ORDER BY keys.flag_key`, [level.id, level.board_id])
return result.rows.map(row => ({ key: row.flag_key, earnedAt: row.earned_at?.toISOString(), gatedDocumentCount: row.gated_document_count }))
},
async setFlag(levelId, rawKey, earned) {
const key = requireFlagKey(rawKey.trim())
const client = await pool.connect()
try {
await client.query('BEGIN')
const level = await findLevel(client, levelId, true)
if (!level) { await client.query('ROLLBACK'); return false }
if (earned) await client.query(`INSERT INTO osint.level_flags (level_id,board_id,flag_key) VALUES ($1,$2,$3)
ON CONFLICT (level_id,flag_key) DO NOTHING`, [level.id, level.board_id, key])
else {
await client.query('DELETE FROM osint.level_flags WHERE level_id=$1 AND flag_key=$2', [level.id, key])
await client.query(`DELETE FROM osint.level_seen_documents seen USING osint.document_flag_requirements requirement
WHERE seen.level_id=$1 AND seen.document_exhibit_id=requirement.document_exhibit_id AND requirement.board_id=$2 AND requirement.flag_key=$3`,
[level.id, level.board_id, key])
}
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
await client.query('COMMIT')
return true
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
},
async listEvidenceMatchRules(levelId) {
const level = await findLevel(pool, levelId)
return level ? evidenceMatchRules(pool, level.board_id, true) : null
},
async createEvidenceMatchRule(levelId, input) {
const client = await pool.connect()
try {
await client.query('BEGIN')
const level = await findLevel(client, levelId, true)
if (!level) { await client.query('ROLLBACK'); return null }
const rule = await writeEvidenceMatchRule(client, level, randomUUID(), input, false)
await client.query('COMMIT')
return rule
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
},
async updateEvidenceMatchRule(levelId, ruleId, input) {
if (!uuidPattern.test(ruleId)) return null
const client = await pool.connect()
try {
await client.query('BEGIN')
const level = await findLevel(client, levelId, true)
if (!level) { await client.query('ROLLBACK'); return null }
const rule = await writeEvidenceMatchRule(client, level, ruleId, input, true)
await client.query('COMMIT')
return rule
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
},
async deleteEvidenceMatchRule(levelId, ruleId) {
if (!uuidPattern.test(ruleId)) return false
const client = await pool.connect()
try {
await client.query('BEGIN')
const level = await findLevel(client, levelId, true)
if (!level) { await client.query('ROLLBACK'); return null }
const removed = await client.query('DELETE FROM osint.evidence_match_rules WHERE id=$1 AND board_id=$2', [ruleId, level.board_id])
if (removed.rowCount) await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
await client.query('COMMIT')
return Boolean(removed.rowCount)
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
},
async acknowledgeRevealedDocuments(levelId, rawDocumentIds) {
const documentIds = [...new Set(rawDocumentIds.filter(id => uuidPattern.test(id)))]
const level = await findLevel(pool, levelId)
if (!level) return null
if (!documentIds.length) return 0
const result = await pool.query(`INSERT INTO osint.level_seen_documents (level_id,board_id,document_exhibit_id)
SELECT $1,$2,e.id FROM osint.exhibits e
WHERE e.board_id=$2 AND e.id=ANY($3::uuid[]) AND e.exhibit_type_id='document' AND NOT e.hidden
AND NOT EXISTS (
SELECT 1 FROM osint.document_flag_requirements requirement
WHERE requirement.document_exhibit_id=e.id AND NOT EXISTS (
SELECT 1 FROM osint.level_flags flag WHERE flag.level_id=$1 AND flag.flag_key=requirement.flag_key
)
)
ON CONFLICT (level_id,document_exhibit_id) DO NOTHING`, [level.id, level.board_id, documentIds])
return result.rowCount || 0
},
} }
} }
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import type { CaseState, DocumentExhibit, NoteExhibit } from '../src/types.js'
import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVisibility.js'
const placed = { x: 10, y: 20, width: 174, height: 145, rotation: 0, zIndex: 1, hidden: false }
const open: DocumentExhibit = { id: 'open', type: 'document', title: 'Open', body: [], regions: [], fileType: 'image', metadata: {}, ...placed }
const gated: DocumentExhibit = { id: 'gated', type: 'document', title: 'Gated', body: [], regions: [], fileType: 'image', metadata: {}, requiredFlags: ['tip.received'], ...placed }
const note: NoteExhibit = { id: 'note', type: 'note', title: 'Note', content: '', ...placed }
const state: CaseState = {
id: 'demo', title: 'Demo', subtitle: '', exhibits: [open, gated, note], viewport: { x: 0, y: 0, zoom: 1 },
relations: [{ id: 'source', type: 'source', fromExhibitId: note.id, toExhibitId: gated.id, sortOrder: 0 }],
connections: [{ id: 'thread', fromExhibitId: open.id, toExhibitId: gated.id }],
views: [], brief: { body: '', concepts: [] }, revision: 0,
}
describe('level flag visibility', () => {
it('hides gated documents and every edge touching them', () => {
const visible = filterLevelVisibility(state, [], [], false)
expect(visible.exhibits.map(item => item.id)).toEqual(['open', 'note'])
expect(visible.relations).toEqual([])
expect(visible.connections).toEqual([])
expect(visible.newlyVisibleDocumentIds).toEqual(['open'])
})
it('reveals earned documents once without leaking their gate definition', () => {
const visible = filterLevelVisibility(state, ['tip.received'], ['open'], false)
expect(visible.exhibits.map(item => item.id)).toEqual(['open', 'gated', 'note'])
expect((visible.exhibits[1] as DocumentExhibit).requiredFlags).toBeUndefined()
expect(visible.newlyVisibleDocumentIds).toEqual(['gated'])
})
it('returns all documents and requirements to author mode', () => {
const authored = filterLevelVisibility(state, [], [], true)
expect((authored.exhibits[1] as DocumentExhibit).requiredFlags).toEqual(['tip.received'])
expect(authored.newlyVisibleDocumentIds).toEqual([])
})
it('preserves unrevealed documents and their edges during a play-mode save', () => {
const visible = filterLevelVisibility(state, [], ['open'], false)
const submitted = { ...visible, exhibits: visible.exhibits.map(item => item.id === 'open' ? { ...item, x: 99 } : item) }
const merged = mergePlayerStateForPersistence(state, visible, submitted)
expect(merged.exhibits.find(item => item.id === 'open')?.x).toBe(99)
expect(merged.exhibits.find(item => item.id === 'gated')).toMatchObject({ requiredFlags: ['tip.received'] })
expect(merged.relations).toHaveLength(1)
expect(merged.connections).toHaveLength(1)
})
})
+49
View File
@@ -0,0 +1,49 @@
import type { CaseState, DocumentExhibit, ExhibitRelation, Connection } from '../src/types.js'
function requirementsMet(document: DocumentExhibit, earnedFlags: ReadonlySet<string>) {
return (document.requiredFlags || []).every(flag => earnedFlags.has(flag))
}
export function filterLevelVisibility(full: CaseState, earnedFlags: Iterable<string>, seenDocumentIds: Iterable<string>, authorMode: boolean): CaseState {
if (authorMode) return { ...full, newlyVisibleDocumentIds: [] }
const earned = new Set(earnedFlags)
const seen = new Set(seenDocumentIds)
const visibleExhibits = full.exhibits.filter(exhibit => !exhibit.hidden && (exhibit.type !== 'document' || requirementsMet(exhibit, earned)))
const visibleIds = new Set(visibleExhibits.map(exhibit => exhibit.id))
const sanitize = (exhibit: typeof visibleExhibits[number]) => {
if (exhibit.type !== 'document') return exhibit
const { requiredFlags: _requirements, ...document } = exhibit
return document
}
return {
...full,
exhibits: visibleExhibits.map(sanitize),
relations: full.relations.filter(relation => visibleIds.has(relation.fromExhibitId) && visibleIds.has(relation.toExhibitId)),
connections: full.connections.filter(connection => visibleIds.has(connection.fromExhibitId) && visibleIds.has(connection.toExhibitId)),
newlyVisibleDocumentIds: visibleExhibits.flatMap(exhibit => exhibit.type === 'document' && !seen.has(exhibit.id) ? [exhibit.id] : []),
}
}
function appendMissingById<T extends { id: string }>(submitted: T[], preserved: T[]) {
const ids = new Set(submitted.map(item => item.id))
return [...submitted, ...preserved.filter(item => !ids.has(item.id))]
}
/** Preserve server-hidden objects during the legacy whole-board PUT used by play mode. */
export function mergePlayerStateForPersistence(full: CaseState, visible: CaseState, submitted: CaseState): CaseState {
const visibleIds = new Set(visible.exhibits.map(exhibit => exhibit.id))
const unavailableIds = new Set(full.exhibits.filter(exhibit => !visibleIds.has(exhibit.id)).map(exhibit => exhibit.id))
const fullDocuments = new Map(full.exhibits.flatMap(exhibit => exhibit.type === 'document' ? [[exhibit.id, exhibit] as const] : []))
const submittedExhibits = submitted.exhibits.map(exhibit => exhibit.type === 'document'
? { ...exhibit, requiredFlags: fullDocuments.get(exhibit.id)?.requiredFlags || exhibit.requiredFlags || [] }
: exhibit)
const preservedExhibits = full.exhibits.filter(exhibit => unavailableIds.has(exhibit.id))
const touchesUnavailable = (item: ExhibitRelation | Connection) => unavailableIds.has(item.fromExhibitId) || unavailableIds.has(item.toExhibitId)
return {
...submitted,
exhibits: appendMissingById(submittedExhibits, preservedExhibits),
relations: appendMissingById(submitted.relations, full.relations.filter(touchesUnavailable)),
connections: appendMissingById(submitted.connections, full.connections.filter(touchesUnavailable)),
newlyVisibleDocumentIds: [],
}
}
+8 -3
View File
@@ -1,4 +1,5 @@
import path from 'node:path' import path from 'node:path'
import fs from 'node:fs/promises'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import pg from 'pg' import pg from 'pg'
import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { afterAll, beforeAll, describe, expect, it } from 'vitest'
@@ -31,9 +32,10 @@ suite('PostgreSQL migrations', () => {
it('applies every migration transactionally and is idempotent', async () => { it('applies every migration transactionally and is idempotent', async () => {
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations') const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
const migrationCount = (await fs.readdir(migrationsDir)).filter(name => /^\d+.*\.sql$/.test(name)).length
const firstRun: string[] = [] const firstRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message)) await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(24) expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(migrationCount)
const client = new Client({ connectionString: testDatabaseUrl }) const client = new Client({ connectionString: testDatabaseUrl })
await client.connect() await client.connect()
@@ -46,10 +48,13 @@ suite('PostgreSQL migrations', () => {
'board_views', 'timeline_views', 'board_views', 'timeline_views',
'mysteries', 'npcs', 'npc_poses', 'playthroughs', 'mysteries', 'npcs', 'npc_poses', 'playthroughs',
'story_nodes', 'story_node_terminals', 'utterances', 'story_nodes', 'story_node_terminals', 'utterances',
'level_flags', 'document_flag_requirements', 'level_seen_documents', 'achievements',
'asset_text_extractions', 'evidence_match_rules', 'evidence_match_anchors',
'evidence_match_evaluations', 'evidence_match_anchor_evaluations',
])) ]))
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue'])) expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue']))
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations') const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
expect(ledger.rows[0].count).toBe('24') expect(ledger.rows[0].count).toBe(String(migrationCount))
const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`) const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`)
expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset'])) expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset']))
const eventOccurrence = await client.query<{ is_nullable: string }>(`SELECT is_nullable FROM information_schema.columns WHERE table_schema='osint' AND table_name='event_exhibits' AND column_name='occurred_at'`) const eventOccurrence = await client.query<{ is_nullable: string }>(`SELECT is_nullable FROM information_schema.columns WHERE table_schema='osint' AND table_name='event_exhibits' AND column_name='occurred_at'`)
@@ -58,7 +63,7 @@ suite('PostgreSQL migrations', () => {
const secondRun: string[] = [] const secondRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message)) await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(24) expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(migrationCount)
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false) expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
}) })
}) })
+20
View File
@@ -47,6 +47,8 @@ export interface NarrativeRepository {
createPlaythrough(userId: string, mysterySlug?: string): Promise<PlaythroughState | null> createPlaythrough(userId: string, mysterySlug?: string): Promise<PlaythroughState | null>
getCurrentPlaythrough(userId: string): Promise<PlaythroughState | null> getCurrentPlaythrough(userId: string): Promise<PlaythroughState | null>
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }> advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
listAchievements(playthroughId: string): Promise<string[] | null>
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }>
listMysteries(): Promise<MysterySummary[]> listMysteries(): Promise<MysterySummary[]>
deleteMystery(id: string): Promise<boolean> deleteMystery(id: string): Promise<boolean>
uploadAsset(file: UploadedFile): Promise<AssetDto> uploadAsset(file: UploadedFile): Promise<AssetDto>
@@ -231,6 +233,24 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
return row ? stateForPlaythrough(row.id) : null return row ? stateForPlaythrough(row.id) : null
}, },
async listAchievements(playthroughId) {
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
const rows = (await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1 ORDER BY flag_key', [playthroughId])).rows
return rows.map(row => row.flag_key)
},
// Grant an achievement (idempotent). `earned` is true only on the first grant.
// The eventual server-side rule engine calls this same operation.
async awardAchievement(playthroughId, rawKey, nodeId) {
const key = rawKey.trim()
if (!/^[a-z][a-z0-9_.-]{0,63}$/.test(key)) return { ok: false, earned: false, error: 'Invalid achievement key' }
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return { ok: false, earned: false, error: 'Playthrough not found' }
const result = await pool.query(
'INSERT INTO osint.achievements (playthrough_id,flag_key,awarded_by_node_id) VALUES ($1,$2,$3) ON CONFLICT (playthrough_id,flag_key) DO NOTHING',
[playthroughId, key, nodeId || null])
return { ok: true, earned: (result.rowCount || 0) > 0 }
},
async advancePlaythrough(userId, playthroughId, terminalKey) { async advancePlaythrough(userId, playthroughId, terminalKey) {
const client = await pool.connect() const client = await pool.connect()
try { try {
+78
View File
@@ -0,0 +1,78 @@
import { spawn } from 'node:child_process'
export type TextExtractionResult = {
extractor: string
extractorVersion: string
language: string
status: 'succeeded' | 'unsupported' | 'failed'
text: string
error?: string
}
export interface TextExtractor {
provider: string
extract(file: { buffer: Buffer; mimetype: string }): Promise<TextExtractionResult>
}
function plainText(file: { buffer: Buffer; mimetype: string }, maximumCharacters: number): TextExtractionResult | null {
if (!file.mimetype.startsWith('text/')) return null
return { extractor: 'plain-text', extractorVersion: '1', language: 'und', status: 'succeeded', text: file.buffer.toString('utf8').slice(0, maximumCharacters) }
}
function runTesseract(command: string, buffer: Buffer, languages: string, pageSegmentationMode: string, timeoutMs: number) {
return new Promise<string>((resolve, reject) => {
const child = spawn(command, ['stdin', 'stdout', '-l', languages, '--psm', pageSegmentationMode], { stdio: ['pipe', 'pipe', 'pipe'] })
const stdout: Buffer[] = []
const stderr: Buffer[] = []
let settled = false
const timer = setTimeout(() => {
if (settled) return
settled = true
child.kill('SIGKILL')
reject(new Error(`OCR timed out after ${timeoutMs}ms`))
}, timeoutMs)
child.stdout.on('data', chunk => stdout.push(Buffer.from(chunk)))
child.stderr.on('data', chunk => stderr.push(Buffer.from(chunk)))
child.once('error', error => {
if (settled) return
settled = true
clearTimeout(timer)
reject(error)
})
child.once('close', code => {
if (settled) return
settled = true
clearTimeout(timer)
if (code === 0) resolve(Buffer.concat(stdout).toString('utf8').trim())
else reject(new Error(Buffer.concat(stderr).toString('utf8').trim() || `OCR exited with status ${code}`))
})
child.stdin.end(buffer)
})
}
export function createTextExtractorFromEnv(): TextExtractor {
const enabled = process.env.OCR_ENABLED !== 'false'
const command = process.env.OCR_COMMAND || 'tesseract'
const languages = process.env.OCR_LANGUAGES || 'nor+eng'
const extractorVersion = process.env.OCR_ENGINE_VERSION || 'tesseract-cli-5'
const pageSegmentationMode = process.env.OCR_PAGE_SEGMENTATION_MODE || '3'
const timeoutMs = Math.max(1_000, Number(process.env.OCR_TIMEOUT_MS || 20_000))
const maximumBytes = Math.max(1, Number(process.env.MAX_OCR_BYTES || 15 * 1024 * 1024))
const maximumCharacters = Math.max(1_000, Number(process.env.MAX_EXTRACTED_TEXT_CHARACTERS || 200_000))
return {
provider: enabled ? 'tesseract' : 'disabled',
async extract(file) {
const direct = plainText(file, maximumCharacters)
if (direct) return direct
if (!file.mimetype.startsWith('image/')) return { extractor: 'none', extractorVersion: '1', language: 'und', status: 'unsupported', text: '' }
if (!enabled) return { extractor: 'tesseract', extractorVersion, language: languages, status: 'unsupported', text: '' }
if (file.buffer.byteLength > maximumBytes) return { extractor: 'tesseract', extractorVersion, language: languages, status: 'failed', text: '', error: `Image exceeds the ${maximumBytes}-byte OCR limit` }
try {
const text = (await runTesseract(command, file.buffer, languages, pageSegmentationMode, timeoutMs)).slice(0, maximumCharacters)
return { extractor: 'tesseract', extractorVersion, language: languages, status: 'succeeded', text }
} catch (error) {
return { extractor: 'tesseract', extractorVersion, language: languages, status: 'failed', text: '', error: error instanceof Error ? error.message : 'OCR failed' }
}
},
}
}
+196 -78
View File
@@ -1,9 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react' import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView } from './types' import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState, type PlaythroughSummary, type RuntimeNode } from './narrative'
import { AdminPanel } from './admin' import { AdminPanel } from './admin'
import { audio } from './audio'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain' import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry' import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
@@ -14,6 +12,11 @@ const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
].map(value => ({ value: value as SourceFileType, label: documentWidget(value as SourceFileType).label })) ].map(value => ({ value: value as SourceFileType, label: documentWidget(value as SourceFileType).label }))
function uid(_prefix: string) { return crypto.randomUUID() } function uid(_prefix: string) { return crypto.randomUUID() }
function screenshotFile(file: File, index: number) {
const extension = file.type === 'image/jpeg' ? 'jpg' : file.type === 'image/webp' ? 'webp' : 'png'
const timestamp = new Date().toISOString().replace('T', ' ').replace(/:/g, '.').slice(0, 19)
return new File([file], `Screenshot ${timestamp}${index ? ` ${index + 1}` : ''}.${extension}`, { type: file.type || 'image/png', lastModified: Date.now() })
}
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` } function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
function documentSearchText(document: CaseDocument) { function documentSearchText(document: CaseDocument) {
return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.fileName, document.mimeType, return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.fileName, document.mimeType,
@@ -63,11 +66,9 @@ export function App() {
const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null) const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null) const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null)
const [threadDraft, setThreadDraft] = useState<Connection | null>(null) const [threadDraft, setThreadDraft] = useState<Connection | null>(null)
const [splashOpen, setSplashOpen] = useState(false) const [flagsOpen, setFlagsOpen] = useState(false)
const [splashBusy, setSplashBusy] = useState(false) const [matchRulesOpen, setMatchRulesOpen] = useState(false)
const [playthrough, setPlaythrough] = useState<PlaythroughSummary | null>(null) const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
const [runtimeNode, setRuntimeNode] = useState<RuntimeNode | null>(null)
const [muted, setMuted] = useState(audio.isMuted())
const saveTimer = useRef<number | undefined>(undefined) const saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null) const boardRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
@@ -80,6 +81,13 @@ export function App() {
if (!response.ok) throw new Error('Level unavailable') if (!response.ok) throw new Error('Level unavailable')
const data = normalizeCase(await response.json()) const data = normalizeCase(await response.json())
setCaseState(data) setCaseState(data)
const arrivals = data.newlyVisibleDocumentIds || []
if (arrivals.length) {
setArrivingExhibitIds(arrivals)
void fetch(`/api/levels/${encodeURIComponent(data.id)}/reveals/seen`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: arrivals }),
})
}
return data return data
}, []) }, [])
@@ -95,18 +103,11 @@ export function App() {
if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true) if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
setStatus('EVIDENCE INTEGRITY: PROBABLY OK') setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
} }
// Player campaign entry: a live playthrough resumes silently; none shows the splash.
// An explicit ?level= deep link (admin/authoring) bypasses the campaign entirely.
const boot = async () => { const boot = async () => {
if (deepLinkLevel) { await openLevel(deepLinkLevel); return } if (deepLinkLevel) { await openLevel(deepLinkLevel); return }
const current = await fetch('/api/playthroughs/current') const levels = await (await fetch('/api/levels')).json() as { id: string }[]
if (current.status === 204) { setSplashOpen(true); setStatus('AWAITING PRINCIPAL INVESTIGATOR'); return } if (!levels[0]?.id) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return }
if (!current.ok) throw new Error('Playthrough unavailable') await openLevel(levels[0].id)
const state: PlaythroughState = await current.json()
setPlaythrough(state.playthrough)
setRuntimeNode(state.node)
if (state.node?.kind === 'level' && state.node.levelSlug) await loadLevelBySlug(state.node.levelSlug)
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
} }
boot().catch(async () => { boot().catch(async () => {
try { try {
@@ -125,40 +126,6 @@ export function App() {
return () => clearInterval(timer) return () => clearInterval(timer)
}, [loadLevelBySlug, adminRoute]) }, [loadLevelBySlug, adminRoute])
const applyState = useCallback(async (state: PlaythroughState) => {
setPlaythrough(state.playthrough)
setRuntimeNode(state.node)
if (state.node?.kind === 'level' && state.node.levelSlug) await loadLevelBySlug(state.node.levelSlug)
if (!state.node && state.playthrough.status === 'finished') { setSplashOpen(true); setStatus('CASE CLOSED · GREYHAVEN FILE 87-10') }
}, [loadLevelBySlug])
const startNewGame = useCallback(async () => {
setSplashBusy(true)
try {
const response = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
if (!response.ok) throw new Error('Could not start')
await applyState(await response.json())
setSplashOpen(false)
} catch { setStatus('COULD NOT OPEN CASE FILE') } finally { setSplashBusy(false) }
}, [applyState])
// Advance the story graph through a terminal (a dialogue supplies the chosen exit;
// cutscene/level advance through the node's single terminal).
const advance = useCallback(async (terminalKey?: string) => {
if (!playthrough) return
try {
const response = await fetch(`/api/playthroughs/${encodeURIComponent(playthrough.id)}/advance`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(terminalKey ? { terminalKey } : {}) })
if (!response.ok) throw new Error()
await applyState(await response.json())
} catch { setStatus('COULD NOT ADVANCE') }
}, [playthrough, applyState])
// Scene music follows the current node (null inherits; a finished playthrough stops).
useEffect(() => {
if (runtimeNode?.musicUrl) audio.setMusic(runtimeNode.musicUrl, runtimeNode.musicVolume)
else if (playthrough?.status === 'finished') audio.setMusic(null)
}, [runtimeNode, playthrough])
useEffect(() => { useEffect(() => {
if (!adminMenuOpen) return if (!adminMenuOpen) return
const close = (event: PointerEvent) => { if (!adminMenuRef.current?.contains(event.target as Node)) setAdminMenuOpen(false) } const close = (event: PointerEvent) => { if (!adminMenuRef.current?.contains(event.target as Node)) setAdminMenuOpen(false) }
@@ -178,6 +145,12 @@ export function App() {
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [recentlyCreatedConnectionId]) }, [recentlyCreatedConnectionId])
useEffect(() => {
if (!arrivingExhibitIds.length) return
const timer = window.setTimeout(() => setArrivingExhibitIds([]), 1800)
return () => window.clearTimeout(timer)
}, [arrivingExhibitIds])
const update = useCallback((fn: (state: CaseState) => CaseState) => { const update = useCallback((fn: (state: CaseState) => CaseState) => {
setCaseState(current => { setCaseState(current => {
if (!current) return current if (!current) return current
@@ -371,33 +344,65 @@ export function App() {
window.location.assign(`${window.location.pathname}?${params.toString()}`) window.location.assign(`${window.location.pathname}?${params.toString()}`)
} }
const uploadFiles = async (files: FileList | File[]) => { const uploadFiles = useCallback(async (files: FileList | File[], source: 'file' | 'clipboard' = 'file') => {
if (!caseState || !requestedEditMode || !caseState.editingAllowed) return if (!caseState) return
const queue = Array.from(files) const queue = Array.from(files)
setUploading(queue.length) setUploading(queue.length)
setDraggingFiles(false) setDraggingFiles(false)
for (const file of queue) { for (const [queueIndex, file] of queue.entries()) {
const position = nextOpenBoardPosition(caseState.exhibits, {
x: Math.max(100, (520 - caseState.viewport.x) / caseState.viewport.zoom) + queueIndex * 24,
y: Math.max(100, (310 - caseState.viewport.y) / caseState.viewport.zoom) + queueIndex * 24,
}, { width: 174, height: 145 })
const form = new FormData() const form = new FormData()
form.append('file', file) form.append('file', file)
form.append('x', String(position.x))
form.append('y', String(position.y))
try { try {
const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents?edit=1`, { method: 'POST', body: form }) const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents`, { method: 'POST', body: form })
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) } if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
const document: CaseDocument = await response.json() const uploaded: UploadedCaseDocument = await response.json()
update(s => ({ ...s, exhibits: [...s.exhibits, document] })) const { analysis, ...document } = uploaded
setStatus(`IMPORTED · ${file.name.toUpperCase()}`) update(s => {
return { ...s, exhibits: [...s.exhibits, { ...document, ...position }] }
})
setSelected(document.id)
setArrivingExhibitIds(current => [...new Set([...current, document.id])])
void fetch(`/api/levels/${encodeURIComponent(caseState.id)}/reveals/seen`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: [document.id] }),
})
if (analysis.awardedFlags.length) {
window.clearTimeout(saveTimer.current)
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
await loadLevelBySlug(caseState.id, editQuery)
setSelected(document.id)
setStatus(`EVIDENCE MATCHED · ${analysis.awardedFlags.join(', ').toUpperCase()} · NEW MATERIAL UNLOCKED`)
} else if (analysis.matchedFlags.length) setStatus('EVIDENCE MATCHED · ACHIEVEMENT ALREADY RECORDED')
else if (source === 'clipboard' && analysis.extractionStatus === 'succeeded') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
else if (source === 'clipboard' && analysis.extractionStatus === 'failed') setStatus('SCREENSHOT SAVED · TEXT ANALYSIS UNAVAILABLE')
else setStatus(source === 'clipboard' ? 'SCREENSHOT PASTED · NEW IMAGE DOCUMENT' : `IMPORTED · ${file.name.toUpperCase()}`)
} catch (error) { } catch (error) {
setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED') setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED')
} finally { setUploading(count => count - 1) } } finally { setUploading(count => count - 1) }
} }
} }, [caseState, loadLevelBySlug, requestedEditMode, update])
const audioToggle = playthrough ? <button className={`audio-toggle${muted ? ' muted' : ''}`} title={muted ? 'Unmute' : 'Mute'} onClick={() => setMuted(audio.toggleMute())}></button> : null useEffect(() => {
if (!caseState) return
const handlePaste = (event: ClipboardEvent) => {
const images = Array.from(event.clipboardData?.items || []).flatMap(item => {
const file = item.kind === 'file' && item.type.startsWith('image/') ? item.getAsFile() : null
return file ? [file] : []
})
if (!images.length) return
event.preventDefault()
void uploadFiles(images.map(screenshotFile), 'clipboard')
}
window.addEventListener('paste', handlePaste)
return () => window.removeEventListener('paste', handlePaste)
}, [caseState, uploadFiles])
if (adminRoute) return <AdminPanel /> if (adminRoute) return <AdminPanel />
if (splashOpen) return <SplashScreen hasResume={false} busy={splashBusy} status={status} onNewGame={startNewGame} onResume={() => setSplashOpen(false)} />
// Story-graph runtime: cutscene and dialogue nodes play full-screen (no board).
if (runtimeNode?.kind === 'cutscene') return <>{audioToggle}<CutsceneHost componentKey={runtimeNode.componentKey} label={runtimeNode.label} onComplete={() => advance()} /></>
if (runtimeNode?.kind === 'dialogue') return <>{audioToggle}<DialoguePlayer node={{ utterances: runtimeNode.utterances || [], rootId: runtimeNode.rootId ?? null }} onExit={advance} /></>
if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} /> if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} />
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div> if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
@@ -421,7 +426,6 @@ export function App() {
}) })
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline') const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
return <main className="desktop"> return <main className="desktop">
{audioToggle}
<header className="menubar"> <header className="menubar">
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div> <div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
<nav> <nav>
@@ -429,13 +433,13 @@ export function App() {
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button> <button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button>
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button> <button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
<button onClick={() => setHelpOpen(true)}>HELP</button> <button onClick={() => setHelpOpen(true)}>HELP</button>
{runtimeNode?.kind === 'level' && <button className="report-back" onClick={() => advance()} title="Finish investigating and continue the story">REPORT BACK </button>}
{isAdmin && <div className="admin-menu" ref={adminMenuRef}> {isAdmin && <div className="admin-menu" ref={adminMenuRef}>
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button> <button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
{adminMenuOpen && <div className="admin-menu-items" role="menu"> {adminMenuOpen && <div className="admin-menu-items" role="menu">
<button role="menuitem" onClick={() => window.location.assign('/admin')}>NPC &amp; MYSTERY ADMIN</button> <button role="menuitem" onClick={() => { setFlagsOpen(true); setAdminMenuOpen(false) }}>LEVEL FLAGS</button>
{!canAuthor ? <button role="menuitem" onClick={enterLevelEditor}>ENTER LEVEL EDITOR</button> : <> {!canAuthor ? <button role="menuitem" onClick={enterLevelEditor}>ENTER LEVEL EDITOR</button> : <>
<button role="menuitem" onClick={() => { setEditingBrief(true); setAdminMenuOpen(false) }}>EDIT BRIEF &amp; CONCEPTS</button> <button role="menuitem" onClick={() => { setEditingBrief(true); setAdminMenuOpen(false) }}>EDIT BRIEF &amp; CONCEPTS</button>
<button role="menuitem" onClick={() => { setMatchRulesOpen(true); setAdminMenuOpen(false) }}>EVIDENCE MATCHING</button>
<button role="menuitem" onClick={() => { fileInputRef.current?.click(); setAdminMenuOpen(false) }}>IMPORT DOCUMENTS</button> <button role="menuitem" onClick={() => { fileInputRef.current?.click(); setAdminMenuOpen(false) }}>IMPORT DOCUMENTS</button>
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void saveAsTemplate() }}>SAVE AS TEMPLATE</button> <button role="menuitem" onClick={() => { setAdminMenuOpen(false); void saveAsTemplate() }}>SAVE AS TEMPLATE</button>
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void instantiateTemplate() }}>NEW FROM TEMPLATE</button> <button role="menuitem" onClick={() => { setAdminMenuOpen(false); void instantiateTemplate() }}>NEW FROM TEMPLATE</button>
@@ -450,9 +454,9 @@ export function App() {
<aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}> <aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}>
<div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{normalizedDocumentQuery ? `${filteredDocuments.length}/${documents.length}` : documents.length}</sup></h2></div><button aria-label="Close documents" onClick={() => setDocsOpen(false)}><X size={17}/></button></div> <div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{normalizedDocumentQuery ? `${filteredDocuments.length}/${documents.length}` : documents.length}</sup></h2></div><button aria-label="Close documents" onClick={() => setDocsOpen(false)}><X size={17}/></button></div>
<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>
{canAuthor && <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}` : 'IMPORT DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) 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' : ''}`} 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) => <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)}>
<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>{documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/> <div><strong>{doc.title}</strong><span>{documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
</button>)} </button>)}
@@ -461,9 +465,9 @@ export function App() {
<div className="panel-foot"><FolderOpen size={15}/> ARCHIVE MOUNTED <span>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING' : 'READ ONLY'}</span></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') && canAuthor) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (canAuthor) { 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) 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) }}>
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div> <div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} /> <Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} arrivingExhibitIds={arrivingExhibitIds} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
{briefOpen && <BriefPanel {briefOpen && <BriefPanel
brief={caseState.brief} brief={caseState.brief}
parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')} parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
@@ -521,7 +525,7 @@ export function App() {
setStatus('FOLDER UPDATED') setStatus('FOLDER UPDATED')
}} }}
/>} />}
{editingFileId && <FileEditor key={editingFileId} document={documents.find(document => document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/> {editingFileId && <FileEditor key={editingFileId} document={documents.find(document => document.id === editingFileId)!} canEditGates={canAuthor} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>
} }
{editingEventId && <EventEditor {editingEventId && <EventEditor
key={editingEventId} key={editingEventId}
@@ -602,6 +606,8 @@ export function App() {
onRemove={() => removeThread(threadDraft.id)} onRemove={() => removeThread(threadDraft.id)}
/>} />}
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>} {helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
{flagsOpen && <LevelFlagsEditor levelId={caseState.id} onClose={() => setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />}
{matchRulesOpen && <EvidenceMatchRulesEditor levelId={caseState.id} onClose={() => setMatchRulesOpen(false)}/>}
</main> </main>
} }
@@ -620,7 +626,7 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le
return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main> return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main>
} }
function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; locatorDocumentId: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) { function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedExhibitId, arrivingExhibitIds, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; locatorDocumentId: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; arrivingExhibitIds: string[]; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
const drag = useRef<{ kind: 'pan' | 'widget' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null) const drag = useRef<{ kind: 'pan' | 'widget' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
const suppressClick = useRef(false) const suppressClick = useRef(false)
const touchPoints = useRef(new Map<number, { x: number; y: number }>()) const touchPoints = useRef(new Map<number, { x: number; y: number }>())
@@ -808,14 +814,14 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}> <svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
{containmentRelations.map(relation => { const folder = byId.get(relation.fromExhibitId), document = byId.get(relation.toExhibitId); if (folder?.type !== 'folder' || document?.type !== 'document') return null; const origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={folder.isOpen ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={folder.isOpen ? document.x + document.width / 2 : origin.x} y2={folder.isOpen ? document.y + document.height / 2 : origin.y}/> })} {containmentRelations.map(relation => { const folder = byId.get(relation.fromExhibitId), document = byId.get(relation.toExhibitId); if (folder?.type !== 'folder' || document?.type !== 'document') return null; const origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={folder.isOpen ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={folder.isOpen ? document.x + document.width / 2 : origin.x} y2={folder.isOpen ? document.y + document.height / 2 : origin.y}/> })}
</svg> </svg>
{evidenceExhibits(state.exhibits).filter(exhibit => !exhibit.hidden).map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = byId.get(id); return document?.type === 'document' ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !ev.isOpen && containedDocuments.some(document => document.id === locatorDocumentId) ? locatorDocumentId : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} tabIndex={ev.type === 'folder' ? 0 : undefined} aria-expanded={ev.type === 'folder' ? ev.isOpen : undefined} title={ev.type === 'folder' ? 'Double-click or hold to open or close this folder' : undefined} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }} {evidenceExhibits(state.exhibits).filter(exhibit => !exhibit.hidden).map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = byId.get(id); return document?.type === 'document' ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !ev.isOpen && containedDocuments.some(document => document.id === locatorDocumentId) ? locatorDocumentId : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; const containsArrival = ev.type === 'folder' && containedDocuments.some(document => arrivingExhibitIds.includes(document.id)); return <article key={ev.id} tabIndex={ev.type === 'folder' ? 0 : undefined} aria-expanded={ev.type === 'folder' ? ev.isOpen : undefined} title={ev.type === 'folder' ? 'Double-click or hold to open or close this folder' : undefined} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id || arrivingExhibitIds.includes(ev.id) || containsArrival ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }}
onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (ev.type === 'folder') startFolderLongPress(e, ev.id); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }} onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (ev.type === 'folder') startFolderLongPress(e, ev.id); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }} onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (ev.type === 'folder' && e.detail > 1) return; if (tool === 'move') onCardClick(ev.id) }} onDoubleClick={e => { e.stopPropagation(); if (ev.type === 'folder' && tool === 'move' && !linkFrom && !(e.target as HTMLElement).closest('button')) toggleFolder(ev.id) }} onKeyDown={e => { if (ev.type === 'folder' && e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); toggleFolder(ev.id) } }}> onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (ev.type === 'folder' && e.detail > 1) return; if (tool === 'move') onCardClick(ev.id) }} onDoubleClick={e => { e.stopPropagation(); if (ev.type === 'folder' && tool === 'move' && !linkFrom && !(e.target as HTMLElement).closest('button')) toggleFolder(ev.id) }} onKeyDown={e => { if (ev.type === 'folder' && e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); toggleFolder(ev.id) } }}>
<header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header> <header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
<Widget exhibit={ev} context={widgetContext}/> <Widget exhibit={ev} context={widgetContext}/>
</article>})} </article>})}
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }} {documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }} onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}> onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
<header><span>{definition.label.toUpperCase()}</span><i>{String((membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header> <header><span>{definition.label.toUpperCase()}</span><i>{String((membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
@@ -1072,15 +1078,18 @@ function EventEditor({ event, exhibits, relations, onClose, onSave }: { event: E
</form></div> </form></div>
} }
function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onClose: () => void; onSave: (document: CaseDocument) => void }) { function FileEditor({ document, canEditGates, onClose, onSave }: { document: CaseDocument; canEditGates: boolean; onClose: () => void; onSave: (document: CaseDocument) => void }) {
const [title, setTitle] = useState(document.title) const [title, setTitle] = useState(document.title)
const [fileType, setFileType] = useState<SourceFileType>(document.fileType) const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt)) const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt))
const [requiredFlags, setRequiredFlags] = useState((document.requiredFlags || []).join(', '))
const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value }))) const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value })))
const submit = (event: React.FormEvent) => { const submit = (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
const publishedAt = publishedTime ? new Date(publishedTime).toISOString() : undefined const publishedAt = publishedTime ? new Date(publishedTime).toISOString() : undefined
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt, metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) }) onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt,
requiredFlags: canEditGates ? [...new Set(requiredFlags.split(',').map(value => value.trim().toLowerCase()).filter(Boolean))] : document.requiredFlags,
metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
} }
return <div className="modal-shade"><form className="window file-editor" onSubmit={submit}> return <div className="modal-shade"><form className="window file-editor" onSubmit={submit}>
<header><ImageIcon size={16}/><b>Edit source-file metadata</b><span/><button type="button" aria-label="Close file editor" onClick={onClose}><X size={14}/></button></header> <header><ImageIcon size={16}/><b>Edit source-file metadata</b><span/><button type="button" aria-label="Close file editor" onClick={onClose}><X size={14}/></button></header>
@@ -1091,6 +1100,7 @@ function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onC
<label className="field"><span>FILE TYPE</span><select value={fileType} onChange={event => setFileType(event.target.value as SourceFileType)}>{SOURCE_FILE_TYPES.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label> <label className="field"><span>FILE TYPE</span><select value={fileType} onChange={event => setFileType(event.target.value as SourceFileType)}>{SOURCE_FILE_TYPES.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
</div> </div>
<label className="field"><span><CalendarClock size={13}/> PUBLISHED TIME · LOCAL</span><input type="datetime-local" value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label> <label className="field"><span><CalendarClock size={13}/> PUBLISHED TIME · LOCAL</span><input type="datetime-local" value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
{canEditGates && <label className="field gate-field"><span>REVEAL FLAGS · ALL REQUIRED</span><input value={requiredFlags} placeholder="tip.received, archive.unlocked" pattern="[a-z0-9_.\-, ]*" onChange={event => setRequiredFlags(event.target.value)}/><small>Leave blank to show this document when the level first loads.</small></label>}
<div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div> <div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div>
<div className="metadata-rows">{metadata.length === 0 && <p>NO ADDITIONAL METADATA</p>}{metadata.map(row => <div className="metadata-row" key={row.id}><input aria-label="Metadata key" placeholder="FIELD" value={row.key} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, key: event.target.value } : candidate))}/><input aria-label="Metadata value" placeholder="VALUE" value={row.value} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, value: event.target.value } : candidate))}/><button type="button" aria-label="Remove metadata field" onClick={() => setMetadata(rows => rows.filter(candidate => candidate.id !== row.id))}><Trash2 size={13}/></button></div>)}</div> <div className="metadata-rows">{metadata.length === 0 && <p>NO ADDITIONAL METADATA</p>}{metadata.map(row => <div className="metadata-row" key={row.id}><input aria-label="Metadata key" placeholder="FIELD" value={row.key} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, key: event.target.value } : candidate))}/><input aria-label="Metadata value" placeholder="VALUE" value={row.value} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, value: event.target.value } : candidate))}/><button type="button" aria-label="Remove metadata field" onClick={() => setMetadata(rows => rows.filter(candidate => candidate.id !== row.id))}><Trash2 size={13}/></button></div>)}</div>
<p className="folder-editor-note">This metadata belongs to the source file, not to any folder that contains it.</p> <p className="folder-editor-note">This metadata belongs to the source file, not to any folder that contains it.</p>
@@ -1099,6 +1109,114 @@ function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onC
</form></div> </form></div>
} }
function LevelFlagsEditor({ levelId, onClose, onChanged }: { levelId: string; onClose: () => void; onChanged: () => void | Promise<void> }) {
const [flags, setFlags] = useState<LevelFlag[]>([])
const [newKey, setNewKey] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const load = useCallback(async () => {
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/flags`)
if (!response.ok) throw new Error('Could not load level flags')
setFlags(await response.json())
}, [levelId])
useEffect(() => { void load().catch(error => setError(error instanceof Error ? error.message : 'Could not load flags')) }, [load])
const setEarned = async (key: string, earned: boolean) => {
setBusy(true); setError('')
try {
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/flags/${encodeURIComponent(key)}`, { method: earned ? 'PUT' : 'DELETE' })
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || 'Could not update flag') }
await Promise.all([load(), onChanged()])
setNewKey('')
} catch (error) { setError(error instanceof Error ? error.message : 'Could not update flag') } finally { setBusy(false) }
}
const submit = (event: React.FormEvent) => {
event.preventDefault()
const key = newKey.trim().toLowerCase()
if (key) void setEarned(key, true)
}
return <div className="modal-shade"><section className="window flags-editor">
<header><Network size={16}/><b>Level flags</b><span/><button type="button" aria-label="Close level flags" onClick={onClose}><X size={14}/></button></header>
<div className="flags-editor-body"><small>ACHIEVEMENTS / DOCUMENT REVEALS</small>
<p>Documents remain server-hidden until every flag assigned in their metadata has been earned.</p>
<div className="flag-list">{flags.length === 0 && <div className="flag-empty">NO FLAGS OR DOCUMENT GATES IN THIS LEVEL</div>}{flags.map(flag => <div className={`flag-row ${flag.earnedAt ? 'earned' : ''}`} key={flag.key}><div><b>{flag.key}</b><small>{flag.gatedDocumentCount} GATED DOCUMENT{flag.gatedDocumentCount === 1 ? '' : 'S'}</small></div><button disabled={busy} onClick={() => void setEarned(flag.key, !flag.earnedAt)}>{flag.earnedAt ? 'REVOKE' : 'AWARD'}</button></div>)}</div>
<form className="flag-add" onSubmit={submit}><input aria-label="New flag key" value={newKey} placeholder="tip.received" pattern="[a-z][a-z0-9_.-]{0,63}" onChange={event => setNewKey(event.target.value.toLowerCase())}/><button disabled={busy || !newKey.trim()} type="submit">AWARD FLAG</button></form>
{error && <p className="flag-error">{error}</p>}
</div>
</section></div>
}
type MatchRuleDraft = {
id?: string
name: string
flagKey: string
minimumAnchorMatches: number
enabled: boolean
anchors: { id: string; phrase: string; minimumSimilarity: number }[]
}
const emptyMatchRule = (): MatchRuleDraft => ({ name: '', flagKey: '', minimumAnchorMatches: 1, enabled: true,
anchors: [{ id: uid('anchor'), phrase: '', minimumSimilarity: 0.72 }] })
function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClose: () => void }) {
const [rules, setRules] = useState<EvidenceMatchRuleDefinition[]>([])
const [draft, setDraft] = useState<MatchRuleDraft>(emptyMatchRule)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const load = useCallback(async () => {
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules`)
if (!response.ok) throw new Error('Could not load evidence match rules')
setRules(await response.json())
}, [levelId])
useEffect(() => { void load().catch(error => setError(error instanceof Error ? error.message : 'Could not load rules')) }, [load])
const edit = (rule: EvidenceMatchRuleDefinition) => setDraft({ id:rule.id,name:rule.name,flagKey:rule.flagKey,
minimumAnchorMatches:rule.minimumAnchorMatches,enabled:rule.enabled,
anchors:rule.anchors.map(anchor => ({ id:anchor.id,phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) })
const submit = async (event: React.FormEvent) => {
event.preventDefault(); setBusy(true); setError('')
try {
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules${draft.id ? `/${encodeURIComponent(draft.id)}` : ''}`, {
method: draft.id ? 'PUT' : 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name:draft.name,flagKey:draft.flagKey.toLowerCase(),minimumAnchorMatches:draft.minimumAnchorMatches,
enabled:draft.enabled,anchors:draft.anchors.map(anchor => ({ phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) }),
})
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || 'Could not save evidence rule') }
await load(); setDraft(emptyMatchRule())
} catch (error) { setError(error instanceof Error ? error.message : 'Could not save evidence rule') } finally { setBusy(false) }
}
const remove = async (rule: EvidenceMatchRuleDefinition) => {
if (!window.confirm(`Delete evidence match rule “${rule.name}”?`)) return
setBusy(true); setError('')
try {
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules/${encodeURIComponent(rule.id)}`, { method:'DELETE' })
if (!response.ok) throw new Error('Could not delete evidence rule')
await load(); if (draft.id === rule.id) setDraft(emptyMatchRule())
} catch (error) { setError(error instanceof Error ? error.message : 'Could not delete evidence rule') } finally { setBusy(false) }
}
const validAnchors = draft.anchors.filter(anchor => anchor.phrase.trim().length >= 12)
const canSave = draft.name.trim() && /^[a-z][a-z0-9_.-]{0,63}$/.test(draft.flagKey) && validAnchors.length === draft.anchors.length
&& draft.minimumAnchorMatches >= 1 && draft.minimumAnchorMatches <= draft.anchors.length
return <div className="modal-shade"><section className="window match-rules-editor">
<header><Search size={16}/><b>Evidence text matching</b><span/><button type="button" aria-label="Close evidence matching" onClick={onClose}><X size={14}/></button></header>
<div className="match-rules-body"><small>OCR / FUZZY PASSAGE RULES</small>
<p>When OCR from a player-uploaded source matches enough distinctive passages, the configured flag is awarded. Matching ignores case, punctuation, accents, and ordinary OCR noise.</p>
<div className="match-rule-layout"><div className="match-rule-list">
{rules.length === 0 && <div className="flag-empty">NO AUTOMATIC EVIDENCE RULES</div>}
{rules.map(rule => <div className={`match-rule-row ${rule.enabled ? '' : 'disabled'}`} key={rule.id}><div><b>{rule.name}</b><small>{rule.flagKey} · {rule.minimumAnchorMatches}/{rule.anchors.length} ANCHORS</small></div><button type="button" onClick={() => edit(rule)}>EDIT</button><button type="button" disabled={busy} onClick={() => void remove(rule)}><Trash2 size={12}/></button></div>)}
</div>
<form className="match-rule-form" onSubmit={event => void submit(event)}>
<div className="match-rule-form-heading"><b>{draft.id ? 'EDIT RULE' : 'NEW RULE'}</b>{draft.id && <button type="button" onClick={() => setDraft(emptyMatchRule())}>NEW</button>}</div>
<label className="field"><span>RULE NAME</span><input value={draft.name} maxLength={160} onChange={event => setDraft(value => ({ ...value,name:event.target.value }))} placeholder="Contemporary fire report"/></label>
<div className="match-rule-fields"><label className="field"><span>AWARD FLAG</span><input value={draft.flagKey} pattern="[a-z][a-z0-9_.-]{0,63}" onChange={event => setDraft(value => ({ ...value,flagKey:event.target.value.toLowerCase() }))} placeholder="source.fire-report"/></label>
<label className="field"><span>REQUIRED HITS</span><input type="number" min="1" max={draft.anchors.length} value={draft.minimumAnchorMatches} onChange={event => setDraft(value => ({ ...value,minimumAnchorMatches:Number(event.target.value) }))}/></label></div>
<label className="match-rule-enabled"><input type="checkbox" checked={draft.enabled} onChange={event => setDraft(value => ({ ...value,enabled:event.target.checked }))}/> ENABLE THIS RULE</label>
<div className="anchor-heading"><b>REFERENCE PASSAGES</b><button type="button" onClick={() => setDraft(value => ({ ...value,anchors:[...value.anchors,{ id:uid('anchor'),phrase:'',minimumSimilarity:.72 }] }))}><Plus size={12}/> ADD PASSAGE</button></div>
<div className="anchor-list">{draft.anchors.map((anchor,index) => <div className="anchor-row" key={anchor.id}><div><small>ANCHOR {index + 1}</small><textarea value={anchor.phrase} rows={3} placeholder="Paste a distinctive passage of at least 12 characters…" onChange={event => setDraft(value => ({ ...value,anchors:value.anchors.map(item => item.id === anchor.id ? { ...item,phrase:event.target.value } : item) }))}/></div><label><span>SIMILARITY</span><input type="number" min="0.5" max="1" step="0.01" value={anchor.minimumSimilarity} onChange={event => setDraft(value => ({ ...value,anchors:value.anchors.map(item => item.id === anchor.id ? { ...item,minimumSimilarity:Number(event.target.value) } : item) }))}/></label><button type="button" aria-label="Remove reference passage" disabled={draft.anchors.length === 1} onClick={() => setDraft(value => ({ ...value,minimumAnchorMatches:Math.min(value.minimumAnchorMatches,value.anchors.length - 1),anchors:value.anchors.filter(item => item.id !== anchor.id) }))}><Trash2 size={13}/></button></div>)}</div>
{error && <p className="flag-error">{error}</p>}
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CLOSE</button><button className="primary" type="submit" disabled={busy || !canSave}>{busy ? 'SAVING…' : 'SAVE RULE'}</button></div>
</form></div>
</div>
</section></div>
}
function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocument; onClose: () => void; onExtract: (id: string) => void; extracted: (string | undefined)[] }) { function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocument; onClose: () => void; onExtract: (id: string) => void; extracted: (string | undefined)[] }) {
const [pos, setPos] = useState({ x: Math.max(280, window.innerWidth * .34), y: 118 }) const [pos, setPos] = useState({ x: Math.max(280, window.innerWidth * .34), y: 118 })
const [minimized, setMinimized] = useState(false) const [minimized, setMinimized] = useState(false)
+4 -1
View File
@@ -210,6 +210,7 @@ type LegacyCaseState = {
sourceTemplateVersionId?: string sourceTemplateVersionId?: string
editingAllowed?: boolean editingAllowed?: boolean
revision?: number revision?: number
newlyVisibleDocumentIds?: string[]
exhibits?: Exhibit[] exhibits?: Exhibit[]
views?: BoardView[] views?: BoardView[]
documents?: Array<Record<string, unknown>> documents?: Array<Record<string, unknown>>
@@ -240,6 +241,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)], views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit, ...placement(exhibit as unknown as Record<string, unknown>, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })), exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit, ...placement(exhibit as unknown as Record<string, unknown>, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })),
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed, updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [],
} }
} }
@@ -277,5 +279,6 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
fromExhibitId: String(connection.fromExhibitId || connection.fromEvidenceId), toExhibitId: String(connection.toExhibitId || connection.toEvidenceId) } as Connection)) fromExhibitId: String(connection.fromExhibitId || connection.fromEvidenceId), toExhibitId: String(connection.toExhibitId || connection.toEvidenceId) } as Connection))
return { id: state.id, title: state.title, subtitle: state.subtitle, exhibits: [...documents, ...evidence], relations: derivedRelations, connections, return { id: state.id, title: state.title, subtitle: state.subtitle, exhibits: [...documents, ...evidence], relations: derivedRelations, connections,
views: [defaultTimelineView(state.timelineRange)], viewport: state.viewport, brief: state.brief || { body: '', concepts: [] }, revision: Number(state.revision || 0), views: [defaultTimelineView(state.timelineRange)], viewport: state.viewport, brief: state.brief || { body: '', concepts: [] }, revision: Number(state.revision || 0),
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed } updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [] }
} }
+51 -11
View File
@@ -228,16 +228,16 @@ function PhoneDevice({ open, onKey }: { open: boolean; onKey: (k: string) => voi
return <div ref={stageRef} className="phone-canvas" /> return <div ref={stageRef} className="phone-canvas" />
} }
// ---- directory (spike stub) --------------------------------------------------- // ---- directory ----------------------------------------------------------------
// Real version: numbers are the phone node's terminals -> dialogue nodes; `requires` // The number->node directory stays authored config for now; `requires` are the
// are the target node's flag requirements, checked against the playthrough flags. // target node's flag requirements, checked live against the player's ACHIEVEMENTS
// (the real playthrough case-state). Wiring numbers to actual dialogue nodes and
// moving the directory server-side is the next slice.
type Contact = { name: string; requires: string[] } type Contact = { name: string; requires: string[] }
const DIRECTORY: Record<string, Contact> = { const DIRECTORY: Record<string, Contact> = {
'55501': { name: 'Elias Board', requires: [] }, // always enabled -> connects '55501': { name: 'Elias Board', requires: ['elias_number_callable'] }, // dev-grant unlocks -> connects
'55502': { name: 'Voss Antiquities', requires: ['found_voss_number'] }, // enabled below -> connects '55502': { name: 'Voss Antiquities', requires: ['voss_number_known'] }, // not earned -> voicemail
'55503': { name: 'Preservation Soc.', requires: ['society_clearance'] }, // not enabled -> voicemail
} }
const FLAGS = new Set<string>(['found_voss_number']) // toggle to demo connect vs. voicemail
type Mode = 'home' | 'dial' | 'calling' | 'unknown' | 'voicemail' | 'connected' type Mode = 'home' | 'dial' | 'calling' | 'unknown' | 'voicemail' | 'connected'
@@ -270,6 +270,9 @@ export function PhonePreview() {
const [mode, setMode] = useState<Mode>('home') const [mode, setMode] = useState<Mode>('home')
const [dialed, setDialed] = useState('') const [dialed, setDialed] = useState('')
const [callee, setCallee] = useState('') const [callee, setCallee] = useState('')
const [playthroughId, setPlaythroughId] = useState<string | null>(null)
const [achieved, setAchieved] = useState<Set<string>>(new Set())
const [called, setCalled] = useState<Set<string>>(new Set())
const callTimer = useRef<number | undefined>(undefined) const callTimer = useRef<number | undefined>(undefined)
useEffect(() => { useEffect(() => {
@@ -277,6 +280,38 @@ export function PhonePreview() {
setScreenOn(false); setMode('home'); setDialed('') setScreenOn(false); setMode('home'); setDialed('')
}, [open]) }, [open])
const refreshAchievements = async (id: string) => {
const res = await fetch(`/api/playthroughs/${id}/achievements`)
if (res.ok) setAchieved(new Set(await res.json() as string[]))
}
// Attach to the player's live playthrough (or create one) and load its case-state.
useEffect(() => {
let cancelled = false
;(async () => {
let id: string | null = null
const cur = await fetch('/api/playthroughs/current')
if (cur.ok && cur.status !== 204) id = (await cur.json())?.playthrough?.id ?? null
if (!id) {
const made = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: 'glass-harbor' }) })
if (made.ok) id = (await made.json())?.playthrough?.id ?? null
}
if (cancelled || !id) return
setPlaythroughId(id)
await refreshAchievements(id)
})()
return () => { cancelled = true }
}, [])
const connectable = (num: string) => { const c = DIRECTORY[num]; return c ? c.requires.every(f => achieved.has(f)) : false }
// Glow the handset when an enabled, not-yet-called number is waiting.
const glow = Object.keys(DIRECTORY).some(num => connectable(num) && !called.has(num))
const grantElias = async () => {
if (!playthroughId) return
await fetch(`/api/playthroughs/${playthroughId}/achievements`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ flagKey: 'elias_number_callable' }) })
await refreshAchievements(playthroughId)
}
const resolveCall = (num: string) => { const resolveCall = (num: string) => {
sfx.ring() sfx.ring()
setMode('calling') setMode('calling')
@@ -285,8 +320,8 @@ export function PhonePreview() {
callTimer.current = window.setTimeout(() => { callTimer.current = window.setTimeout(() => {
if (!contact) { sfx.unobtainable(); setMode('unknown'); return } if (!contact) { sfx.unobtainable(); setMode('unknown'); return }
setCallee(contact.name) setCallee(contact.name)
const enabled = contact.requires.every(f => FLAGS.has(f)) setCalled(prev => new Set(prev).add(num))
if (enabled) setMode('connected') if (connectable(num)) setMode('connected')
else { sfx.voicemail(); setMode('voicemail') } else { sfx.voicemail(); setMode('voicemail') }
}, 950) }, 950)
} }
@@ -319,7 +354,12 @@ export function PhonePreview() {
<PhoneDevice open={open} onKey={press} /> <PhoneDevice open={open} onKey={press} />
<PhoneScreen visible={screenOn} mode={mode} dialed={dialed} callee={callee} /> <PhoneScreen visible={screenOn} mode={mode} dialed={dialed} callee={callee} />
</div> </div>
<button className="phone-open-btn" onClick={() => setOpen(o => !o)}>{open ? 'CLOSE' : 'OPEN'}</button> <button className={`phone-open-btn${glow && !open ? ' glow' : ''}`} onClick={() => setOpen(o => !o)}>{open ? 'CLOSE' : 'OPEN'}</button>
<p className="phone-hint">spike dial <code>55501</code> connects · <code>55503</code> voicemail · anything else unobtainable</p> <div className="phone-dev">
<button className="phone-dev-btn" disabled={!playthroughId || achieved.has('elias_number_callable')} onClick={grantElias}>
{achieved.has('elias_number_callable') ? '✓ elias_number_callable' : '▸ grant elias_number_callable'}
</button>
<p className="phone-hint">dial <code>55501</code> Elias (voicemail connect once granted) · <code>55502</code> Voss (voicemail) · else unobtainable</p>
</div>
</div> </div>
} }
+22 -2
View File
@@ -110,7 +110,10 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
@keyframes document-locator-target { from { outline-color: #cda936; filter: drop-shadow(0 0 5px #f3d75d66) brightness(1.03); } to { outline-color: #fff19a; filter: drop-shadow(0 0 14px #ffe66cbb) brightness(1.12); } } @keyframes document-locator-target { from { outline-color: #cda936; filter: drop-shadow(0 0 5px #f3d75d66) brightness(1.03); } to { outline-color: #fff19a; filter: drop-shadow(0 0 14px #ffe66cbb) brightness(1.12); } }
.evidence-card.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; } .evidence-card.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; }
.evidence-card.thread-target:hover, .source-file-widget.thread-target:hover { outline: 2px dashed #b8443e; outline-offset: 6px; } .evidence-card.thread-target:hover, .source-file-widget.thread-target:hover { outline: 2px dashed #b8443e; outline-offset: 6px; }
.evidence-card.arriving { z-index: 6; animation: exhibit-arrival 1.15s cubic-bezier(.18,.85,.22,1) both; } .evidence-card.arriving, .source-file-widget.arriving { z-index: 6; animation: exhibit-arrival 1.15s cubic-bezier(.18,.85,.22,1) both; }
.source-file-widget.arriving::before { content: 'NEW EVIDENCE'; position: absolute; z-index: 4; top: -19px; right: -8px; padding: 4px 6px; border: 1px solid #f0c16f; background: #9a3c2e; color: #fff4d6; box-shadow: 2px 3px #02090799; font: 600 7px IBM Plex Mono; letter-spacing: .08em; }
.doc-row.arriving { animation: document-row-arrival 1.15s ease both; }
@keyframes document-row-arrival { 0% { background: #a05328; box-shadow: inset 5px 0 #ffd48a; } 100% { background: transparent; box-shadow: inset 0 0 transparent; } }
@keyframes exhibit-arrival { 0% { opacity: 0; scale: .72; translate: 0 -24px; filter: brightness(1.7); box-shadow: 0 0 0 0 #eda85b00; } 45% { opacity: 1; scale: 1.035; translate: 0 2px; box-shadow: 0 0 0 12px #eda85b55, 7px 9px 0 #020b0980; } 100% { opacity: 1; scale: 1; translate: 0 0; filter: brightness(1); box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; } } @keyframes exhibit-arrival { 0% { opacity: 0; scale: .72; translate: 0 -24px; filter: brightness(1.7); box-shadow: 0 0 0 0 #eda85b00; } 45% { opacity: 1; scale: 1.035; translate: 0 2px; box-shadow: 0 0 0 12px #eda85b55, 7px 9px 0 #020b0980; } 100% { opacity: 1; scale: 1; translate: 0 0; filter: brightness(1); box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; } }
.evidence-card header { border-bottom: 1px solid #989e94; display: flex; justify-content: space-between; padding-bottom: 6px; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #5d6763; } .evidence-card header { border-bottom: 1px solid #989e94; display: flex; justify-content: space-between; padding-bottom: 6px; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #5d6763; }
.evidence-card h3 { font: 600 10px IBM Plex Mono; letter-spacing: .09em; margin: 12px 0 6px; color: #9a5d2e; } .evidence-card h3 { font: 600 10px IBM Plex Mono; letter-spacing: .09em; margin: 12px 0 6px; color: #9a5d2e; }
@@ -272,6 +275,16 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.metadata-row { display: grid; grid-template-columns: 150px 1fr 28px; gap: 7px; padding: 7px; border-bottom: 1px solid #a1a69f; } .metadata-row { display: grid; grid-template-columns: 150px 1fr 28px; gap: 7px; padding: 7px; border-bottom: 1px solid #a1a69f; }
.metadata-row input { padding: 6px; font-size: 9px; } .metadata-row input { padding: 6px; font-size: 9px; }
.metadata-row button { display: grid; place-items: center; border: 0; background: #b8bcb5; color: #6c3a2c; cursor: pointer; } .metadata-row button { display: grid; place-items: center; border: 0; background: #b8bcb5; color: #6c3a2c; cursor: pointer; }
.gate-field { margin: 18px 0; padding: 12px; border: 1px dashed #9b6638; background: #d2c8af; }.gate-field small { color: #6f5840; font: 8px/1.4 IBM Plex Mono; }
.flags-editor { width: min(560px, 90vw); }.flags-editor-body { padding: 24px 27px 26px; }.flags-editor-body > small { color: #805027; font: 600 8px IBM Plex Mono; letter-spacing: .16em; }.flags-editor-body > p { margin: 12px 0 18px; font: 12px/1.5 Special Elite; }
.flag-list { max-height: 290px; overflow: auto; border: 1px solid #8b938d; background: #d3d4cc; }.flag-empty { padding: 28px 16px; text-align: center; color: #6d7771; font: 8px IBM Plex Mono; }
.flag-row { min-height: 55px; padding: 8px 10px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #a0a69f; }.flag-row.earned { background: #d9dfce; box-shadow: inset 4px 0 #3f755f; }.flag-row > div { flex: 1; min-width: 0; display: grid; gap: 4px; }.flag-row b { overflow: hidden; text-overflow: ellipsis; color: #273d36; font: 600 10px IBM Plex Mono; }.flag-row small { color: #737d77; font: 7px IBM Plex Mono; }.flag-row button, .flag-add button { border: 1px outset #89938d; background: #c6cbc4; color: #30463f; padding: 7px 9px; cursor: pointer; font: 8px IBM Plex Mono; }.flag-row.earned button { color: #783f2e; }
.flag-add { margin-top: 13px; display: grid; grid-template-columns: 1fr auto; gap: 7px; }.flag-add input { min-width: 0; border: 1px solid #7d8780; background: #e8e5d8; padding: 8px 9px; font: 10px IBM Plex Mono; }.flag-add button { background: #244c41; color: white; }.flag-row button:disabled, .flag-add button:disabled { opacity: .5; cursor: default; }.flags-editor-body .flag-error { margin: 10px 0 0; color: #8a342e; font: 8px IBM Plex Mono; }
.match-rules-editor { width: min(1000px, 94vw); max-height: min(820px, 92vh); }
.match-rules-body { padding: 22px 25px 25px; overflow: auto; }.match-rules-body > small { color: #805027; font: 600 8px IBM Plex Mono; letter-spacing: .16em; }.match-rules-body > p { max-width: 760px; margin: 10px 0 17px; font: 11px/1.5 Special Elite; }
.match-rule-layout { display: grid; grid-template-columns: minmax(250px, .75fr) minmax(390px, 1.25fr); gap: 13px; align-items: start; }.match-rule-list { max-height: 560px; overflow: auto; border: 1px solid #8b938d; background: #d3d4cc; }
.match-rule-row { min-height: 58px; display: grid; grid-template-columns: minmax(0, 1fr) auto 28px; align-items: center; gap: 6px; padding: 7px; border-bottom: 1px solid #a0a69f; }.match-rule-row.disabled { opacity: .55; }.match-rule-row > div { min-width: 0; display: grid; gap: 4px; }.match-rule-row b, .match-rule-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.match-rule-row b { color: #273d36; font: 600 9px IBM Plex Mono; }.match-rule-row small { color: #737d77; font: 7px IBM Plex Mono; }.match-rule-row button, .match-rule-form button { min-height: 27px; border: 1px outset #89938d; background: #c6cbc4; color: #30463f; cursor: pointer; font: 8px IBM Plex Mono; }.match-rule-row button:last-child { display: grid; place-items: center; color: #783f2e; }
.match-rule-form { padding: 13px; border: 1px solid #8b938d; background: #cecfc7; }.match-rule-form-heading, .anchor-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 11px; color: #44504c; font: 8px IBM Plex Mono; letter-spacing: .08em; }.match-rule-form-heading button, .anchor-heading button { display: flex; align-items: center; gap: 4px; padding: 5px 8px; }.match-rule-fields { display: grid; grid-template-columns: minmax(0, 1fr) 110px; gap: 9px; }.match-rule-enabled { display: flex; align-items: center; gap: 7px; margin: 10px 0 15px; color: #59645e; font: 8px IBM Plex Mono; }.anchor-heading { margin: 0; padding: 8px 0; border-bottom: 2px solid #59625d; }.anchor-list { max-height: 295px; overflow: auto; border: 1px solid #929991; border-top: 0; background: #d7d8d0; }.anchor-row { display: grid; grid-template-columns: minmax(0, 1fr) 88px 28px; align-items: end; gap: 7px; padding: 8px; border-bottom: 1px solid #a1a69f; }.anchor-row > div, .anchor-row label { display: grid; gap: 4px; }.anchor-row small, .anchor-row label span { color: #6b756f; font: 7px IBM Plex Mono; }.anchor-row textarea { min-width: 0; resize: vertical; padding: 7px; background: #eeeadd; font: 9px/1.4 IBM Plex Mono; }.anchor-row input { min-width: 0; padding: 7px 4px; font: 8px IBM Plex Mono; }.anchor-row > button { display: grid; place-items: center; color: #783f2e; }.match-rule-form .folder-editor-actions { margin-top: 12px; }.match-rule-form .folder-editor-actions button { padding: 8px 11px; }.match-rule-form .folder-editor-actions button:disabled, .match-rule-row button:disabled, .anchor-row > button:disabled { opacity: .45; cursor: default; }.match-rule-form .flag-error { margin: 9px 0 0; color: #8a342e; font: 8px IBM Plex Mono; }
.boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; } .boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; }
.empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; } .empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; }
@media (max-width: 900px) { @media (max-width: 900px) {
@@ -298,6 +311,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.brief-panel > header { position: sticky; z-index: 2; top: 0; min-height: 48px; padding-left: max(13px, env(safe-area-inset-left)); padding-right: max(7px, env(safe-area-inset-right)); } .brief-panel > header { position: sticky; z-index: 2; top: 0; min-height: 48px; padding-left: max(13px, env(safe-area-inset-left)); padding-right: max(7px, env(safe-area-inset-right)); }
.brief-panel.minimized { inset: 82px 8px auto; width: auto; height: 48px; max-height: 48px; border: 2px solid #d8dbd4; box-shadow: 5px 6px 0 #020a08; } .brief-panel.minimized { inset: 82px 8px auto; width: auto; height: 48px; max-height: 48px; border: 2px solid #d8dbd4; box-shadow: 5px 6px 0 #020a08; }
.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; } .folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; }
.match-rules-editor { width: 100vw; height: 100dvh; max-height: none; border: 0; }.match-rules-body { padding: 16px; }.match-rule-layout { grid-template-columns: 1fr; }.match-rule-list { max-height: 180px; }.match-rule-fields { grid-template-columns: 1fr 100px; }
.board-actions button { width: 38px; padding: 0; justify-content: center; gap: 0; font-size: 0; } .board-actions button { width: 38px; padding: 0; justify-content: center; gap: 0; font-size: 0; }
.board-actions > b { display: none; } .board-actions > b { display: none; }
.board-actions > span { margin: 0 2px; } .board-actions > span { margin: 0 2px; }
@@ -307,7 +321,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.board-actions button { flex: 0 0 36px; width: 36px; justify-content: center; } .board-actions button { flex: 0 0 36px; width: 36px; justify-content: center; }
.board-actions > span { flex: 0 0 1px; width: 30px; height: 1px; margin: 3px 0; } .board-actions > span { flex: 0 0 1px; width: 30px; height: 1px; margin: 3px 0; }
} }
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } } @media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .source-file-widget.arriving, .doc-row.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
/* Narrative layer: splash + NPC dialogue */ /* Narrative layer: splash + NPC dialogue */
.splash { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle at 50% 40%, #123029 0, #071916 68%); color: #9bb0a9; } .splash { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle at 50% 40%, #123029 0, #071916 68%); color: #9bb0a9; }
@@ -628,3 +642,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.phone-open-btn:hover { border-color: #cdea6a; } .phone-open-btn:hover { border-color: #cdea6a; }
.phone-hint { color: #5f7b73; font-family: ui-monospace, monospace; font-size: 11px; margin: 0; } .phone-hint { color: #5f7b73; font-family: ui-monospace, monospace; font-size: 11px; margin: 0; }
.phone-hint code { color: #8fae4a; } .phone-hint code { color: #8fae4a; }
.phone-open-btn.glow { border-color: #cdea6a; color: #eafaa0; box-shadow: 0 0 14px #9fd02088, inset 0 0 8px #9fd02044; animation: phone-glow 1.4s ease-in-out infinite; }
@keyframes phone-glow { 50% { box-shadow: 0 0 22px #cdea6acc, inset 0 0 12px #9fd02066; } }
.phone-dev { display: flex; flex-direction: column; align-items: center; gap: 8px; }
.phone-dev-btn { border: 1px dashed #6f8f85; background: #0a1a16cc; color: #9fd020; font-family: ui-monospace, monospace; font-size: 11px; letter-spacing: 1px; padding: 5px 12px; cursor: pointer; }
.phone-dev-btn:disabled { color: #5f7b73; border-style: solid; cursor: default; }
.phone-dev-btn:not(:disabled):hover { border-color: #cdea6a; color: #cdea6a; }
+38
View File
@@ -34,6 +34,8 @@ export interface FolderExhibit extends ExhibitBase {
export interface DocumentExhibit extends ExhibitBase { export interface DocumentExhibit extends ExhibitBase {
type: 'document' type: 'document'
/** Author-mode reveal requirements. Omitted from play-mode payloads. */
requiredFlags?: string[]
publishedAt?: string publishedAt?: string
capturedAt?: string capturedAt?: string
sourceUri?: string sourceUri?: string
@@ -47,6 +49,17 @@ export interface DocumentExhibit extends ExhibitBase {
metadata: Record<string, string> metadata: Record<string, string>
} }
export interface DocumentUploadAnalysis {
extractionStatus: 'succeeded' | 'unsupported' | 'failed'
matchedFlags: string[]
awardedFlags: string[]
}
export interface UploadedCaseDocument extends DocumentExhibit {
/** Transient upload response data; it is not part of persisted exhibit state. */
analysis: DocumentUploadAnalysis
}
export interface NoteExhibit extends ExhibitBase { export interface NoteExhibit extends ExhibitBase {
type: 'note' type: 'note'
content: string content: string
@@ -152,6 +165,31 @@ export interface CaseState {
levelStatus?: string levelStatus?: string
sourceTemplateVersionId?: string sourceTemplateVersionId?: string
editingAllowed?: boolean editingAllowed?: boolean
/** Visible documents that have not previously played their arrival flourish. */
newlyVisibleDocumentIds?: string[]
}
export interface LevelFlag {
key: string
earnedAt?: string
gatedDocumentCount: number
}
export interface EvidenceMatchAnchorDefinition {
id: string
phrase: string
minimumSimilarity: number
sortOrder: number
}
export interface EvidenceMatchRuleDefinition {
id: string
name: string
flagKey: string
matcherVersion: 'char_trigram_v1'
minimumAnchorMatches: number
enabled: boolean
anchors: EvidenceMatchAnchorDefinition[]
} }
export function isDocumentExhibit(exhibit: Exhibit): exhibit is DocumentExhibit { return exhibit.type === 'document' } export function isDocumentExhibit(exhibit: Exhibit): exhibit is DocumentExhibit { return exhibit.type === 'document' }