diff --git a/Dockerfile b/Dockerfile index 5cdf7c2..e85f3b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,9 @@ RUN npm run build FROM node:22-bookworm-slim ENV NODE_ENV=production PORT=8787 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 ./ RUN npm ci --omit=dev COPY --from=build /app/dist ./dist diff --git a/README.md b/README.md index f589d99..e928128 100644 --- a/README.md +++ b/README.md @@ -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. 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 @@ -74,7 +75,15 @@ The API surface is: - `PUT /api/levels/:id` - `POST /api/levels/:id/reset` - `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/session` (verified session and admin capability summary) - `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. -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. @@ -126,4 +139,4 @@ The deploy script builds and syncs the application, reads production database cr ## 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. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index cc057cc..ddb6a1a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -30,6 +30,11 @@ services: JWT_SECRET: ${JWT_SECRET:-osint-local-dev-secret} LEVEL_EDITING_ENABLED: "true" 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_REGION: us-east-1 S3_ACCESS_KEY: gupi diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 75b2ce5..4ac78e9 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -13,6 +13,11 @@ services: JWT_SECRET: ${JWT_SECRET} LEVEL_EDITING_ENABLED: ${LEVEL_EDITING_ENABLED:-false} 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: - "8787" networks: diff --git a/docs/demo-scope.md b/docs/demo-scope.md new file mode 100644 index 0000000..8f1464e --- /dev/null +++ b/docs/demo-scope.md @@ -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. diff --git a/docs/persistent-boards.md b/docs/persistent-boards.md index 6d9013e..d4fec82 100644 --- a/docs/persistent-boards.md +++ b/docs/persistent-boards.md @@ -1,6 +1,10 @@ # 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 ([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** diff --git a/lysenetter.m4a b/lysenetter.m4a new file mode 100644 index 0000000..48556dc Binary files /dev/null and b/lysenetter.m4a differ diff --git a/lysenetter.mp3 b/lysenetter.mp3 new file mode 100644 index 0000000..92321b8 Binary files /dev/null and b/lysenetter.mp3 differ diff --git a/migrations/025_level_document_flags.sql b/migrations/025_level_document_flags.sql new file mode 100644 index 0000000..46c1f2b --- /dev/null +++ b/migrations/025_level_document_flags.sql @@ -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.'; diff --git a/migrations/026_achievements.sql b/migrations/026_achievements.sql new file mode 100644 index 0000000..0ed5c0e --- /dev/null +++ b/migrations/026_achievements.sql @@ -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.'; diff --git a/migrations/027_evidence_text_matching.sql b/migrations/027_evidence_text_matching.sql new file mode 100644 index 0000000..9f13db5 --- /dev/null +++ b/migrations/027_evidence_text_matching.sql @@ -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.'; diff --git a/mysteries/glass-harbor/mystery.json b/mysteries/glass-harbor/mystery.json index 1ce5a8a..4c5f065 100644 --- a/mysteries/glass-harbor/mystery.json +++ b/mysteries/glass-harbor/mystery.json @@ -112,6 +112,7 @@ "key": "auction-catalogue", "title": "Meridian Maritime Auction · Lot 117", "fileType": "article", + "requiredFlags": ["lead.auction_catalogue"], "publishedAt": "1987-10-24T12:00:00.000Z", "body": [ "MERIDIAN MARITIME AUCTION — ADVANCE CATALOGUE · 24 OCTOBER 1987", diff --git a/scripts/importMysteryTemplate.ts b/scripts/importMysteryTemplate.ts index 3982c49..c934942 100644 --- a/scripts/importMysteryTemplate.ts +++ b/scripts/importMysteryTemplate.ts @@ -12,6 +12,7 @@ type MysteryDocument = { body?: string[] metadata?: Record asset?: string + requiredFlags?: string[] } type MysteryGraph = { 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, body: source.body || [], regions: [], assetId: uploaded?.assetId, fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize, - fileType: source.fileType, metadata: source.metadata || {}, + fileType: source.fileType, metadata: source.metadata || {}, requiredFlags: source.requiredFlags || [], }) } diff --git a/server/api.integration.test.ts b/server/api.integration.test.ts index 4875933..4201232 100644 --- a/server/api.integration.test.ts +++ b/server/api.integration.test.ts @@ -84,11 +84,12 @@ suite('normalized level persistence API', () => { 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 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 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 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 = [ { 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 }, @@ -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.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.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() - upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt') - const uploadResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/documents?edit=1`, { method: 'POST', body: upload }) + upload.append('file', new Blob(['OSINT smoke evidence from the archlve'], { type: 'text/plain' }), 'smoke-evidence.txt') + 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) - const uploaded = await uploadResponse.json() as DocumentExhibit - expect(uploaded).toMatchObject({ type: 'document', fileName: 'smoke-evidence.txt', fileType: 'text' }) - expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence') + 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', x: 812, y: 438, + 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]) 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' }) }) 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.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 }) 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' })] }), + ]) }) }) diff --git a/server/boardClone.ts b/server/boardClone.ts index 8271213..f4d5b83 100644 --- a/server/boardClone.ts +++ b/server/boardClone.ts @@ -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.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.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.exhibits WHERE board_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)`, [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 }>( `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( diff --git a/server/evidenceMatching.test.ts b/server/evidenceMatching.test.ts new file mode 100644 index 0000000..440792f --- /dev/null +++ b/server/evidenceMatching.test.ts @@ -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) + }) +}) diff --git a/server/evidenceMatching.ts b/server/evidenceMatching.ts new file mode 100644 index 0000000..841ea24 --- /dev/null +++ b/server/evidenceMatching.ts @@ -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() + 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, + } + }) +} diff --git a/server/index.ts b/server/index.ts index cadcced..b326d4d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -11,6 +11,7 @@ import type { CaseState } from '../src/types.js' import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolveUserId } from './auth.js' import { createLevelRepository } from './levelRepository.js' import { createNarrativeRepository } from './narrativeRepository.js' +import { createTextExtractorFromEnv } from './ocr.js' import { createStoryGraphRepository, type StoryNodeType } from './storyGraphRepository.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 objectStorage = createObjectStorageFromEnv() await objectStorage.initialize() +const textExtractor = createTextExtractorFromEnv() const levels = createLevelRepository(pool, editingEnabled, objectStorage) const narrative = createNarrativeRepository(pool, objectStorage) const storyGraph = createStoryGraphRepository(pool) @@ -49,7 +51,7 @@ const upload = multer({ }) 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' }) } }) 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) } 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 { - 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' }) - 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' }) } 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) => { try { 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 } 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) => { 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) } }) +// 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) => { 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 }) diff --git a/server/levelRepository.ts b/server/levelRepository.ts index 4d74746..7c29819 100644 --- a/server/levelRepository.ts +++ b/server/levelRepository.ts @@ -1,15 +1,25 @@ import { createHash, randomUUID } from 'node:crypto' import { Readable } from 'node:stream' 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 { 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 { TextExtractionResult } from './ocr.js' 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 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 EvidenceMatchRuleInput = { + name: string + flagKey: string + minimumAnchorMatches?: number + enabled?: boolean + anchors: { phrase: string; minimumSimilarity?: number }[] +} export interface LevelRepository { listLevels(): Promise @@ -21,7 +31,14 @@ export interface LevelRepository { saveLevel(state: CaseState, authorMode: boolean): Promise resetLevel(levelId: string): Promise getAsset(assetId: string): Promise - uploadDocument(levelId: string, file: UploadedDocument): Promise + uploadDocument(levelId: string, file: UploadedDocument, extraction: TextExtractionResult, placement?: { x: number; y: number }): Promise + listFlags(levelId: string): Promise + setFlag(levelId: string, key: string, earned: boolean): Promise + acknowledgeRevealedDocuments(levelId: string, documentIds: string[]): Promise + listEvidenceMatchRules(levelId: string): Promise + createEvidenceMatchRule(levelId: string, input: EvidenceMatchRuleInput): Promise + updateEvidenceMatchRule(levelId: string, ruleId: string, input: EvidenceMatchRuleInput): Promise + deleteEvidenceMatchRule(levelId: string, ruleId: string): Promise } 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 flagPattern = /^[a-z][a-z0-9_.-]{0,63}$/ function requireUuid(value: string, label: string) { if (!uuidPattern.test(value)) throw new Error(`${label} must be a UUID`) return value @@ -48,6 +66,26 @@ function timestamp(value: string | undefined) { const date = new Date(value) 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 { const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', '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]) } + async function evidenceMatchRules(client: Pool | PoolClient, boardId: string, includeDisabled = false): Promise { + 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() + 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 { const level = await findLevel(pool, slug) if (!level) return null 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(`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.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, 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]), + 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() @@ -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 }) const aliases = new Map() for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias]) + const requirements = new Map() + for (const row of requirementsResult.rows) requirements.set(row.document_exhibit_id, [...(requirements.get(row.document_exhibit_id) || []), row.flag_key]) const relations: ExhibitRelation[] = [ ...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 })), @@ -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 type = row.document_type_id || 'file' 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, 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) || {} } }) 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 } 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() }) @@ -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 })) 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 })) - 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, label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style, 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), brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode, 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 { @@ -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, 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]) + 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( '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( @@ -411,13 +496,19 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec return templateSummary(input.slug) }, 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() try { await client.query('BEGIN') const level = await findLevel(client, state.id, true) if (!level) throw new Error('Level not found') - await replaceBoard(client, level, state) + await replaceBoard(client, level, persistedState) await client.query('COMMIT') } 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) 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() try { await client.query('BEGIN') @@ -474,17 +565,149 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec 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 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) - 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)`, [exhibitId, fileType, assetId, file.originalname]) 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.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id]) 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, - fileType,metadata:{},body:[],regions:[],assetId,fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size } + 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: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() } }, + 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 + }, } } diff --git a/server/levelVisibility.test.ts b/server/levelVisibility.test.ts new file mode 100644 index 0000000..8232fd0 --- /dev/null +++ b/server/levelVisibility.test.ts @@ -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) + }) +}) diff --git a/server/levelVisibility.ts b/server/levelVisibility.ts new file mode 100644 index 0000000..eaaadc2 --- /dev/null +++ b/server/levelVisibility.ts @@ -0,0 +1,49 @@ +import type { CaseState, DocumentExhibit, ExhibitRelation, Connection } from '../src/types.js' + +function requirementsMet(document: DocumentExhibit, earnedFlags: ReadonlySet) { + return (document.requiredFlags || []).every(flag => earnedFlags.has(flag)) +} + +export function filterLevelVisibility(full: CaseState, earnedFlags: Iterable, seenDocumentIds: Iterable, 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(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: [], + } +} diff --git a/server/migrations.integration.test.ts b/server/migrations.integration.test.ts index 2b548bc..350b88c 100644 --- a/server/migrations.integration.test.ts +++ b/server/migrations.integration.test.ts @@ -1,4 +1,5 @@ import path from 'node:path' +import fs from 'node:fs/promises' import { fileURLToPath } from 'node:url' import pg from 'pg' import { afterAll, beforeAll, describe, expect, it } from 'vitest' @@ -31,9 +32,10 @@ suite('PostgreSQL migrations', () => { it('applies every migration transactionally and is idempotent', async () => { 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[] = [] 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 }) await client.connect() @@ -46,10 +48,13 @@ suite('PostgreSQL migrations', () => { 'board_views', 'timeline_views', 'mysteries', 'npcs', 'npc_poses', 'playthroughs', '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'])) 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'`) 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'`) @@ -58,7 +63,7 @@ suite('PostgreSQL migrations', () => { const secondRun: string[] = [] 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) }) }) diff --git a/server/narrativeRepository.ts b/server/narrativeRepository.ts index a110509..b8d8bdc 100644 --- a/server/narrativeRepository.ts +++ b/server/narrativeRepository.ts @@ -47,6 +47,8 @@ export interface NarrativeRepository { createPlaythrough(userId: string, mysterySlug?: string): Promise getCurrentPlaythrough(userId: string): Promise advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }> + listAchievements(playthroughId: string): Promise + awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }> listMysteries(): Promise deleteMystery(id: string): Promise uploadAsset(file: UploadedFile): Promise @@ -231,6 +233,24 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora 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) { const client = await pool.connect() try { diff --git a/server/ocr.ts b/server/ocr.ts new file mode 100644 index 0000000..83c8536 --- /dev/null +++ b/server/ocr.ts @@ -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 +} + +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((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' } + } + }, + } +} diff --git a/src/App.tsx b/src/App.tsx index 8923329..eb3f1bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,7 @@ 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 type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView } from './types' -import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState, type PlaythroughSummary, type RuntimeNode } from './narrative' +import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types' 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 { 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 })) 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 documentSearchText(document: CaseDocument) { return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.fileName, document.mimeType, @@ -63,11 +66,9 @@ export function App() { const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState(null) const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState(null) const [threadDraft, setThreadDraft] = useState(null) - const [splashOpen, setSplashOpen] = useState(false) - const [splashBusy, setSplashBusy] = useState(false) - const [playthrough, setPlaythrough] = useState(null) - const [runtimeNode, setRuntimeNode] = useState(null) - const [muted, setMuted] = useState(audio.isMuted()) + const [flagsOpen, setFlagsOpen] = useState(false) + const [matchRulesOpen, setMatchRulesOpen] = useState(false) + const [arrivingExhibitIds, setArrivingExhibitIds] = useState([]) const saveTimer = useRef(undefined) const boardRef = useRef(null) const fileInputRef = useRef(null) @@ -80,6 +81,13 @@ export function App() { if (!response.ok) throw new Error('Level unavailable') const data = normalizeCase(await response.json()) 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 }, []) @@ -95,18 +103,11 @@ export function App() { if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true) 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 () => { if (deepLinkLevel) { await openLevel(deepLinkLevel); return } - const current = await fetch('/api/playthroughs/current') - if (current.status === 204) { setSplashOpen(true); setStatus('AWAITING PRINCIPAL INVESTIGATOR'); return } - if (!current.ok) throw new Error('Playthrough unavailable') - 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') + const levels = await (await fetch('/api/levels')).json() as { id: string }[] + if (!levels[0]?.id) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return } + await openLevel(levels[0].id) } boot().catch(async () => { try { @@ -125,40 +126,6 @@ export function App() { return () => clearInterval(timer) }, [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(() => { if (!adminMenuOpen) return 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) }, [recentlyCreatedConnectionId]) + useEffect(() => { + if (!arrivingExhibitIds.length) return + const timer = window.setTimeout(() => setArrivingExhibitIds([]), 1800) + return () => window.clearTimeout(timer) + }, [arrivingExhibitIds]) + const update = useCallback((fn: (state: CaseState) => CaseState) => { setCaseState(current => { if (!current) return current @@ -371,33 +344,65 @@ export function App() { window.location.assign(`${window.location.pathname}?${params.toString()}`) } - const uploadFiles = async (files: FileList | File[]) => { - if (!caseState || !requestedEditMode || !caseState.editingAllowed) return + const uploadFiles = useCallback(async (files: FileList | File[], source: 'file' | 'clipboard' = 'file') => { + if (!caseState) return const queue = Array.from(files) setUploading(queue.length) 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() form.append('file', file) + form.append('x', String(position.x)) + form.append('y', String(position.y)) 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})`) } - const document: CaseDocument = await response.json() - update(s => ({ ...s, exhibits: [...s.exhibits, document] })) - setStatus(`IMPORTED · ${file.name.toUpperCase()}`) + const uploaded: UploadedCaseDocument = await response.json() + const { analysis, ...document } = uploaded + 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) { setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED') } finally { setUploading(count => count - 1) } } - } + }, [caseState, loadLevelBySlug, requestedEditMode, update]) - const audioToggle = playthrough ? : 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 - if (splashOpen) return setSplashOpen(false)} /> - // Story-graph runtime: cutscene and dialogue nodes play full-screen (no board). - if (runtimeNode?.kind === 'cutscene') return <>{audioToggle} advance()} /> - if (runtimeNode?.kind === 'dialogue') return <>{audioToggle} if (noLevels) return { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} /> if (!caseState) return
GU

GLITCH UNIVERSITY NETWORK TERMINAL

{status}
@@ -421,7 +426,6 @@ export function App() { }) const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline') return
- {audioToggle}
GUOSINT BOARD / {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}
} @@ -620,7 +626,7 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le return
GU
GLITCH UNIVERSITY LEVEL ARCHIVE

No investigations found.

The database is ready, but no authored level exists yet.

{canEdit ? :

Add ?edit=1 and enable level editing on the server to begin authoring.

}
} -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; 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; 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 suppressClick = useRef(false) const touchPoints = useRef(new Map()) @@ -808,14 +814,14 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx {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 })} - {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
!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
{ 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) }} 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) } }}>
{definition.heading(ev, widgetContext)}{String(i + 1).padStart(3, '0')}
})} - {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
!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
{ 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)}>
{definition.label.toUpperCase()}{String((membership?.sortOrder || 0) + 1).padStart(2, '0')}
@@ -1072,15 +1078,18 @@ function EventEditor({ event, exhibits, relations, onClose, onSave }: { event: E } -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 [fileType, setFileType] = useState(document.fileType) 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 submit = (event: React.FormEvent) => { event.preventDefault() 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
Edit source-file metadata
@@ -1091,6 +1100,7 @@ function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onC
+ {canEditGates && }
ADDITIONAL METADATAFREE-FORM KEY / VALUE FIELDS
{metadata.length === 0 &&

NO ADDITIONAL METADATA

}{metadata.map(row =>
setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, key: event.target.value } : candidate))}/> setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, value: event.target.value } : candidate))}/>
)}

This metadata belongs to the source file, not to any folder that contains it.

@@ -1099,6 +1109,114 @@ function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onC } +function LevelFlagsEditor({ levelId, onClose, onChanged }: { levelId: string; onClose: () => void; onChanged: () => void | Promise }) { + const [flags, setFlags] = useState([]) + 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
+
Level flags
+
ACHIEVEMENTS / DOCUMENT REVEALS +

Documents remain server-hidden until every flag assigned in their metadata has been earned.

+
{flags.length === 0 &&
NO FLAGS OR DOCUMENT GATES IN THIS LEVEL
}{flags.map(flag =>
{flag.key}{flag.gatedDocumentCount} GATED DOCUMENT{flag.gatedDocumentCount === 1 ? '' : 'S'}
)}
+
setNewKey(event.target.value.toLowerCase())}/>
+ {error &&

{error}

} +
+
+} + +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([]) + const [draft, setDraft] = useState(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
+
Evidence text matching
+
OCR / FUZZY PASSAGE RULES +

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.

+
+ {rules.length === 0 &&
NO AUTOMATIC EVIDENCE RULES
} + {rules.map(rule =>
{rule.name}{rule.flagKey} · {rule.minimumAnchorMatches}/{rule.anchors.length} ANCHORS
)} +
+
void submit(event)}> +
{draft.id ? 'EDIT RULE' : 'NEW RULE'}{draft.id && }
+ +
+
+ +
REFERENCE PASSAGES
+
{draft.anchors.map((anchor,index) =>
ANCHOR {index + 1}