Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a925d61b7e | ||
|
|
778c6d972f | ||
|
|
5c514562a2 | ||
|
|
cd4b8bf4fa | ||
|
|
34aa23237e | ||
|
|
70c7506f1d | ||
|
|
94ccbfd1b9 |
@@ -1,9 +1,18 @@
|
|||||||
DATABASE_URL=postgres://osint:osint_secret@localhost:5433/osint_dev
|
DATABASE_URL=postgres://osint:osint_secret@localhost:5433/osint_dev
|
||||||
|
|
||||||
|
# Dev ports — give each branch checkout distinct values to run them side by side.
|
||||||
|
# PORT is the Express API; WEB_PORT is the Vite dev server, which proxies /api to PORT.
|
||||||
PORT=8787
|
PORT=8787
|
||||||
|
WEB_PORT=5173
|
||||||
CORS_ORIGIN=http://localhost:5173
|
CORS_ORIGIN=http://localhost:5173
|
||||||
LEVEL_EDITING_ENABLED=true
|
LEVEL_EDITING_ENABLED=true
|
||||||
JWT_SECRET=osint-local-dev-secret
|
JWT_SECRET=osint-local-dev-secret
|
||||||
MAX_DOCUMENT_BYTES=26214400
|
MAX_DOCUMENT_BYTES=26214400
|
||||||
|
OCR_ENABLED=true
|
||||||
|
OCR_LANGUAGES=nor+eng
|
||||||
|
OCR_TIMEOUT_MS=20000
|
||||||
|
MAX_OCR_BYTES=15728640
|
||||||
|
MAX_EXTRACTED_TEXT_CHARACTERS=200000
|
||||||
|
|
||||||
# Game asset storage (MinIO). Start it with: docker compose -f docker-compose.dev.yml up -d minio createbuckets
|
# Game asset storage (MinIO). Start it with: docker compose -f docker-compose.dev.yml up -d minio createbuckets
|
||||||
S3_ENDPOINT=http://localhost:9000
|
S3_ENDPOINT=http://localhost:9000
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ RUN npm run build
|
|||||||
FROM node:22-bookworm-slim
|
FROM node:22-bookworm-slim
|
||||||
ENV NODE_ENV=production PORT=8787
|
ENV NODE_ENV=production PORT=8787
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends tesseract-ocr tesseract-ocr-eng tesseract-ocr-nor \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ A standalone, server-backed proof of concept for the Glitch University investiga
|
|||||||
The accepted normalized domain model and terminology are specified in [`docs/exhibit-data-model.md`](docs/exhibit-data-model.md). PostgreSQL stores **exhibits**; frontend **widgets** visualize exhibit types.
|
The accepted normalized domain model and terminology are specified in [`docs/exhibit-data-model.md`](docs/exhibit-data-model.md). PostgreSQL stores **exhibits**; frontend **widgets** visualize exhibit types.
|
||||||
|
|
||||||
Planned schema and exhibit work is tracked in [`docs/TODO.md`](docs/TODO.md).
|
Planned schema and exhibit work is tracked in [`docs/TODO.md`](docs/TODO.md).
|
||||||
|
The intentionally narrow current demo is defined in [`docs/demo-scope.md`](docs/demo-scope.md): it opens directly on the board and focuses on investigation, flag-gated evidence reveals, and pasted screenshots.
|
||||||
|
|
||||||
## Run locally
|
## Run locally
|
||||||
|
|
||||||
@@ -74,7 +75,15 @@ The API surface is:
|
|||||||
- `PUT /api/levels/:id`
|
- `PUT /api/levels/:id`
|
||||||
- `POST /api/levels/:id/reset`
|
- `POST /api/levels/:id/reset`
|
||||||
- `POST /api/levels/:id/templates` (save a new immutable version; editor only)
|
- `POST /api/levels/:id/templates` (save a new immutable version; editor only)
|
||||||
- `POST /api/levels/:id/documents` (editor only)
|
- `POST /api/levels/:id/documents` (player-uploaded evidence)
|
||||||
|
- `POST /api/levels/:id/reveals/seen`
|
||||||
|
- `GET /api/levels/:id/flags` (admin only)
|
||||||
|
- `PUT /api/levels/:id/flags/:key` (admin only)
|
||||||
|
- `DELETE /api/levels/:id/flags/:key` (admin only)
|
||||||
|
- `GET /api/levels/:id/evidence-match-rules` (admin only)
|
||||||
|
- `POST /api/levels/:id/evidence-match-rules` (editor only)
|
||||||
|
- `PUT /api/levels/:id/evidence-match-rules/:ruleId` (editor only)
|
||||||
|
- `DELETE /api/levels/:id/evidence-match-rules/:ruleId` (editor only)
|
||||||
- `GET /api/assets/:id`
|
- `GET /api/assets/:id`
|
||||||
- `GET /api/session` (verified session and admin capability summary)
|
- `GET /api/session` (verified session and admin capability summary)
|
||||||
- `GET /api/health`
|
- `GET /api/health`
|
||||||
@@ -87,7 +96,11 @@ Set `LEVEL_EDITING_ENABLED=true` and open `/?edit=1` while signed in with a JWT
|
|||||||
|
|
||||||
For the standalone development Compose stack, visit `/api/dev/admin-session?returnTo=/?edit=1` once to receive a local signed admin cookie. This helper does not exist in production.
|
For the standalone development Compose stack, visit `/api/dev/admin-session?returnTo=/?edit=1` once to receive a local signed admin cookie. This helper does not exist in production.
|
||||||
|
|
||||||
In edit mode, files can be dragged from the desktop onto the board or selected with **Import Document**. Images, PDFs, and text files render inside document windows; unknown formats remain downloadable source files. Extracted evidence becomes an editable folder widget. Its editor controls the title, annotation, contained documents, and each source document's publication time. The default upload limit is 25 MB and can be changed with `MAX_DOCUMENT_BYTES`.
|
Files can be dragged from the desktop onto the board or selected with **Add Document**. Pasting a clipboard image creates a persisted image Document, which supports ordinary macOS and Windows screenshot workflows. Images, PDFs, and text files render inside document windows; unknown formats remain downloadable source files. Extracted evidence becomes an editable folder widget. Its editor controls the title, annotation, contained documents, and each source document's publication time. The default upload limit is 25 MB and can be changed with `MAX_DOCUMENT_BYTES`.
|
||||||
|
|
||||||
|
Image uploads are OCRed by the Tesseract executable bundled into the application image; text-file uploads use their text directly. Extracted text is stored against the immutable asset, copied into the Document's searchable body, and evaluated against level-authored fuzzy passage rules. A successful rule awards its configured flag and immediately participates in ordinary document reveals. Rules, anchors, per-anchor scores, and evaluation provenance are normalized PostgreSQL data—no case text is compiled into the engine. OCR is time-limited and failure-tolerant: the source remains on the board even when text extraction fails. `OCR_LANGUAGES`, `OCR_TIMEOUT_MS`, `MAX_OCR_BYTES`, and `MAX_EXTRACTED_TEXT_CHARACTERS` tune the worker; `OCR_ENABLED=false` disables image OCR without disabling uploads. Authors configure passages under **Admin → Evidence Matching** while editing a level.
|
||||||
|
|
||||||
|
In author mode, a Document may be assigned comma-separated reveal flags in its metadata editor. Play-mode level responses omit gated Documents until all requirements are earned. Admins can exercise the demo through **Admin → Level Flags**; newly delivered evidence receives a one-time arrival animation.
|
||||||
|
|
||||||
Production defaults editing to disabled. Set `LEVEL_EDITING_ENABLED=true` in `/opt/gu_common/.env.prod` only when the authoring surface should be available. `JWT_SECRET` is inherited from that shared environment, and authoring endpoints additionally require a verified admin claim.
|
Production defaults editing to disabled. Set `LEVEL_EDITING_ENABLED=true` in `/opt/gu_common/.env.prod` only when the authoring surface should be available. `JWT_SECRET` is inherited from that shared environment, and authoring endpoints additionally require a verified admin claim.
|
||||||
|
|
||||||
@@ -126,4 +139,4 @@ The deploy script builds and syncs the application, reads production database cr
|
|||||||
|
|
||||||
## Deliberate POC boundaries
|
## Deliberate POC boundaries
|
||||||
|
|
||||||
Authentication is supplied by the shared Glitch University account system. There is no OSINT-specific account model, real-world web browsing, OCR, or collaboration yet. The server data model and provenance fields leave room for those later without making them part of the first playability test.
|
Authentication is supplied by the shared Glitch University account system. There is no OSINT-specific account model, real-world web browsing, or collaboration yet. OCR deliberately recognizes only evidence the player brings onto the board; it does not fetch or search the web.
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET:-osint-local-dev-secret}
|
JWT_SECRET: ${JWT_SECRET:-osint-local-dev-secret}
|
||||||
LEVEL_EDITING_ENABLED: "true"
|
LEVEL_EDITING_ENABLED: "true"
|
||||||
MAX_DOCUMENT_BYTES: 26214400
|
MAX_DOCUMENT_BYTES: 26214400
|
||||||
|
OCR_ENABLED: "true"
|
||||||
|
OCR_LANGUAGES: nor+eng
|
||||||
|
OCR_TIMEOUT_MS: 20000
|
||||||
|
MAX_OCR_BYTES: 15728640
|
||||||
|
MAX_EXTRACTED_TEXT_CHARACTERS: 200000
|
||||||
S3_ENDPOINT: http://minio:9000
|
S3_ENDPOINT: http://minio:9000
|
||||||
S3_REGION: us-east-1
|
S3_REGION: us-east-1
|
||||||
S3_ACCESS_KEY: gupi
|
S3_ACCESS_KEY: gupi
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
LEVEL_EDITING_ENABLED: ${LEVEL_EDITING_ENABLED:-false}
|
LEVEL_EDITING_ENABLED: ${LEVEL_EDITING_ENABLED:-false}
|
||||||
MAX_DOCUMENT_BYTES: ${MAX_DOCUMENT_BYTES:-26214400}
|
MAX_DOCUMENT_BYTES: ${MAX_DOCUMENT_BYTES:-26214400}
|
||||||
|
OCR_ENABLED: ${OCR_ENABLED:-true}
|
||||||
|
OCR_LANGUAGES: ${OCR_LANGUAGES:-nor+eng}
|
||||||
|
OCR_TIMEOUT_MS: ${OCR_TIMEOUT_MS:-20000}
|
||||||
|
MAX_OCR_BYTES: ${MAX_OCR_BYTES:-15728640}
|
||||||
|
MAX_EXTRACTED_TEXT_CHARACTERS: ${MAX_EXTRACTED_TEXT_CHARACTERS:-200000}
|
||||||
expose:
|
expose:
|
||||||
- "8787"
|
- "8787"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
# Persistent boards, gated exhibits, and citation codes
|
# Persistent boards, gated exhibits, and citation codes
|
||||||
|
|
||||||
Status: **proposed** (design locked, not implemented). Extends the story flow graph
|
Status: **demo subset implemented**. Migration 025 implements level-local boolean
|
||||||
|
flags, normalized Document requirements, server-side filtering, persisted reveal
|
||||||
|
acknowledgements, and the arrival animation. Board-key reuse, narrative-triggered
|
||||||
|
live reveals, citation groups, and per-user playthrough overlays remain deferred.
|
||||||
|
The broader design extends the story flow graph
|
||||||
([story-graph.md](story-graph.md)) and the narrative layer
|
([story-graph.md](story-graph.md)) and the narrative layer
|
||||||
([narrative-todo.md](narrative-todo.md)); interlocks with the Claim/Case Report
|
([narrative-todo.md](narrative-todo.md)); interlocks with the Claim/Case Report
|
||||||
work in [TODO.md](TODO.md) Milestone 5. Depends on the **flags / case-state**
|
work in [TODO.md](TODO.md) Milestone 5. Depends on the **flags / case-state**
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Routes & how to test (dev)
|
||||||
|
|
||||||
|
The front end picks what to render from the URL (see `src/main.tsx`). Vite dev
|
||||||
|
server runs on `http://localhost:5173`.
|
||||||
|
|
||||||
|
| URL | Renders | For |
|
||||||
|
|---|---|---|
|
||||||
|
| `/` | Splash → campaign (walks the story graph: cutscene → dialogue → hands off to the board) | Narrative / player flow |
|
||||||
|
| `/node/<nodeId>` | **Dev/admin teleport** to a story node; a *level* node lands you on that level's board clone | Jumping around the tree while testing |
|
||||||
|
| `/level/<levelId>` | A level board directly (add `?edit=1` to author) | Board work / editing |
|
||||||
|
| `/admin` | Admin panel (mysteries, NPCs, assets, graph editor) | Authoring |
|
||||||
|
| `/?phone=1` | 3D clamshell phone dialer spike | Phone prototype |
|
||||||
|
|
||||||
|
## Game state (demo)
|
||||||
|
|
||||||
|
A player's whole state is two things on their **playthrough**:
|
||||||
|
- `playthroughs.current_node_id` — *where* they are (which node; a level node also has `current_level_id`, the board clone).
|
||||||
|
- `achievements` — *what* they've earned (`(playthrough_id, flag_key, awarded_by_node_id)`).
|
||||||
|
|
||||||
|
Normal play resolves position via `GET /api/playthroughs/current`. `/node/:id`
|
||||||
|
is just the dev override that *writes* `current_node_id` (the `goto` teleport).
|
||||||
|
|
||||||
|
## Quick test recipes
|
||||||
|
|
||||||
|
**Play the campaign:** open `/` → New Game → click through the intro/briefing → you land on the board.
|
||||||
|
|
||||||
|
**Jump straight to a node:** `/node/<id>`. List the current node ids:
|
||||||
|
```bash
|
||||||
|
docker exec osint-board-db psql postgres://osint:osint_secret@localhost:5432/osint_dev -At -F' | ' \
|
||||||
|
-c "SELECT n.id,n.node_type,n.label FROM osint.story_nodes n JOIN osint.mysteries m ON m.id=n.mystery_id WHERE m.slug='glass-harbor' ORDER BY n.ypos;"
|
||||||
|
```
|
||||||
|
(The `level` row is the board.)
|
||||||
|
|
||||||
|
**Phone seam (achievements):** `/?phone=1` → dial `55501` (Elias) → voicemail → click **grant elias_number_callable** → the handset glows → dial `55501` again → connects. Voss `55502` stays voicemail; other numbers = unobtainable.
|
||||||
|
|
||||||
|
**Reset to a clean slate:** `docker exec osint-board-db psql postgres://osint:osint_secret@localhost:5432/osint_dev -c "TRUNCATE osint.playthroughs CASCADE;"` then reload (playthroughs + achievements are wiped; `/` shows the splash again).
|
||||||
|
|
||||||
|
## Dev-only endpoints
|
||||||
|
|
||||||
|
Both are gated to `NODE_ENV !== 'production'` (swap for the admin JWT when we want them in a deployed build):
|
||||||
|
- `POST /api/playthroughs/:id/goto` `{ nodeId }` — teleport (powers `/node/:id`).
|
||||||
|
- `POST /api/playthroughs/:id/achievements` `{ flagKey }` — grant an achievement (stand-in until the server-side rule engine fires them from play). `GET` the same path lists earned achievements.
|
||||||
|
|
||||||
|
Backend changes need a container rebuild: `docker compose -f docker-compose.dev.yml up -d --build app`. The Vite front end is live on 5173.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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.';
|
||||||
@@ -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.';
|
||||||
@@ -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.';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Merit nodes: a ceremony node that awards an achievement when the playthrough
|
||||||
|
-- reaches it (Scene 8 — "The Barricelli Luggage"). The awarded flag is authored on
|
||||||
|
-- the node; the runtime grants it on arrival with node provenance. Merit nodes may
|
||||||
|
-- also carry a component_key for their ceremony presentation (e.g. a 3D model).
|
||||||
|
|
||||||
|
ALTER TABLE osint.story_nodes DROP CONSTRAINT story_nodes_node_type_check;
|
||||||
|
ALTER TABLE osint.story_nodes ADD CONSTRAINT story_nodes_node_type_check
|
||||||
|
CHECK (node_type IN ('cutscene','dialogue','level','det_gate','llm_gate','merit'));
|
||||||
|
|
||||||
|
-- Let a merit node carry a ceremony component_key (was cutscene/gate only).
|
||||||
|
ALTER TABLE osint.story_nodes DROP CONSTRAINT story_nodes_check1;
|
||||||
|
ALTER TABLE osint.story_nodes ADD CONSTRAINT story_nodes_check1
|
||||||
|
CHECK (component_key IS NULL OR node_type IN ('cutscene','det_gate','llm_gate','merit'));
|
||||||
|
|
||||||
|
ALTER TABLE osint.story_nodes ADD COLUMN awards_flag TEXT
|
||||||
|
CHECK (awards_flag IS NULL OR awards_flag ~ '^[a-z][a-z0-9_.-]{0,63}$');
|
||||||
|
ALTER TABLE osint.story_nodes ADD CONSTRAINT story_nodes_awards_flag_type
|
||||||
|
CHECK (awards_flag IS NULL OR node_type = 'merit');
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Phone book + phone directory nodes. NPCs gain contact details; a phone node's
|
||||||
|
-- terminals each bind to an NPC (the callee) whose number the player dials.
|
||||||
|
|
||||||
|
ALTER TABLE osint.npcs ADD COLUMN phone_number TEXT;
|
||||||
|
ALTER TABLE osint.npcs ADD COLUMN email TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE osint.story_nodes DROP CONSTRAINT story_nodes_node_type_check;
|
||||||
|
ALTER TABLE osint.story_nodes ADD CONSTRAINT story_nodes_node_type_check
|
||||||
|
CHECK (node_type IN ('cutscene','dialogue','level','det_gate','llm_gate','merit','phone'));
|
||||||
|
|
||||||
|
-- A terminal on a phone node binds to the NPC you reach by dialing their number.
|
||||||
|
ALTER TABLE osint.story_node_terminals ADD COLUMN npc_id UUID REFERENCES osint.npcs(id) ON DELETE SET NULL;
|
||||||
@@ -112,6 +112,7 @@
|
|||||||
"key": "auction-catalogue",
|
"key": "auction-catalogue",
|
||||||
"title": "Meridian Maritime Auction · Lot 117",
|
"title": "Meridian Maritime Auction · Lot 117",
|
||||||
"fileType": "article",
|
"fileType": "article",
|
||||||
|
"requiredFlags": ["lead.auction_catalogue"],
|
||||||
"publishedAt": "1987-10-24T12:00:00.000Z",
|
"publishedAt": "1987-10-24T12:00:00.000Z",
|
||||||
"body": [
|
"body": [
|
||||||
"MERIDIAN MARITIME AUCTION — ADVANCE CATALOGUE · 24 OCTOBER 1987",
|
"MERIDIAN MARITIME AUCTION — ADVANCE CATALOGUE · 24 OCTOBER 1987",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type MysteryDocument = {
|
|||||||
body?: string[]
|
body?: string[]
|
||||||
metadata?: Record<string, string>
|
metadata?: Record<string, string>
|
||||||
asset?: string
|
asset?: string
|
||||||
|
requiredFlags?: string[]
|
||||||
}
|
}
|
||||||
type MysteryGraph = {
|
type MysteryGraph = {
|
||||||
entry: string
|
entry: string
|
||||||
@@ -81,7 +82,7 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
|
|||||||
width: uploaded?.width || 174, height: uploaded?.height || 145, rotation: 0, zIndex: uploaded?.zIndex || 1, hidden: false,
|
width: uploaded?.width || 174, height: uploaded?.height || 145, rotation: 0, zIndex: uploaded?.zIndex || 1, hidden: false,
|
||||||
body: source.body || [], regions: [], assetId: uploaded?.assetId,
|
body: source.body || [], regions: [], assetId: uploaded?.assetId,
|
||||||
fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize,
|
fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize,
|
||||||
fileType: source.fileType, metadata: source.metadata || {},
|
fileType: source.fileType, metadata: source.metadata || {}, requiredFlags: source.requiredFlags || [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,11 +84,12 @@ suite('normalized level persistence API', () => {
|
|||||||
state.viewport = { x: 91, y: -42, zoom: 0.85 }
|
state.viewport = { x: 91, y: -42, zoom: 0.85 }
|
||||||
|
|
||||||
const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', metadata: {}, ...placed(1051, 417, 174, 145, 2) }
|
const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', metadata: {}, ...placed(1051, 417, 174, 145, 2) }
|
||||||
|
const gatedDocument: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Later tip', body: [], regions: [], fileType: 'image', metadata: {}, requiredFlags: ['tip.received'], ...placed(1260, 417, 174, 145, 3) }
|
||||||
const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) }
|
const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) }
|
||||||
const note: NoteExhibit = { id: randomUUID(), type: 'note', title: 'Extract', content: 'Date matters', ...placed(420, 300, 108, 154) }
|
const note: NoteExhibit = { id: randomUUID(), type: 'note', title: 'Extract', content: 'Date matters', ...placed(420, 300, 108, 154) }
|
||||||
const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) }
|
const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) }
|
||||||
const party: PartyExhibit = { id: randomUUID(), type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as correspondent.', aliases: ['A. A. L.'], ...placed(720, 250, 280, 190) }
|
const party: PartyExhibit = { id: randomUUID(), type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as correspondent.', aliases: ['A. A. L.'], ...placed(720, 250, 280, 190) }
|
||||||
state.exhibits = [document, folder, note, event, party]
|
state.exhibits = [document, gatedDocument, folder, note, event, party]
|
||||||
state.relations = [
|
state.relations = [
|
||||||
{ id: randomUUID(), fromExhibitId: folder.id, toExhibitId: document.id, type: 'contains', sortOrder: 0 },
|
{ id: randomUUID(), fromExhibitId: folder.id, toExhibitId: document.id, type: 'contains', sortOrder: 0 },
|
||||||
{ id: randomUUID(), fromExhibitId: note.id, toExhibitId: document.id, type: 'source', sourceRegionId: 'stamp', sortOrder: 0 },
|
{ id: randomUUID(), fromExhibitId: note.id, toExhibitId: document.id, type: 'source', sourceRegionId: 'stamp', sortOrder: 0 },
|
||||||
@@ -106,16 +107,55 @@ suite('normalized level persistence API', () => {
|
|||||||
expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true })
|
expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true })
|
||||||
expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })]))
|
expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })]))
|
||||||
expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' })
|
expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' })
|
||||||
|
expect(loaded.exhibits.find(item => item.id === gatedDocument.id)).toMatchObject({ requiredFlags: ['tip.received'] })
|
||||||
|
|
||||||
|
const beforeFlag = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
|
||||||
|
expect(beforeFlag.exhibits.map(item => item.id)).not.toContain(gatedDocument.id)
|
||||||
|
expect(beforeFlag.newlyVisibleDocumentIds).toContain(document.id)
|
||||||
|
expect(await (await adminFetch(`${baseUrl}/api/levels/${state.id}/flags`)).json()).toEqual([
|
||||||
|
{ key: 'tip.received', gatedDocumentCount: 1 },
|
||||||
|
])
|
||||||
|
expect((await adminFetch(`${baseUrl}/api/levels/${state.id}/flags/tip.received`, { method: 'PUT' })).status).toBe(200)
|
||||||
|
const afterFlag = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
|
||||||
|
expect(afterFlag.exhibits.map(item => item.id)).toContain(gatedDocument.id)
|
||||||
|
expect(afterFlag.newlyVisibleDocumentIds).toContain(gatedDocument.id)
|
||||||
|
expect((await fetch(`${baseUrl}/api/levels/${state.id}/reveals/seen`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: afterFlag.newlyVisibleDocumentIds }) })).status).toBe(200)
|
||||||
|
expect((await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState).newlyVisibleDocumentIds).toEqual([])
|
||||||
|
|
||||||
|
expect((await adminFetch(`${baseUrl}/api/levels/${state.id}/flags/tip.received`, { method: 'DELETE' })).status).toBe(200)
|
||||||
|
const matchRuleResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/evidence-match-rules`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({
|
||||||
|
name: 'Smoke source passage', flagKey: 'tip.received', minimumAnchorMatches: 1,
|
||||||
|
anchors: [{ phrase: 'OSINT smoke evidence from the archive', minimumSimilarity: 0.72 }],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
expect(matchRuleResponse.status).toBe(201)
|
||||||
|
expect(await matchRuleResponse.json()).toMatchObject({ name: 'Smoke source passage', flagKey: 'tip.received', anchors: [{ phrase: 'OSINT smoke evidence from the archive' }] })
|
||||||
|
expect(await (await adminFetch(`${baseUrl}/api/levels/${state.id}/evidence-match-rules`)).json()).toHaveLength(1)
|
||||||
|
|
||||||
const upload = new FormData()
|
const upload = new FormData()
|
||||||
upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt')
|
upload.append('file', new Blob(['OSINT smoke evidence from the archlve'], { type: 'text/plain' }), 'smoke-evidence.txt')
|
||||||
const uploadResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/documents?edit=1`, { method: 'POST', body: upload })
|
upload.append('x', '812')
|
||||||
|
upload.append('y', '438')
|
||||||
|
const uploadResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents`, { method: 'POST', body: upload })
|
||||||
expect(uploadResponse.status).toBe(201)
|
expect(uploadResponse.status).toBe(201)
|
||||||
const uploaded = await uploadResponse.json() as DocumentExhibit
|
const uploaded = await uploadResponse.json() as DocumentExhibit & { analysis: { extractionStatus: string; matchedFlags: string[]; awardedFlags: string[] } }
|
||||||
expect(uploaded).toMatchObject({ type: 'document', fileName: 'smoke-evidence.txt', fileType: 'text' })
|
expect(uploaded).toMatchObject({ type: 'document', fileName: 'smoke-evidence.txt', fileType: 'text', x: 812, y: 438,
|
||||||
expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence')
|
body: ['OSINT smoke evidence from the archlve'], analysis: { extractionStatus: 'succeeded', matchedFlags: ['tip.received'], awardedFlags: ['tip.received'] } })
|
||||||
|
expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence from the archlve')
|
||||||
const assetRow = await appPool.query<{ storage_provider: string; content: Buffer | null; object_key: string | null }>('SELECT storage_provider,content,object_key FROM osint.assets WHERE id=$1', [uploaded.assetId])
|
const assetRow = await appPool.query<{ storage_provider: string; content: Buffer | null; object_key: string | null }>('SELECT storage_provider,content,object_key FROM osint.assets WHERE id=$1', [uploaded.assetId])
|
||||||
expect(assetRow.rows[0]).toMatchObject({ storage_provider: 's3', content: null, object_key: expect.stringMatching(/^assets\//) })
|
expect(assetRow.rows[0]).toMatchObject({ storage_provider: 's3', content: null, object_key: expect.stringMatching(/^assets\//) })
|
||||||
|
const automaticallyRevealed = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
|
||||||
|
expect(automaticallyRevealed.exhibits.map(item => item.id)).toContain(gatedDocument.id)
|
||||||
|
const evaluationRows = await appPool.query<{ matched: boolean; matched_anchor_count: number }>(
|
||||||
|
'SELECT matched,matched_anchor_count FROM osint.evidence_match_evaluations WHERE document_exhibit_id=$1', [uploaded.id])
|
||||||
|
expect(evaluationRows.rows).toEqual([{ matched: true, matched_anchor_count: 1 }])
|
||||||
|
|
||||||
|
const screenshot = new FormData()
|
||||||
|
screenshot.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), 'Screenshot 2026-08-22.png')
|
||||||
|
const screenshotResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents`, { method: 'POST', body: screenshot })
|
||||||
|
expect(screenshotResponse.status).toBe(201)
|
||||||
|
expect(await screenshotResponse.json()).toMatchObject({ type: 'document', fileType: 'image', fileName: 'Screenshot 2026-08-22.png' })
|
||||||
|
|
||||||
const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }) })
|
const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }) })
|
||||||
expect(templateResponse.status).toBe(201)
|
expect(templateResponse.status).toBe(201)
|
||||||
@@ -128,5 +168,10 @@ suite('normalized level persistence API', () => {
|
|||||||
expect(clone.relations.filter(relation => relation.type === 'supports')).toHaveLength(2)
|
expect(clone.relations.filter(relation => relation.type === 'supports')).toHaveLength(2)
|
||||||
expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 })
|
expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 })
|
||||||
expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id)
|
expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id)
|
||||||
|
const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
|
||||||
|
expect(authoredClone.exhibits.find(item => item.type === 'document' && item.title === 'Later tip')).toMatchObject({ requiredFlags: ['tip.received'] })
|
||||||
|
expect(await (await adminFetch(`${baseUrl}/api/levels/${clone.id}/evidence-match-rules`)).json()).toEqual([
|
||||||
|
expect.objectContaining({ name: 'Smoke source passage', flagKey: 'tip.received', anchors: [expect.objectContaining({ phrase: 'OSINT smoke evidence from the archive' })] }),
|
||||||
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export async function clearBoard(client: PoolClient, boardId: string) {
|
|||||||
await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [boardId])
|
await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [boardId])
|
||||||
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [boardId])
|
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [boardId])
|
||||||
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [boardId])
|
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [boardId])
|
||||||
|
await client.query('DELETE FROM osint.evidence_match_rules WHERE board_id=$1', [boardId])
|
||||||
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [boardId])
|
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [boardId])
|
||||||
await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [boardId])
|
await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [boardId])
|
||||||
await client.query('UPDATE osint.boards SET revision=0,updated_at=NOW() WHERE id=$1', [boardId])
|
await client.query('UPDATE osint.boards SET revision=0,updated_at=NOW() WHERE id=$1', [boardId])
|
||||||
@@ -68,6 +69,29 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
|
|||||||
(exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
(exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||||
[mapped(exhibitIds, row.exhibit_id, 'document'), row.document_type_id, row.asset_id, row.title, row.published_at, row.captured_at, row.source_uri])
|
[mapped(exhibitIds, row.exhibit_id, 'document'), row.document_type_id, row.asset_id, row.title, row.published_at, row.captured_at, row.source_uri])
|
||||||
|
|
||||||
|
const documentRequirements = await client.query<{ document_exhibit_id: string; flag_key: string }>(
|
||||||
|
'SELECT document_exhibit_id,flag_key FROM osint.document_flag_requirements WHERE board_id=$1 ORDER BY document_exhibit_id,flag_key', [sourceBoardId])
|
||||||
|
for (const row of documentRequirements.rows) await client.query(
|
||||||
|
'INSERT INTO osint.document_flag_requirements (board_id,document_exhibit_id,flag_key) VALUES ($1,$2,$3)',
|
||||||
|
[targetBoardId, mapped(exhibitIds, row.document_exhibit_id, 'document reveal requirement'), row.flag_key])
|
||||||
|
|
||||||
|
const ruleIds: IdMap = new Map()
|
||||||
|
const matchRules = await client.query<{
|
||||||
|
id: string; name: string; flag_key: string; matcher_version: string; minimum_anchor_matches: number; enabled: boolean
|
||||||
|
}>('SELECT id,name,flag_key,matcher_version,minimum_anchor_matches,enabled FROM osint.evidence_match_rules WHERE board_id=$1 ORDER BY created_at,id', [sourceBoardId])
|
||||||
|
for (const row of matchRules.rows) {
|
||||||
|
const id = randomUUID(); ruleIds.set(row.id, id)
|
||||||
|
await client.query(`INSERT INTO osint.evidence_match_rules
|
||||||
|
(id,board_id,origin_rule_id,name,flag_key,matcher_version,minimum_anchor_matches,enabled)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, [id,targetBoardId,row.id,row.name,row.flag_key,row.matcher_version,row.minimum_anchor_matches,row.enabled])
|
||||||
|
}
|
||||||
|
const matchAnchors = await client.query<{ rule_id: string; phrase_text: string; minimum_similarity: string; sort_order: number }>(
|
||||||
|
`SELECT a.rule_id,a.phrase_text,a.minimum_similarity::text,a.sort_order FROM osint.evidence_match_anchors a
|
||||||
|
JOIN osint.evidence_match_rules r ON r.id=a.rule_id WHERE r.board_id=$1 ORDER BY a.rule_id,a.sort_order,a.id`, [sourceBoardId])
|
||||||
|
for (const row of matchAnchors.rows) await client.query(`INSERT INTO osint.evidence_match_anchors
|
||||||
|
(id,rule_id,phrase_text,minimum_similarity,sort_order) VALUES ($1,$2,$3,$4,$5)`,
|
||||||
|
[randomUUID(), mapped(ruleIds, row.rule_id, 'evidence match rule'), row.phrase_text, row.minimum_similarity, row.sort_order])
|
||||||
|
|
||||||
const images = await client.query<{ exhibit_id: string; pixel_width: number | null; pixel_height: number | null; alt_text: string }>(
|
const images = await client.query<{ exhibit_id: string; pixel_width: number | null; pixel_height: number | null; alt_text: string }>(
|
||||||
`SELECT i.* FROM osint.image_documents i JOIN osint.exhibits e ON e.id=i.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
`SELECT i.* FROM osint.image_documents i JOIN osint.exhibits e ON e.id=i.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||||
for (const row of images.rows) await client.query(
|
for (const row of images.rows) await client.query(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
export type EvidenceMatchAnchor = {
|
||||||
|
id: string
|
||||||
|
phrase: string
|
||||||
|
minimumSimilarity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EvidenceMatchRule = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
flagKey: string
|
||||||
|
minimumAnchorMatches: number
|
||||||
|
anchors: EvidenceMatchAnchor[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EvidenceAnchorEvaluation = {
|
||||||
|
anchorId: string
|
||||||
|
similarity: number
|
||||||
|
matched: boolean
|
||||||
|
matchedText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EvidenceRuleEvaluation = {
|
||||||
|
ruleId: string
|
||||||
|
flagKey: string
|
||||||
|
matched: boolean
|
||||||
|
matchedAnchorCount: number
|
||||||
|
score: number
|
||||||
|
anchors: EvidenceAnchorEvaluation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_MATCH_TEXT_CHARACTERS = 200_000
|
||||||
|
|
||||||
|
/** Normalize historical spelling characters, punctuation, line breaks, and accents without changing word order. */
|
||||||
|
export function normalizeEvidenceText(value: string) {
|
||||||
|
return value.slice(0, MAX_MATCH_TEXT_CHARACTERS)
|
||||||
|
.toLocaleLowerCase('en')
|
||||||
|
.replace(/æ/g, 'ae')
|
||||||
|
.replace(/ø/g, 'o')
|
||||||
|
.replace(/å/g, 'aa')
|
||||||
|
.replace(/½/g, ' 1 2 ')
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/\p{Mark}/gu, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function grams(value: string, size = 3) {
|
||||||
|
const compact = value.replace(/\s+/g, ' ')
|
||||||
|
if (compact.length <= size) return [compact]
|
||||||
|
const result: string[] = []
|
||||||
|
for (let index = 0; index <= compact.length - size; index += 1) result.push(compact.slice(index, index + size))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function diceSimilarity(left: string, right: string) {
|
||||||
|
if (left === right) return 1
|
||||||
|
if (!left || !right) return 0
|
||||||
|
const leftGrams = grams(left)
|
||||||
|
const rightGrams = grams(right)
|
||||||
|
const rightCounts = new Map<string, number>()
|
||||||
|
for (const gram of rightGrams) rightCounts.set(gram, (rightCounts.get(gram) || 0) + 1)
|
||||||
|
let overlap = 0
|
||||||
|
for (const gram of leftGrams) {
|
||||||
|
const count = rightCounts.get(gram) || 0
|
||||||
|
if (!count) continue
|
||||||
|
overlap += 1
|
||||||
|
rightCounts.set(gram, count - 1)
|
||||||
|
}
|
||||||
|
return (2 * overlap) / (leftGrams.length + rightGrams.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreEvidenceAnchor(normalizedDocument: string, phrase: string) {
|
||||||
|
const normalizedPhrase = normalizeEvidenceText(phrase)
|
||||||
|
if (!normalizedDocument || !normalizedPhrase) return { similarity: 0, matchedText: '' }
|
||||||
|
if (normalizedDocument.includes(normalizedPhrase)) return { similarity: 1, matchedText: normalizedPhrase }
|
||||||
|
|
||||||
|
const documentTokens = normalizedDocument.split(' ')
|
||||||
|
const phraseTokens = normalizedPhrase.split(' ')
|
||||||
|
const spread = Math.max(2, Math.min(8, Math.ceil(phraseTokens.length * 0.2)))
|
||||||
|
const minimumWindow = Math.max(1, phraseTokens.length - spread)
|
||||||
|
const maximumWindow = Math.min(documentTokens.length, phraseTokens.length + spread)
|
||||||
|
let best = { similarity: 0, matchedText: '' }
|
||||||
|
|
||||||
|
for (let windowSize = minimumWindow; windowSize <= maximumWindow; windowSize += 1) {
|
||||||
|
for (let start = 0; start + windowSize <= documentTokens.length; start += 1) {
|
||||||
|
const candidate = documentTokens.slice(start, start + windowSize).join(' ')
|
||||||
|
const similarity = diceSimilarity(normalizedPhrase, candidate)
|
||||||
|
if (similarity > best.similarity) best = { similarity, matchedText: candidate }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateEvidenceRules(text: string, rules: EvidenceMatchRule[]): EvidenceRuleEvaluation[] {
|
||||||
|
const normalizedDocument = normalizeEvidenceText(text)
|
||||||
|
return rules.map(rule => {
|
||||||
|
const anchors = rule.anchors.map(anchor => {
|
||||||
|
const result = scoreEvidenceAnchor(normalizedDocument, anchor.phrase)
|
||||||
|
const similarity = Math.max(0, Math.min(1, result.similarity))
|
||||||
|
return { anchorId: anchor.id, similarity, matched: similarity >= anchor.minimumSimilarity, matchedText: result.matchedText }
|
||||||
|
})
|
||||||
|
const matchedAnchors = anchors.filter(anchor => anchor.matched)
|
||||||
|
const requiredScores = [...anchors].sort((left, right) => right.similarity - left.similarity).slice(0, rule.minimumAnchorMatches)
|
||||||
|
const score = requiredScores.length ? requiredScores.reduce((sum, anchor) => sum + anchor.similarity, 0) / requiredScores.length : 0
|
||||||
|
return {
|
||||||
|
ruleId: rule.id,
|
||||||
|
flagKey: rule.flagKey,
|
||||||
|
matched: matchedAnchors.length >= rule.minimumAnchorMatches,
|
||||||
|
matchedAnchorCount: matchedAnchors.length,
|
||||||
|
score,
|
||||||
|
anchors,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+96
-7
@@ -11,6 +11,7 @@ import type { CaseState } from '../src/types.js'
|
|||||||
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolveUserId } from './auth.js'
|
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolveUserId } from './auth.js'
|
||||||
import { createLevelRepository } from './levelRepository.js'
|
import { createLevelRepository } from './levelRepository.js'
|
||||||
import { createNarrativeRepository } from './narrativeRepository.js'
|
import { createNarrativeRepository } from './narrativeRepository.js'
|
||||||
|
import { createTextExtractorFromEnv } from './ocr.js'
|
||||||
import { createStoryGraphRepository, type StoryNodeType } from './storyGraphRepository.js'
|
import { createStoryGraphRepository, type StoryNodeType } from './storyGraphRepository.js'
|
||||||
import { createObjectStorageFromEnv } from './objectStorage.js'
|
import { createObjectStorageFromEnv } from './objectStorage.js'
|
||||||
|
|
||||||
@@ -25,10 +26,11 @@ export const pool = new Pool({ connectionString: databaseUrl })
|
|||||||
const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
|
const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
|
||||||
const objectStorage = createObjectStorageFromEnv()
|
const objectStorage = createObjectStorageFromEnv()
|
||||||
await objectStorage.initialize()
|
await objectStorage.initialize()
|
||||||
|
const textExtractor = createTextExtractorFromEnv()
|
||||||
const levels = createLevelRepository(pool, editingEnabled, objectStorage)
|
const levels = createLevelRepository(pool, editingEnabled, objectStorage)
|
||||||
const narrative = createNarrativeRepository(pool, objectStorage)
|
const narrative = createNarrativeRepository(pool, objectStorage)
|
||||||
const storyGraph = createStoryGraphRepository(pool)
|
const storyGraph = createStoryGraphRepository(pool)
|
||||||
const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate']
|
const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate', 'merit', 'phone']
|
||||||
|
|
||||||
function wantsEdit(req: express.Request) {
|
function wantsEdit(req: express.Request) {
|
||||||
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
|
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
|
||||||
@@ -49,7 +51,7 @@ const upload = multer({
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.get('/api/health', async (_req, res) => {
|
app.get('/api/health', async (_req, res) => {
|
||||||
try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', objectStorage: objectStorage.provider, schema: 'osint', editingEnabled }) }
|
try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', objectStorage: objectStorage.provider, textExtraction: textExtractor.provider, schema: 'osint', editingEnabled }) }
|
||||||
catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
|
catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
|
||||||
})
|
})
|
||||||
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req) }))
|
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req) }))
|
||||||
@@ -106,14 +108,41 @@ app.get('/api/assets/:id', async (req, res, next) => {
|
|||||||
asset.stream.pipe(res)
|
asset.stream.pipe(res)
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
app.post('/api/levels/:id/documents', requireAdmin, upload.single('file'), async (req, res, next) => {
|
app.post('/api/levels/:id/documents', upload.single('file'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
|
|
||||||
if (!req.file) return res.status(400).json({ error: 'A file is required' })
|
if (!req.file) return res.status(400).json({ error: 'A file is required' })
|
||||||
const document = await levels.uploadDocument(String(req.params.id), req.file)
|
const extraction = await textExtractor.extract(req.file)
|
||||||
|
const x = Number(req.body?.x); const y = Number(req.body?.y)
|
||||||
|
const placement = Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined
|
||||||
|
const document = await levels.uploadDocument(String(req.params.id), req.file, extraction, placement)
|
||||||
document ? res.status(201).json(document) : res.status(404).json({ error: 'Level not found' })
|
document ? res.status(201).json(document) : res.status(404).json({ error: 'Level not found' })
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
|
app.post('/api/levels/:id/reveals/seen', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const ids = Array.isArray(req.body?.documentIds) ? req.body.documentIds.map(String) : []
|
||||||
|
const acknowledged = await levels.acknowledgeRevealedDocuments(String(req.params.id), ids)
|
||||||
|
acknowledged === null ? res.status(404).json({ error: 'Level not found' }) : res.json({ acknowledged })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.get('/api/levels/:id/flags', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const flags = await levels.listFlags(String(req.params.id))
|
||||||
|
flags ? res.json(flags) : res.status(404).json({ error: 'Level not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.put('/api/levels/:id/flags/:key', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const updated = await levels.setFlag(String(req.params.id), String(req.params.key), true)
|
||||||
|
updated ? res.json({ ok: true }) : res.status(404).json({ error: 'Level not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.delete('/api/levels/:id/flags/:key', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const updated = await levels.setFlag(String(req.params.id), String(req.params.key), false)
|
||||||
|
updated ? res.json({ ok: true }) : res.status(404).json({ error: 'Level not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
app.get('/api/levels/:id', async (req, res, next) => {
|
app.get('/api/levels/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const level = await levels.getLevel(req.params.id, wantsEdit(req))
|
const level = await levels.getLevel(req.params.id, wantsEdit(req))
|
||||||
@@ -142,6 +171,34 @@ function requireEditing(res: express.Response) {
|
|||||||
if (!editingEnabled) { res.status(403).json({ error: 'Level editing is disabled' }); return false }
|
if (!editingEnabled) { res.status(403).json({ error: 'Level editing is disabled' }); return false }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
app.get('/api/levels/:id/evidence-match-rules', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rules = await levels.listEvidenceMatchRules(String(req.params.id))
|
||||||
|
rules ? res.json(rules) : res.status(404).json({ error: 'Level not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.post('/api/levels/:id/evidence-match-rules', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!requireEditing(res)) return
|
||||||
|
const rule = await levels.createEvidenceMatchRule(String(req.params.id), req.body)
|
||||||
|
rule ? res.status(201).json(rule) : res.status(404).json({ error: 'Level not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.put('/api/levels/:id/evidence-match-rules/:ruleId', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!requireEditing(res)) return
|
||||||
|
const rule = await levels.updateEvidenceMatchRule(String(req.params.id), String(req.params.ruleId), req.body)
|
||||||
|
rule ? res.json(rule) : res.status(404).json({ error: 'Level or evidence match rule not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.delete('/api/levels/:id/evidence-match-rules/:ruleId', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!requireEditing(res)) return
|
||||||
|
const removed = await levels.deleteEvidenceMatchRule(String(req.params.id), String(req.params.ruleId))
|
||||||
|
if (removed === null) return res.status(404).json({ error: 'Level not found' })
|
||||||
|
removed ? res.json({ ok: true }) : res.status(404).json({ error: 'Evidence match rule not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
app.get('/api/admin/mysteries', requireAdmin, async (_req, res, next) => {
|
app.get('/api/admin/mysteries', requireAdmin, async (_req, res, next) => {
|
||||||
try { res.json(await narrative.listMysteries()) } catch (error) { next(error) }
|
try { res.json(await narrative.listMysteries()) } catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
@@ -179,13 +236,13 @@ app.post('/api/admin/npcs', requireAdmin, async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
if (!requireEditing(res)) return
|
if (!requireEditing(res)) return
|
||||||
if (!req.body?.key) return res.status(400).json({ error: 'An NPC key is required' })
|
if (!req.body?.key) return res.status(400).json({ error: 'An NPC key is required' })
|
||||||
res.status(201).json(await narrative.createNpc({ key: String(req.body.key), name: String(req.body.name || ''), role: String(req.body.role || ''), defaultPose: req.body.defaultPose || null }))
|
res.status(201).json(await narrative.createNpc({ key: String(req.body.key), name: String(req.body.name || ''), role: String(req.body.role || ''), defaultPose: req.body.defaultPose || null, phoneNumber: req.body.phoneNumber ?? null, email: req.body.email ?? null }))
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
app.patch('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => {
|
app.patch('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
if (!requireEditing(res)) return
|
if (!requireEditing(res)) return
|
||||||
const npc = await narrative.updateNpc(String(req.params.id), { name: req.body?.name, role: req.body?.role, defaultPose: req.body?.defaultPose })
|
const npc = await narrative.updateNpc(String(req.params.id), { name: req.body?.name, role: req.body?.role, defaultPose: req.body?.defaultPose, phoneNumber: req.body?.phoneNumber, email: req.body?.email })
|
||||||
npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' })
|
npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' })
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
@@ -316,6 +373,12 @@ app.delete('/api/admin/utterances/:id', requireAdmin, async (req, res, next) =>
|
|||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Play mode: the launchable case list for the splash picker (entrypoint required).
|
||||||
|
app.get('/api/mysteries', async (_req, res, next) => {
|
||||||
|
try { res.json(await narrative.listPlayableMysteries()) }
|
||||||
|
catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
// Narrative authoring: create a mystery and its NPC cast. The flow (cutscenes,
|
// Narrative authoring: create a mystery and its NPC cast. The flow (cutscenes,
|
||||||
// dialogue, levels) lives in the story graph, seeded separately.
|
// dialogue, levels) lives in the story graph, seeded separately.
|
||||||
app.post('/api/mysteries', requireAdmin, async (req, res, next) => {
|
app.post('/api/mysteries', requireAdmin, async (req, res, next) => {
|
||||||
@@ -350,6 +413,32 @@ app.post('/api/playthroughs/:id/advance', async (req, res, next) => {
|
|||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The playthrough case-state (achievements). Read is open; granting is a dev-only
|
||||||
|
// stand-in until the server-side achievement rule engine drives awards from play.
|
||||||
|
app.get('/api/playthroughs/:id/achievements', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const flags = await narrative.listAchievements(String(req.params.id))
|
||||||
|
flags ? res.json(flags) : res.status(404).json({ error: 'Playthrough not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.post('/api/playthroughs/:id/achievements', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Manual grants are disabled' })
|
||||||
|
if (!req.body?.flagKey) return res.status(400).json({ error: 'A flagKey is required' })
|
||||||
|
const result = await narrative.awardAchievement(String(req.params.id), String(req.body.flagKey), req.body.nodeId ? String(req.body.nodeId) : null)
|
||||||
|
result.ok ? res.json({ earned: result.earned }) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
// Dev teleport: jump the playthrough to an explicit story node. Powers /node/:id.
|
||||||
|
app.post('/api/playthroughs/:id/goto', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Node teleport is disabled' })
|
||||||
|
if (!req.body?.nodeId) return res.status(400).json({ error: 'A nodeId is required' })
|
||||||
|
const result = await narrative.gotoNode(resolveUserId(req), String(req.params.id), String(req.body.nodeId))
|
||||||
|
result.ok ? res.json(result.state ?? null) : res.status(result.error === 'Playthrough not found' || result.error === 'Node not found' ? 404 : 400).json({ error: result.error })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
if (error instanceof multer.MulterError) {
|
if (error instanceof multer.MulterError) {
|
||||||
return res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ error: error.code === 'LIMIT_FILE_SIZE' ? 'Document exceeds the upload limit' : error.message })
|
return res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ error: error.code === 'LIMIT_FILE_SIZE' ? 'Document exceeds the upload limit' : error.message })
|
||||||
|
|||||||
+235
-12
@@ -1,15 +1,25 @@
|
|||||||
import { createHash, randomUUID } from 'node:crypto'
|
import { createHash, randomUUID } from 'node:crypto'
|
||||||
import { Readable } from 'node:stream'
|
import { Readable } from 'node:stream'
|
||||||
import type { Pool, PoolClient } from 'pg'
|
import type { Pool, PoolClient } from 'pg'
|
||||||
import type { BoardView, BriefConcept, CaseDocument, CaseState, Evidence, Exhibit, ExhibitRelation, OrganizationKind, PartyKind, SourceFileType } from '../src/types.js'
|
import type { BoardView, BriefConcept, CaseDocument, CaseState, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, LevelFlag, OrganizationKind, PartyKind, SourceFileType, UploadedCaseDocument } from '../src/types.js'
|
||||||
import { isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js'
|
import { isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js'
|
||||||
import { clearBoard, cloneBoard } from './boardClone.js'
|
import { clearBoard, cloneBoard } from './boardClone.js'
|
||||||
|
import { evaluateEvidenceRules, type EvidenceMatchRule } from './evidenceMatching.js'
|
||||||
|
import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVisibility.js'
|
||||||
import type { ObjectStorage } from './objectStorage.js'
|
import type { ObjectStorage } from './objectStorage.js'
|
||||||
|
import type { TextExtractionResult } from './ocr.js'
|
||||||
|
|
||||||
export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number }
|
export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number }
|
||||||
export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer | null; storage_provider: 'postgres' | 's3'; object_key: string | null }
|
export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer | null; storage_provider: 'postgres' | 's3'; object_key: string | null }
|
||||||
export type AssetResponse = { originalName: string; mimeType: string; byteSize: number; stream: NodeJS.ReadableStream }
|
export type AssetResponse = { originalName: string; mimeType: string; byteSize: number; stream: NodeJS.ReadableStream }
|
||||||
export type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string }
|
export type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string }
|
||||||
|
export type EvidenceMatchRuleInput = {
|
||||||
|
name: string
|
||||||
|
flagKey: string
|
||||||
|
minimumAnchorMatches?: number
|
||||||
|
enabled?: boolean
|
||||||
|
anchors: { phrase: string; minimumSimilarity?: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface LevelRepository {
|
export interface LevelRepository {
|
||||||
listLevels(): Promise<unknown[]>
|
listLevels(): Promise<unknown[]>
|
||||||
@@ -21,7 +31,14 @@ export interface LevelRepository {
|
|||||||
saveLevel(state: CaseState, authorMode: boolean): Promise<void>
|
saveLevel(state: CaseState, authorMode: boolean): Promise<void>
|
||||||
resetLevel(levelId: string): Promise<CaseState | null>
|
resetLevel(levelId: string): Promise<CaseState | null>
|
||||||
getAsset(assetId: string): Promise<AssetResponse | null>
|
getAsset(assetId: string): Promise<AssetResponse | null>
|
||||||
uploadDocument(levelId: string, file: UploadedDocument): Promise<CaseDocument | null>
|
uploadDocument(levelId: string, file: UploadedDocument, extraction: TextExtractionResult, placement?: { x: number; y: number }): Promise<UploadedCaseDocument | null>
|
||||||
|
listFlags(levelId: string): Promise<LevelFlag[] | null>
|
||||||
|
setFlag(levelId: string, key: string, earned: boolean): Promise<boolean>
|
||||||
|
acknowledgeRevealedDocuments(levelId: string, documentIds: string[]): Promise<number | null>
|
||||||
|
listEvidenceMatchRules(levelId: string): Promise<EvidenceMatchRuleDefinition[] | null>
|
||||||
|
createEvidenceMatchRule(levelId: string, input: EvidenceMatchRuleInput): Promise<EvidenceMatchRuleDefinition | null>
|
||||||
|
updateEvidenceMatchRule(levelId: string, ruleId: string, input: EvidenceMatchRuleInput): Promise<EvidenceMatchRuleDefinition | null>
|
||||||
|
deleteEvidenceMatchRule(levelId: string, ruleId: string): Promise<boolean | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
type LevelRow = {
|
type LevelRow = {
|
||||||
@@ -39,6 +56,7 @@ type ExhibitRow = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
const flagPattern = /^[a-z][a-z0-9_.-]{0,63}$/
|
||||||
function requireUuid(value: string, label: string) {
|
function requireUuid(value: string, label: string) {
|
||||||
if (!uuidPattern.test(value)) throw new Error(`${label} must be a UUID`)
|
if (!uuidPattern.test(value)) throw new Error(`${label} must be a UUID`)
|
||||||
return value
|
return value
|
||||||
@@ -48,6 +66,26 @@ function timestamp(value: string | undefined) {
|
|||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
return Number.isFinite(date.getTime()) ? date.toISOString() : null
|
return Number.isFinite(date.getTime()) ? date.toISOString() : null
|
||||||
}
|
}
|
||||||
|
function requireFlagKey(value: string) {
|
||||||
|
if (!flagPattern.test(value)) throw new Error('Flag keys must start with a lowercase letter and contain only lowercase letters, numbers, dots, dashes, or underscores')
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
function requireRuleInput(input: EvidenceMatchRuleInput) {
|
||||||
|
const name = String(input.name || '').trim()
|
||||||
|
if (!name || name.length > 160) throw new Error('Evidence match rule names must be between 1 and 160 characters')
|
||||||
|
const flagKey = requireFlagKey(String(input.flagKey || '').trim())
|
||||||
|
if (!Array.isArray(input.anchors) || !input.anchors.length || input.anchors.length > 20) throw new Error('Evidence match rules require between 1 and 20 anchors')
|
||||||
|
const anchors = input.anchors.map(anchor => {
|
||||||
|
const phrase = String(anchor.phrase || '').trim()
|
||||||
|
if (phrase.length < 12 || phrase.length > 1_000) throw new Error('Evidence match anchors must be between 12 and 1000 characters')
|
||||||
|
const minimumSimilarity = anchor.minimumSimilarity === undefined ? 0.72 : Number(anchor.minimumSimilarity)
|
||||||
|
if (!Number.isFinite(minimumSimilarity) || minimumSimilarity < 0.5 || minimumSimilarity > 1) throw new Error('Anchor similarity must be between 0.5 and 1')
|
||||||
|
return { phrase, minimumSimilarity }
|
||||||
|
})
|
||||||
|
const minimumAnchorMatches = input.minimumAnchorMatches === undefined ? 1 : Number(input.minimumAnchorMatches)
|
||||||
|
if (!Number.isInteger(minimumAnchorMatches) || minimumAnchorMatches < 1 || minimumAnchorMatches > anchors.length) throw new Error('Required anchor matches must be between 1 and the number of anchors')
|
||||||
|
return { name, flagKey, minimumAnchorMatches, enabled: input.enabled !== false, anchors }
|
||||||
|
}
|
||||||
function documentType(document: CaseDocument): SourceFileType {
|
function documentType(document: CaseDocument): SourceFileType {
|
||||||
const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
|
const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
|
||||||
return allowed.includes(document.fileType) ? document.fileType : 'file'
|
return allowed.includes(document.fileType) ? document.fileType : 'file'
|
||||||
@@ -67,11 +105,49 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
await client.query("INSERT INTO osint.timeline_views (view_id,range_mode) VALUES ($1,'auto')", [viewId])
|
await client.query("INSERT INTO osint.timeline_views (view_id,range_mode) VALUES ($1,'auto')", [viewId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function evidenceMatchRules(client: Pool | PoolClient, boardId: string, includeDisabled = false): Promise<EvidenceMatchRuleDefinition[]> {
|
||||||
|
const result = await client.query<{
|
||||||
|
rule_id: string; name: string; flag_key: string; matcher_version: 'char_trigram_v1'; minimum_anchor_matches: number; enabled: boolean
|
||||||
|
anchor_id: string; phrase_text: string; minimum_similarity: string; sort_order: number
|
||||||
|
}>(`SELECT r.id AS rule_id,r.name,r.flag_key,r.matcher_version,r.minimum_anchor_matches,r.enabled,
|
||||||
|
a.id AS anchor_id,a.phrase_text,a.minimum_similarity::text,a.sort_order
|
||||||
|
FROM osint.evidence_match_rules r
|
||||||
|
JOIN osint.evidence_match_anchors a ON a.rule_id=r.id
|
||||||
|
WHERE r.board_id=$1 ${includeDisabled ? '' : 'AND r.enabled'}
|
||||||
|
ORDER BY r.created_at,r.id,a.sort_order,a.id`, [boardId])
|
||||||
|
const rules = new Map<string, EvidenceMatchRuleDefinition>()
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const rule = rules.get(row.rule_id) || { id: row.rule_id, name: row.name, flagKey: row.flag_key,
|
||||||
|
matcherVersion: row.matcher_version, minimumAnchorMatches: row.minimum_anchor_matches, enabled: row.enabled, anchors: [] }
|
||||||
|
rule.anchors.push({ id: row.anchor_id, phrase: row.phrase_text, minimumSimilarity: Number(row.minimum_similarity), sortOrder: row.sort_order })
|
||||||
|
rules.set(row.rule_id, rule)
|
||||||
|
}
|
||||||
|
return [...rules.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeEvidenceMatchRule(client: PoolClient, level: LevelRow, ruleId: string, rawInput: EvidenceMatchRuleInput, update: boolean) {
|
||||||
|
const input = requireRuleInput(rawInput)
|
||||||
|
if (update) {
|
||||||
|
const changed = await client.query(`UPDATE osint.evidence_match_rules SET name=$3,flag_key=$4,minimum_anchor_matches=$5,enabled=$6,updated_at=NOW()
|
||||||
|
WHERE id=$1 AND board_id=$2`, [ruleId, level.board_id, input.name, input.flagKey, input.minimumAnchorMatches, input.enabled])
|
||||||
|
if (!changed.rowCount) return null
|
||||||
|
await client.query('DELETE FROM osint.evidence_match_anchors WHERE rule_id=$1', [ruleId])
|
||||||
|
} else {
|
||||||
|
await client.query(`INSERT INTO osint.evidence_match_rules (id,board_id,name,flag_key,minimum_anchor_matches,enabled)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6)`, [ruleId, level.board_id, input.name, input.flagKey, input.minimumAnchorMatches, input.enabled])
|
||||||
|
}
|
||||||
|
for (const [sortOrder, anchor] of input.anchors.entries()) await client.query(`INSERT INTO osint.evidence_match_anchors
|
||||||
|
(id,rule_id,phrase_text,minimum_similarity,sort_order) VALUES ($1,$2,$3,$4,$5)`,
|
||||||
|
[randomUUID(), ruleId, anchor.phrase, anchor.minimumSimilarity, sortOrder])
|
||||||
|
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
||||||
|
return (await evidenceMatchRules(client, level.board_id, true)).find(rule => rule.id === ruleId) || null
|
||||||
|
}
|
||||||
|
|
||||||
async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> {
|
async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> {
|
||||||
const level = await findLevel(pool, slug)
|
const level = await findLevel(pool, slug)
|
||||||
if (!level) return null
|
if (!level) return null
|
||||||
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
|
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
|
||||||
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult] = await Promise.all([
|
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult, requirementsResult, flagsResult, seenResult] = await Promise.all([
|
||||||
pool.query<ExhibitRow>(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden,
|
pool.query<ExhibitRow>(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden,
|
||||||
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, '') AS title,
|
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, '') AS title,
|
||||||
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
|
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
|
||||||
@@ -122,6 +198,10 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
`SELECT v.id,v.view_type_id,v.placement_mode,v.dock_edge,v.xpos,v.ypos,v.width,v.height,v.z_index,v.visible,
|
`SELECT v.id,v.view_type_id,v.placement_mode,v.dock_edge,v.xpos,v.ypos,v.width,v.height,v.z_index,v.visible,
|
||||||
t.range_mode,t.range_start::text,t.range_end::text FROM osint.board_views v
|
t.range_mode,t.range_start::text,t.range_end::text FROM osint.board_views v
|
||||||
JOIN osint.timeline_views t ON t.view_id=v.id WHERE v.board_id=$1 ORDER BY v.z_index,v.created_at`, [level.board_id]),
|
JOIN osint.timeline_views t ON t.view_id=v.id WHERE v.board_id=$1 ORDER BY v.z_index,v.created_at`, [level.board_id]),
|
||||||
|
pool.query<{ document_exhibit_id: string; flag_key: string }>(
|
||||||
|
'SELECT document_exhibit_id,flag_key FROM osint.document_flag_requirements WHERE board_id=$1 ORDER BY document_exhibit_id,flag_key', [level.board_id]),
|
||||||
|
pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.level_flags WHERE level_id=$1 ORDER BY flag_key', [level.id]),
|
||||||
|
pool.query<{ document_exhibit_id: string }>('SELECT document_exhibit_id FROM osint.level_seen_documents WHERE level_id=$1', [level.id]),
|
||||||
])
|
])
|
||||||
|
|
||||||
const blocks = new Map<string, string[]>()
|
const blocks = new Map<string, string[]>()
|
||||||
@@ -134,6 +214,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
for (const row of metadataResult.rows) metadata.set(row.exhibit_id, { ...(metadata.get(row.exhibit_id) || {}), [row.field_key]: row.value })
|
for (const row of metadataResult.rows) metadata.set(row.exhibit_id, { ...(metadata.get(row.exhibit_id) || {}), [row.field_key]: row.value })
|
||||||
const aliases = new Map<string, string[]>()
|
const aliases = new Map<string, string[]>()
|
||||||
for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias])
|
for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias])
|
||||||
|
const requirements = new Map<string, string[]>()
|
||||||
|
for (const row of requirementsResult.rows) requirements.set(row.document_exhibit_id, [...(requirements.get(row.document_exhibit_id) || []), row.flag_key])
|
||||||
const relations: ExhibitRelation[] = [
|
const relations: ExhibitRelation[] = [
|
||||||
...membershipsResult.rows.map(row => ({ id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromExhibitId: row.folder_exhibit_id,
|
...membershipsResult.rows.map(row => ({ id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromExhibitId: row.folder_exhibit_id,
|
||||||
toExhibitId: row.child_exhibit_id, type: 'contains' as const, sortOrder: row.sort_order })),
|
toExhibitId: row.child_exhibit_id, type: 'contains' as const, sortOrder: row.sort_order })),
|
||||||
@@ -149,13 +231,13 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
const documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => {
|
const documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => {
|
||||||
const type = row.document_type_id || 'file'
|
const type = row.document_type_id || 'file'
|
||||||
const publishedAt = row.published_at?.toISOString()
|
const publishedAt = row.published_at?.toISOString()
|
||||||
return { ...base(row), type: 'document', publishedAt,
|
return { ...base(row), type: 'document', publishedAt, requiredFlags: requirements.get(row.id) || [],
|
||||||
body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined,
|
body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined,
|
||||||
fileName: row.original_name || undefined, mimeType: row.mime_type || undefined,
|
fileName: row.original_name || undefined, mimeType: row.mime_type || undefined,
|
||||||
fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, metadata: metadata.get(row.id) || {} }
|
fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, metadata: metadata.get(row.id) || {} }
|
||||||
})
|
})
|
||||||
const evidence: Evidence[] = []
|
const evidence: Evidence[] = []
|
||||||
for (const row of exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document' && !row.hidden)) {
|
for (const row of exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document')) {
|
||||||
const common = { ...base(row), title: row.title, content: row.content }
|
const common = { ...base(row), title: row.title, content: row.content }
|
||||||
if (row.exhibit_type_id === 'folder') evidence.push({ ...common, type:'folder', isOpen:Boolean(row.is_open) })
|
if (row.exhibit_type_id === 'folder') evidence.push({ ...common, type:'folder', isOpen:Boolean(row.is_open) })
|
||||||
else if (row.exhibit_type_id === 'event') evidence.push({ ...common, type:'event', eventDate:row.occurred_at?.toISOString() })
|
else if (row.exhibit_type_id === 'event') evidence.push({ ...common, type:'event', eventDate:row.occurred_at?.toISOString() })
|
||||||
@@ -170,7 +252,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
rangeMode: row.range_mode, range: row.range_start && row.range_end ? { start: row.range_start, end: row.range_end } : undefined }))
|
rangeMode: row.range_mode, range: row.range_start && row.range_end ? { start: row.range_start, end: row.range_end } : undefined }))
|
||||||
const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text,
|
const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text,
|
||||||
...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined }))
|
...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined }))
|
||||||
return { id: level.slug, title: level.title, subtitle: level.subtitle, exhibits: [...documents, ...evidence], relations,
|
const fullState: CaseState = { id: level.slug, title: level.title, subtitle: level.subtitle, exhibits: [...documents, ...evidence], relations,
|
||||||
connections: connectionsResult.rows.map(row => ({ id: row.id, fromExhibitId: row.from_exhibit_id, toExhibitId: row.to_exhibit_id,
|
connections: connectionsResult.rows.map(row => ({ id: row.id, fromExhibitId: row.from_exhibit_id, toExhibitId: row.to_exhibit_id,
|
||||||
label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style,
|
label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style,
|
||||||
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
|
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
|
||||||
@@ -178,6 +260,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
views, revision: Number(level.revision),
|
views, revision: Number(level.revision),
|
||||||
brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
|
brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
|
||||||
sourceTemplateVersionId: level.source_template_version_id || undefined }
|
sourceTemplateVersionId: level.source_template_version_id || undefined }
|
||||||
|
return filterLevelVisibility(fullState, flagsResult.rows.map(row => row.flag_key), seenResult.rows.map(row => row.document_exhibit_id), authorMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function templateSummary(slug: string): Promise<TemplateSummary | null> {
|
async function templateSummary(slug: string): Promise<TemplateSummary | null> {
|
||||||
@@ -234,6 +317,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [document.id, documentType(document), document.assetId || null, document.title,
|
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [document.id, documentType(document), document.assetId || null, document.title,
|
||||||
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null])
|
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null])
|
||||||
if (document.fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [document.id])
|
if (document.fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [document.id])
|
||||||
|
for (const flag of new Set((document.requiredFlags || []).map(value => value.trim()).filter(Boolean).map(requireFlagKey))) await client.query(
|
||||||
|
'INSERT INTO osint.document_flag_requirements (board_id,document_exhibit_id,flag_key) VALUES ($1,$2,$3)', [level.board_id, document.id, flag])
|
||||||
for (const [sortOrder, content] of document.body.entries()) await client.query(
|
for (const [sortOrder, content] of document.body.entries()) await client.query(
|
||||||
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,$3,$4)', [randomUUID(), document.id, sortOrder, content])
|
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,$3,$4)', [randomUUID(), document.id, sortOrder, content])
|
||||||
for (const [sortOrder, region] of document.regions.entries()) await client.query(
|
for (const [sortOrder, region] of document.regions.entries()) await client.query(
|
||||||
@@ -411,13 +496,19 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
return templateSummary(input.slug)
|
return templateSummary(input.slug)
|
||||||
},
|
},
|
||||||
getLevel(levelId, authorMode = false) { return assembleLevel(levelId, authorMode) },
|
getLevel(levelId, authorMode = false) { return assembleLevel(levelId, authorMode) },
|
||||||
async saveLevel(state) {
|
async saveLevel(state, authorMode) {
|
||||||
|
let persistedState = state
|
||||||
|
if (!authorMode) {
|
||||||
|
const [full, visible] = await Promise.all([assembleLevel(state.id, true), assembleLevel(state.id, false)])
|
||||||
|
if (!full || !visible) throw new Error('Level not found')
|
||||||
|
persistedState = mergePlayerStateForPersistence(full, visible, state)
|
||||||
|
}
|
||||||
const client = await pool.connect()
|
const client = await pool.connect()
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN')
|
await client.query('BEGIN')
|
||||||
const level = await findLevel(client, state.id, true)
|
const level = await findLevel(client, state.id, true)
|
||||||
if (!level) throw new Error('Level not found')
|
if (!level) throw new Error('Level not found')
|
||||||
await replaceBoard(client, level, state)
|
await replaceBoard(client, level, persistedState)
|
||||||
await client.query('COMMIT')
|
await client.query('COMMIT')
|
||||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
},
|
},
|
||||||
@@ -454,7 +545,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
const object = await objectStorage.getObject(asset.object_key)
|
const object = await objectStorage.getObject(asset.object_key)
|
||||||
return object ? { originalName: asset.original_name, mimeType: asset.mime_type, byteSize: Number(asset.byte_size), stream: object.stream } : null
|
return object ? { originalName: asset.original_name, mimeType: asset.mime_type, byteSize: Number(asset.byte_size), stream: object.stream } : null
|
||||||
},
|
},
|
||||||
async uploadDocument(levelId, file) {
|
async uploadDocument(levelId, file, extraction, placement) {
|
||||||
const client = await pool.connect()
|
const client = await pool.connect()
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN')
|
await client.query('BEGIN')
|
||||||
@@ -474,17 +565,149 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
assetId = asset.rows[0].id
|
assetId = asset.rows[0].id
|
||||||
}
|
}
|
||||||
const fileType: SourceFileType = file.mimetype.startsWith('image/') ? 'image' : file.mimetype === 'application/pdf' ? 'pdf' : file.mimetype.startsWith('text/') ? 'text' : 'file'
|
const fileType: SourceFileType = file.mimetype.startsWith('image/') ? 'image' : file.mimetype === 'application/pdf' ? 'pdf' : file.mimetype.startsWith('text/') ? 'text' : 'file'
|
||||||
|
const xpos = Number.isFinite(placement?.x) ? Math.max(0, Math.min(10_000, Number(placement?.x))) : 100
|
||||||
|
const ypos = Number.isFinite(placement?.y) ? Math.max(0, Math.min(10_000, Number(placement?.y))) : 100
|
||||||
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
|
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
|
||||||
VALUES ($1,$2,'document',100,100,174,145,(SELECT COUNT(*) FROM osint.exhibits WHERE board_id=$2),FALSE)`, [exhibitId, level.board_id])
|
VALUES ($1,$2,'document',$3,$4,174,145,(SELECT COUNT(*) FROM osint.exhibits WHERE board_id=$2),FALSE)`, [exhibitId, level.board_id, xpos, ypos])
|
||||||
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)`,
|
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)`,
|
||||||
[exhibitId, fileType, assetId, file.originalname])
|
[exhibitId, fileType, assetId, file.originalname])
|
||||||
if (fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [exhibitId])
|
if (fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [exhibitId])
|
||||||
|
if (extraction.status === 'succeeded' && extraction.text.trim()) await client.query(
|
||||||
|
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,0,$3)', [randomUUID(), exhibitId, extraction.text.trim()])
|
||||||
|
|
||||||
|
const extractionResult = await client.query<{ id: string }>(`INSERT INTO osint.asset_text_extractions
|
||||||
|
(id,asset_id,extractor,extractor_version,language,status,extracted_text,error_message)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||||
|
ON CONFLICT (asset_id,extractor,extractor_version,language) DO UPDATE SET
|
||||||
|
status=EXCLUDED.status,extracted_text=EXCLUDED.extracted_text,error_message=EXCLUDED.error_message,updated_at=NOW()
|
||||||
|
RETURNING id`, [randomUUID(), assetId, extraction.extractor, extraction.extractorVersion, extraction.language,
|
||||||
|
extraction.status, extraction.text, extraction.error?.slice(0, 2_000) || null])
|
||||||
|
const extractionId = extractionResult.rows[0].id
|
||||||
|
const ruleDefinitions = extraction.status === 'succeeded' && extraction.text.trim()
|
||||||
|
? await evidenceMatchRules(client, level.board_id)
|
||||||
|
: []
|
||||||
|
const evaluations = evaluateEvidenceRules(extraction.text, ruleDefinitions as EvidenceMatchRule[])
|
||||||
|
const matchedFlags: string[] = []
|
||||||
|
const awardedFlags: string[] = []
|
||||||
|
for (const evaluation of evaluations) {
|
||||||
|
const evaluationId = randomUUID()
|
||||||
|
await client.query(`INSERT INTO osint.evidence_match_evaluations
|
||||||
|
(id,level_id,board_id,document_exhibit_id,extraction_id,rule_id,matched,matched_anchor_count,score)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [evaluationId, level.id, level.board_id, exhibitId, extractionId,
|
||||||
|
evaluation.ruleId, evaluation.matched, evaluation.matchedAnchorCount, evaluation.score])
|
||||||
|
for (const anchor of evaluation.anchors) await client.query(`INSERT INTO osint.evidence_match_anchor_evaluations
|
||||||
|
(evaluation_id,anchor_id,similarity,matched,matched_text) VALUES ($1,$2,$3,$4,$5)`,
|
||||||
|
[evaluationId, anchor.anchorId, anchor.similarity, anchor.matched, anchor.matchedText])
|
||||||
|
if (!evaluation.matched) continue
|
||||||
|
matchedFlags.push(evaluation.flagKey)
|
||||||
|
const awarded = await client.query(`INSERT INTO osint.level_flags (level_id,board_id,flag_key,awarded_by_evidence_match_id)
|
||||||
|
VALUES ($1,$2,$3,$4) ON CONFLICT (level_id,flag_key) DO NOTHING RETURNING flag_key`,
|
||||||
|
[level.id, level.board_id, evaluation.flagKey, evaluationId])
|
||||||
|
if (awarded.rowCount) awardedFlags.push(evaluation.flagKey)
|
||||||
|
}
|
||||||
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
||||||
await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
|
await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
|
||||||
await client.query('COMMIT')
|
await client.query('COMMIT')
|
||||||
return { id: exhibitId,type:'document',title:file.originalname,x:100,y:100,width:174,height:145,rotation:0,zIndex:0,hidden:false,
|
return { id: exhibitId,type:'document',title:file.originalname,x:xpos,y:ypos,width:174,height:145,rotation:0,zIndex:0,hidden:false,
|
||||||
fileType,metadata:{},body:[],regions:[],assetId,fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size }
|
fileType,metadata:{},body:extraction.status === 'succeeded' && extraction.text.trim() ? [extraction.text.trim()] : [],regions:[],assetId,
|
||||||
|
fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size,
|
||||||
|
analysis:{ extractionStatus:extraction.status, matchedFlags:[...new Set(matchedFlags)], awardedFlags:[...new Set(awardedFlags)] } }
|
||||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
},
|
},
|
||||||
|
async listFlags(levelId) {
|
||||||
|
const level = await findLevel(pool, levelId)
|
||||||
|
if (!level) return null
|
||||||
|
const result = await pool.query<{ flag_key: string; earned_at: Date | null; gated_document_count: number }>(`
|
||||||
|
WITH keys AS (
|
||||||
|
SELECT flag_key FROM osint.level_flags WHERE level_id=$1
|
||||||
|
UNION
|
||||||
|
SELECT flag_key FROM osint.document_flag_requirements WHERE board_id=$2
|
||||||
|
UNION
|
||||||
|
SELECT flag_key FROM osint.evidence_match_rules WHERE board_id=$2
|
||||||
|
)
|
||||||
|
SELECT keys.flag_key,flags.earned_at,COUNT(requirements.document_exhibit_id)::int AS gated_document_count
|
||||||
|
FROM keys
|
||||||
|
LEFT JOIN osint.level_flags flags ON flags.level_id=$1 AND flags.flag_key=keys.flag_key
|
||||||
|
LEFT JOIN osint.document_flag_requirements requirements ON requirements.board_id=$2 AND requirements.flag_key=keys.flag_key
|
||||||
|
GROUP BY keys.flag_key,flags.earned_at ORDER BY keys.flag_key`, [level.id, level.board_id])
|
||||||
|
return result.rows.map(row => ({ key: row.flag_key, earnedAt: row.earned_at?.toISOString(), gatedDocumentCount: row.gated_document_count }))
|
||||||
|
},
|
||||||
|
async setFlag(levelId, rawKey, earned) {
|
||||||
|
const key = requireFlagKey(rawKey.trim())
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
const level = await findLevel(client, levelId, true)
|
||||||
|
if (!level) { await client.query('ROLLBACK'); return false }
|
||||||
|
if (earned) await client.query(`INSERT INTO osint.level_flags (level_id,board_id,flag_key) VALUES ($1,$2,$3)
|
||||||
|
ON CONFLICT (level_id,flag_key) DO NOTHING`, [level.id, level.board_id, key])
|
||||||
|
else {
|
||||||
|
await client.query('DELETE FROM osint.level_flags WHERE level_id=$1 AND flag_key=$2', [level.id, key])
|
||||||
|
await client.query(`DELETE FROM osint.level_seen_documents seen USING osint.document_flag_requirements requirement
|
||||||
|
WHERE seen.level_id=$1 AND seen.document_exhibit_id=requirement.document_exhibit_id AND requirement.board_id=$2 AND requirement.flag_key=$3`,
|
||||||
|
[level.id, level.board_id, key])
|
||||||
|
}
|
||||||
|
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
||||||
|
await client.query('COMMIT')
|
||||||
|
return true
|
||||||
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
|
},
|
||||||
|
async listEvidenceMatchRules(levelId) {
|
||||||
|
const level = await findLevel(pool, levelId)
|
||||||
|
return level ? evidenceMatchRules(pool, level.board_id, true) : null
|
||||||
|
},
|
||||||
|
async createEvidenceMatchRule(levelId, input) {
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
const level = await findLevel(client, levelId, true)
|
||||||
|
if (!level) { await client.query('ROLLBACK'); return null }
|
||||||
|
const rule = await writeEvidenceMatchRule(client, level, randomUUID(), input, false)
|
||||||
|
await client.query('COMMIT')
|
||||||
|
return rule
|
||||||
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
|
},
|
||||||
|
async updateEvidenceMatchRule(levelId, ruleId, input) {
|
||||||
|
if (!uuidPattern.test(ruleId)) return null
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
const level = await findLevel(client, levelId, true)
|
||||||
|
if (!level) { await client.query('ROLLBACK'); return null }
|
||||||
|
const rule = await writeEvidenceMatchRule(client, level, ruleId, input, true)
|
||||||
|
await client.query('COMMIT')
|
||||||
|
return rule
|
||||||
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
|
},
|
||||||
|
async deleteEvidenceMatchRule(levelId, ruleId) {
|
||||||
|
if (!uuidPattern.test(ruleId)) return false
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
const level = await findLevel(client, levelId, true)
|
||||||
|
if (!level) { await client.query('ROLLBACK'); return null }
|
||||||
|
const removed = await client.query('DELETE FROM osint.evidence_match_rules WHERE id=$1 AND board_id=$2', [ruleId, level.board_id])
|
||||||
|
if (removed.rowCount) await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
||||||
|
await client.query('COMMIT')
|
||||||
|
return Boolean(removed.rowCount)
|
||||||
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
|
},
|
||||||
|
async acknowledgeRevealedDocuments(levelId, rawDocumentIds) {
|
||||||
|
const documentIds = [...new Set(rawDocumentIds.filter(id => uuidPattern.test(id)))]
|
||||||
|
const level = await findLevel(pool, levelId)
|
||||||
|
if (!level) return null
|
||||||
|
if (!documentIds.length) return 0
|
||||||
|
const result = await pool.query(`INSERT INTO osint.level_seen_documents (level_id,board_id,document_exhibit_id)
|
||||||
|
SELECT $1,$2,e.id FROM osint.exhibits e
|
||||||
|
WHERE e.board_id=$2 AND e.id=ANY($3::uuid[]) AND e.exhibit_type_id='document' AND NOT e.hidden
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM osint.document_flag_requirements requirement
|
||||||
|
WHERE requirement.document_exhibit_id=e.id AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM osint.level_flags flag WHERE flag.level_id=$1 AND flag.flag_key=requirement.flag_key
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ON CONFLICT (level_id,document_exhibit_id) DO NOTHING`, [level.id, level.board_id, documentIds])
|
||||||
|
return result.rowCount || 0
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { CaseState, DocumentExhibit, ExhibitRelation, Connection } from '../src/types.js'
|
||||||
|
|
||||||
|
function requirementsMet(document: DocumentExhibit, earnedFlags: ReadonlySet<string>) {
|
||||||
|
return (document.requiredFlags || []).every(flag => earnedFlags.has(flag))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterLevelVisibility(full: CaseState, earnedFlags: Iterable<string>, seenDocumentIds: Iterable<string>, authorMode: boolean): CaseState {
|
||||||
|
if (authorMode) return { ...full, newlyVisibleDocumentIds: [] }
|
||||||
|
const earned = new Set(earnedFlags)
|
||||||
|
const seen = new Set(seenDocumentIds)
|
||||||
|
const visibleExhibits = full.exhibits.filter(exhibit => !exhibit.hidden && (exhibit.type !== 'document' || requirementsMet(exhibit, earned)))
|
||||||
|
const visibleIds = new Set(visibleExhibits.map(exhibit => exhibit.id))
|
||||||
|
const sanitize = (exhibit: typeof visibleExhibits[number]) => {
|
||||||
|
if (exhibit.type !== 'document') return exhibit
|
||||||
|
const { requiredFlags: _requirements, ...document } = exhibit
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...full,
|
||||||
|
exhibits: visibleExhibits.map(sanitize),
|
||||||
|
relations: full.relations.filter(relation => visibleIds.has(relation.fromExhibitId) && visibleIds.has(relation.toExhibitId)),
|
||||||
|
connections: full.connections.filter(connection => visibleIds.has(connection.fromExhibitId) && visibleIds.has(connection.toExhibitId)),
|
||||||
|
newlyVisibleDocumentIds: visibleExhibits.flatMap(exhibit => exhibit.type === 'document' && !seen.has(exhibit.id) ? [exhibit.id] : []),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMissingById<T extends { id: string }>(submitted: T[], preserved: T[]) {
|
||||||
|
const ids = new Set(submitted.map(item => item.id))
|
||||||
|
return [...submitted, ...preserved.filter(item => !ids.has(item.id))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preserve server-hidden objects during the legacy whole-board PUT used by play mode. */
|
||||||
|
export function mergePlayerStateForPersistence(full: CaseState, visible: CaseState, submitted: CaseState): CaseState {
|
||||||
|
const visibleIds = new Set(visible.exhibits.map(exhibit => exhibit.id))
|
||||||
|
const unavailableIds = new Set(full.exhibits.filter(exhibit => !visibleIds.has(exhibit.id)).map(exhibit => exhibit.id))
|
||||||
|
const fullDocuments = new Map(full.exhibits.flatMap(exhibit => exhibit.type === 'document' ? [[exhibit.id, exhibit] as const] : []))
|
||||||
|
const submittedExhibits = submitted.exhibits.map(exhibit => exhibit.type === 'document'
|
||||||
|
? { ...exhibit, requiredFlags: fullDocuments.get(exhibit.id)?.requiredFlags || exhibit.requiredFlags || [] }
|
||||||
|
: exhibit)
|
||||||
|
const preservedExhibits = full.exhibits.filter(exhibit => unavailableIds.has(exhibit.id))
|
||||||
|
const touchesUnavailable = (item: ExhibitRelation | Connection) => unavailableIds.has(item.fromExhibitId) || unavailableIds.has(item.toExhibitId)
|
||||||
|
return {
|
||||||
|
...submitted,
|
||||||
|
exhibits: appendMissingById(submittedExhibits, preservedExhibits),
|
||||||
|
relations: appendMissingById(submitted.relations, full.relations.filter(touchesUnavailable)),
|
||||||
|
connections: appendMissingById(submitted.connections, full.connections.filter(touchesUnavailable)),
|
||||||
|
newlyVisibleDocumentIds: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import pg from 'pg'
|
import pg from 'pg'
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
@@ -31,9 +32,10 @@ suite('PostgreSQL migrations', () => {
|
|||||||
|
|
||||||
it('applies every migration transactionally and is idempotent', async () => {
|
it('applies every migration transactionally and is idempotent', async () => {
|
||||||
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
||||||
|
const migrationCount = (await fs.readdir(migrationsDir)).filter(name => /^\d+.*\.sql$/.test(name)).length
|
||||||
const firstRun: string[] = []
|
const firstRun: string[] = []
|
||||||
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
|
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
|
||||||
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(24)
|
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(migrationCount)
|
||||||
|
|
||||||
const client = new Client({ connectionString: testDatabaseUrl })
|
const client = new Client({ connectionString: testDatabaseUrl })
|
||||||
await client.connect()
|
await client.connect()
|
||||||
@@ -46,10 +48,13 @@ suite('PostgreSQL migrations', () => {
|
|||||||
'board_views', 'timeline_views',
|
'board_views', 'timeline_views',
|
||||||
'mysteries', 'npcs', 'npc_poses', 'playthroughs',
|
'mysteries', 'npcs', 'npc_poses', 'playthroughs',
|
||||||
'story_nodes', 'story_node_terminals', 'utterances',
|
'story_nodes', 'story_node_terminals', 'utterances',
|
||||||
|
'level_flags', 'document_flag_requirements', 'level_seen_documents', 'achievements',
|
||||||
|
'asset_text_extractions', 'evidence_match_rules', 'evidence_match_anchors',
|
||||||
|
'evidence_match_evaluations', 'evidence_match_anchor_evaluations',
|
||||||
]))
|
]))
|
||||||
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue']))
|
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue']))
|
||||||
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
|
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
|
||||||
expect(ledger.rows[0].count).toBe('24')
|
expect(ledger.rows[0].count).toBe(String(migrationCount))
|
||||||
const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`)
|
const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`)
|
||||||
expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset']))
|
expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset']))
|
||||||
const eventOccurrence = await client.query<{ is_nullable: string }>(`SELECT is_nullable FROM information_schema.columns WHERE table_schema='osint' AND table_name='event_exhibits' AND column_name='occurred_at'`)
|
const eventOccurrence = await client.query<{ is_nullable: string }>(`SELECT is_nullable FROM information_schema.columns WHERE table_schema='osint' AND table_name='event_exhibits' AND column_name='occurred_at'`)
|
||||||
@@ -58,7 +63,7 @@ suite('PostgreSQL migrations', () => {
|
|||||||
|
|
||||||
const secondRun: string[] = []
|
const secondRun: string[] = []
|
||||||
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
|
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
|
||||||
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(24)
|
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(migrationCount)
|
||||||
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
|
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { ObjectStorage } from './objectStorage.js'
|
|||||||
export type UploadedFile = { buffer: Buffer; originalname: string; mimetype: string; size: number }
|
export type UploadedFile = { buffer: Buffer; originalname: string; mimetype: string; size: number }
|
||||||
export type AssetDto = { id: string; originalName: string; mimeType: string; byteSize: number; url: string }
|
export type AssetDto = { id: string; originalName: string; mimeType: string; byteSize: number; url: string }
|
||||||
export type PoseDto = { poseKey: string; assetId: string; url: string }
|
export type PoseDto = { poseKey: string; assetId: string; url: string }
|
||||||
export type NpcDto = { id: string; key: string; name: string; role: string; defaultPose: string | null; poses: PoseDto[]; inUse: boolean }
|
export type NpcDto = { id: string; key: string; name: string; role: string; defaultPose: string | null; phoneNumber: string | null; email: string | null; poses: PoseDto[]; inUse: boolean }
|
||||||
export type MysterySummary = { id: string; slug: string; title: string; nodes: number }
|
export type MysterySummary = { id: string; slug: string; title: string; nodes: number }
|
||||||
|
|
||||||
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: 'active' | 'finished' }
|
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: 'active' | 'finished' }
|
||||||
@@ -15,8 +15,9 @@ export type RuntimeUtterance = {
|
|||||||
poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null
|
poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null
|
||||||
}
|
}
|
||||||
export type RuntimeNode = {
|
export type RuntimeNode = {
|
||||||
id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string
|
id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string
|
||||||
componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number
|
componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number
|
||||||
|
awardsFlag?: string | null
|
||||||
utterances?: RuntimeUtterance[]; rootId?: string | null
|
utterances?: RuntimeUtterance[]; rootId?: string | null
|
||||||
}
|
}
|
||||||
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
|
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
|
||||||
@@ -47,20 +48,31 @@ export interface NarrativeRepository {
|
|||||||
createPlaythrough(userId: string, mysterySlug?: string): Promise<PlaythroughState | null>
|
createPlaythrough(userId: string, mysterySlug?: string): Promise<PlaythroughState | null>
|
||||||
getCurrentPlaythrough(userId: string): Promise<PlaythroughState | null>
|
getCurrentPlaythrough(userId: string): Promise<PlaythroughState | null>
|
||||||
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
||||||
|
listAchievements(playthroughId: string): Promise<string[] | null>
|
||||||
|
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }>
|
||||||
|
gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
||||||
listMysteries(): Promise<MysterySummary[]>
|
listMysteries(): Promise<MysterySummary[]>
|
||||||
|
listPlayableMysteries(): Promise<{ slug: string; title: string }[]>
|
||||||
deleteMystery(id: string): Promise<boolean>
|
deleteMystery(id: string): Promise<boolean>
|
||||||
uploadAsset(file: UploadedFile): Promise<AssetDto>
|
uploadAsset(file: UploadedFile): Promise<AssetDto>
|
||||||
listAssets(): Promise<AssetDto[]>
|
listAssets(): Promise<AssetDto[]>
|
||||||
deleteAsset(id: string): Promise<'deleted' | 'in_use' | 'not_found'>
|
deleteAsset(id: string): Promise<'deleted' | 'in_use' | 'not_found'>
|
||||||
listNpcs(): Promise<NpcDto[]>
|
listNpcs(): Promise<NpcDto[]>
|
||||||
createNpc(input: { key: string; name: string; role?: string; defaultPose?: string | null }): Promise<NpcDto>
|
createNpc(input: { key: string; name: string; role?: string; defaultPose?: string | null; phoneNumber?: string | null; email?: string | null }): Promise<NpcDto>
|
||||||
updateNpc(id: string, input: { name?: string; role?: string; defaultPose?: string | null }): Promise<NpcDto | null>
|
updateNpc(id: string, input: { name?: string; role?: string; defaultPose?: string | null; phoneNumber?: string | null; email?: string | null }): Promise<NpcDto | null>
|
||||||
deleteNpc(id: string): Promise<'deleted' | 'in_use' | 'not_found'>
|
deleteNpc(id: string): Promise<'deleted' | 'in_use' | 'not_found'>
|
||||||
addPose(npcId: string, poseKey: string, file: UploadedFile): Promise<NpcDto | null>
|
addPose(npcId: string, poseKey: string, file: UploadedFile): Promise<NpcDto | null>
|
||||||
deletePose(npcId: string, poseKey: string): Promise<NpcDto | null>
|
deletePose(npcId: string, poseKey: string): Promise<NpcDto | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
type GraphNodeRow = { id: string; node_type: string; label: string; component_key: string | null; level_template_version_id: string | null; music_asset_id: string | null; music_volume: number }
|
type GraphNodeRow = { id: string; node_type: string; label: string; component_key: string | null; level_template_version_id: string | null; music_asset_id: string | null; music_volume: number; awards_flag?: string | null }
|
||||||
|
|
||||||
|
// Grant a merit node's achievement to the player on arrival (idempotent, with node
|
||||||
|
// provenance). Called from the write paths that move current_node_id onto a node.
|
||||||
|
async function awardMeritWithin(client: PoolClient, playthroughId: string, node: { id: string; node_type: string; awards_flag?: string | null }) {
|
||||||
|
if (node.node_type !== 'merit' || !node.awards_flag) return
|
||||||
|
await client.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, node.awards_flag, node.id])
|
||||||
|
}
|
||||||
|
|
||||||
export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStorage): NarrativeRepository {
|
export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStorage): NarrativeRepository {
|
||||||
// ---- Runtime: walking the story graph -------------------------------------
|
// ---- Runtime: walking the story graph -------------------------------------
|
||||||
@@ -97,13 +109,14 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveNodeForPlay(nodeId: string, levelSlug: string | null): Promise<RuntimeNode | null> {
|
async function resolveNodeForPlay(nodeId: string, levelSlug: string | null): Promise<RuntimeNode | null> {
|
||||||
const node = (await pool.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id,music_asset_id,music_volume FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0]
|
const node = (await pool.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id,music_asset_id,music_volume,awards_flag FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0]
|
||||||
if (!node) return null
|
if (!node) return null
|
||||||
const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null
|
const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null
|
||||||
const musicVolume = node.music_volume / 100
|
const musicVolume = node.music_volume / 100
|
||||||
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key, musicUrl, musicVolume }
|
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key, musicUrl, musicVolume }
|
||||||
if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug, musicUrl, musicVolume }
|
if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug, musicUrl, musicVolume }
|
||||||
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, musicVolume, ...(await resolveDialogueGraph(node.id)) }
|
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, musicVolume, ...(await resolveDialogueGraph(node.id)) }
|
||||||
|
if (node.node_type === 'merit') return { id: node.id, kind: 'merit', label: node.label, componentKey: node.component_key, awardsFlag: node.awards_flag, musicUrl, musicVolume }
|
||||||
return null // gates are auto-resolved during advance and never surfaced
|
return null // gates are auto-resolved during advance and never surfaced
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +124,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
async function resolveThroughGates(client: PoolClient, nodeId: string | null): Promise<GraphNodeRow | null> {
|
async function resolveThroughGates(client: PoolClient, nodeId: string | null): Promise<GraphNodeRow | null> {
|
||||||
let current = nodeId
|
let current = nodeId
|
||||||
for (let guard = 0; guard < 50 && current; guard++) {
|
for (let guard = 0; guard < 50 && current; guard++) {
|
||||||
const node = (await client.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id FROM osint.story_nodes WHERE id=$1', [current])).rows[0]
|
const node = (await client.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id,awards_flag FROM osint.story_nodes WHERE id=$1', [current])).rows[0]
|
||||||
if (!node) return null
|
if (!node) return null
|
||||||
if (node.node_type !== 'det_gate' && node.node_type !== 'llm_gate') return node
|
if (node.node_type !== 'det_gate' && node.node_type !== 'llm_gate') return node
|
||||||
const next = await client.query<{ to_node_id: string | null }>('SELECT to_node_id FROM osint.story_node_terminals WHERE parent_node_id=$1 ORDER BY sort_order LIMIT 1', [current])
|
const next = await client.query<{ to_node_id: string | null }>('SELECT to_node_id FROM osint.story_node_terminals WHERE parent_node_id=$1 ORDER BY sort_order LIMIT 1', [current])
|
||||||
@@ -160,8 +173,8 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadNpc(id: string): Promise<NpcDto | null> {
|
async function loadNpc(id: string): Promise<NpcDto | null> {
|
||||||
const npc = (await pool.query<{ id: string; npc_key: string; name: string; role: string; default_pose_key: string | null }>(
|
const npc = (await pool.query<{ id: string; npc_key: string; name: string; role: string; default_pose_key: string | null; phone_number: string | null; email: string | null }>(
|
||||||
'SELECT id,npc_key,name,role,default_pose_key FROM osint.npcs WHERE id=$1 AND mystery_id IS NULL', [id])).rows[0]
|
'SELECT id,npc_key,name,role,default_pose_key,phone_number,email FROM osint.npcs WHERE id=$1 AND mystery_id IS NULL', [id])).rows[0]
|
||||||
if (!npc) return null
|
if (!npc) return null
|
||||||
const [poses, usage] = await Promise.all([
|
const [poses, usage] = await Promise.all([
|
||||||
pool.query<{ pose_key: string; asset_id: string }>('SELECT pose_key,asset_id FROM osint.npc_poses WHERE npc_id=$1 AND asset_id IS NOT NULL ORDER BY pose_key', [id]),
|
pool.query<{ pose_key: string; asset_id: string }>('SELECT pose_key,asset_id FROM osint.npc_poses WHERE npc_id=$1 AND asset_id IS NOT NULL ORDER BY pose_key', [id]),
|
||||||
@@ -169,6 +182,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
])
|
])
|
||||||
return {
|
return {
|
||||||
id: npc.id, key: npc.npc_key, name: npc.name, role: npc.role, defaultPose: npc.default_pose_key,
|
id: npc.id, key: npc.npc_key, name: npc.name, role: npc.role, defaultPose: npc.default_pose_key,
|
||||||
|
phoneNumber: npc.phone_number, email: npc.email,
|
||||||
poses: poses.rows.map(pose => ({ poseKey: pose.pose_key, assetId: pose.asset_id, url: `/api/assets/${pose.asset_id}` })),
|
poses: poses.rows.map(pose => ({ poseKey: pose.pose_key, assetId: pose.asset_id, url: `/api/assets/${pose.asset_id}` })),
|
||||||
inUse: Number(usage.rows[0].count) > 0,
|
inUse: Number(usage.rows[0].count) > 0,
|
||||||
}
|
}
|
||||||
@@ -220,6 +234,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
playthroughId = randomUUID()
|
playthroughId = randomUUID()
|
||||||
await client.query('INSERT INTO osint.playthroughs (id,user_id,mystery_id,current_node_id,current_level_id) VALUES ($1,$2,$3,$4,$5)',
|
await client.query('INSERT INTO osint.playthroughs (id,user_id,mystery_id,current_node_id,current_level_id) VALUES ($1,$2,$3,$4,$5)',
|
||||||
[playthroughId, userId, mystery.id, entry.id, levelId])
|
[playthroughId, userId, mystery.id, entry.id, levelId])
|
||||||
|
await awardMeritWithin(client, playthroughId, entry)
|
||||||
await client.query('COMMIT')
|
await client.query('COMMIT')
|
||||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
return stateForPlaythrough(playthroughId)
|
return stateForPlaythrough(playthroughId)
|
||||||
@@ -231,6 +246,47 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
return row ? stateForPlaythrough(row.id) : null
|
return row ? stateForPlaythrough(row.id) : null
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listAchievements(playthroughId) {
|
||||||
|
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
|
||||||
|
const rows = (await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1 ORDER BY flag_key', [playthroughId])).rows
|
||||||
|
return rows.map(row => row.flag_key)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Grant an achievement (idempotent). `earned` is true only on the first grant.
|
||||||
|
// The eventual server-side rule engine calls this same operation.
|
||||||
|
async awardAchievement(playthroughId, rawKey, nodeId) {
|
||||||
|
const key = rawKey.trim()
|
||||||
|
if (!/^[a-z][a-z0-9_.-]{0,63}$/.test(key)) return { ok: false, earned: false, error: 'Invalid achievement key' }
|
||||||
|
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return { ok: false, earned: false, error: 'Playthrough not found' }
|
||||||
|
const result = await pool.query(
|
||||||
|
'INSERT INTO osint.achievements (playthrough_id,flag_key,awarded_by_node_id) VALUES ($1,$2,$3) ON CONFLICT (playthrough_id,flag_key) DO NOTHING',
|
||||||
|
[playthroughId, key, nodeId || null])
|
||||||
|
return { ok: true, earned: (result.rowCount || 0) > 0 }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dev teleport: jump the playthrough straight to an explicit node (no gate
|
||||||
|
// resolution). Instantiates a fresh level clone for level nodes. Powers /node/:id.
|
||||||
|
async gotoNode(userId, playthroughId, nodeId) {
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
const playthrough = (await client.query<{ mystery_id: string; mystery_slug: string }>(
|
||||||
|
`SELECT p.mystery_id, m.slug AS mystery_slug FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id
|
||||||
|
WHERE p.id=$1 AND p.user_id=$2 FOR UPDATE OF p`, [playthroughId, userId])).rows[0]
|
||||||
|
if (!playthrough) { await client.query('ROLLBACK'); return { ok: false, error: 'Playthrough not found' } }
|
||||||
|
const node = (await client.query<{ id: string; node_type: string; level_template_version_id: string | null; awards_flag: string | null }>(
|
||||||
|
'SELECT id,node_type,level_template_version_id,awards_flag FROM osint.story_nodes WHERE id=$1 AND mystery_id=$2', [nodeId, playthrough.mystery_id])).rows[0]
|
||||||
|
if (!node) { await client.query('ROLLBACK'); return { ok: false, error: 'Node not found' } }
|
||||||
|
const levelId = node.node_type === 'level' && node.level_template_version_id
|
||||||
|
? await instantiateLevel(client, node.level_template_version_id, playthrough.mystery_slug) : null
|
||||||
|
await client.query(`UPDATE osint.playthroughs SET status='active',current_node_id=$2,current_level_id=$3,updated_at=NOW() WHERE id=$1`, [playthroughId, node.id, levelId])
|
||||||
|
await awardMeritWithin(client, playthroughId, node)
|
||||||
|
await client.query('COMMIT')
|
||||||
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
|
const state = await stateForPlaythrough(playthroughId)
|
||||||
|
return { ok: true, state: state ?? undefined }
|
||||||
|
},
|
||||||
|
|
||||||
async advancePlaythrough(userId, playthroughId, terminalKey) {
|
async advancePlaythrough(userId, playthroughId, terminalKey) {
|
||||||
const client = await pool.connect()
|
const client = await pool.connect()
|
||||||
try {
|
try {
|
||||||
@@ -254,6 +310,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
const levelId = target.node_type === 'level' && target.level_template_version_id
|
const levelId = target.node_type === 'level' && target.level_template_version_id
|
||||||
? await instantiateLevel(client, target.level_template_version_id, playthrough.mystery_slug) : null
|
? await instantiateLevel(client, target.level_template_version_id, playthrough.mystery_slug) : null
|
||||||
await client.query('UPDATE osint.playthroughs SET current_node_id=$2,current_level_id=$3,updated_at=NOW() WHERE id=$1', [playthroughId, target.id, levelId])
|
await client.query('UPDATE osint.playthroughs SET current_node_id=$2,current_level_id=$3,updated_at=NOW() WHERE id=$1', [playthroughId, target.id, levelId])
|
||||||
|
await awardMeritWithin(client, playthroughId, target)
|
||||||
}
|
}
|
||||||
await client.query('COMMIT')
|
await client.query('COMMIT')
|
||||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
@@ -261,6 +318,14 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
return { ok: true, state: state ?? undefined }
|
return { ok: true, state: state ?? undefined }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Play mode: only mysteries with an entrypoint are launchable (this filters out
|
||||||
|
// half-authored, empty ones). Returns the minimum the case picker needs.
|
||||||
|
async listPlayableMysteries() {
|
||||||
|
const result = await pool.query<{ slug: string; title: string }>(
|
||||||
|
'SELECT slug,title FROM osint.mysteries WHERE entry_node_id IS NOT NULL ORDER BY title')
|
||||||
|
return result.rows.map(row => ({ slug: row.slug, title: row.title }))
|
||||||
|
},
|
||||||
|
|
||||||
async listMysteries() {
|
async listMysteries() {
|
||||||
const result = await pool.query<{ id: string; slug: string; title: string; nodes: string }>(
|
const result = await pool.query<{ id: string; slug: string; title: string; nodes: string }>(
|
||||||
`SELECT m.id,m.slug,m.title,COUNT(n.id)::text AS nodes
|
`SELECT m.id,m.slug,m.title,COUNT(n.id)::text AS nodes
|
||||||
@@ -308,17 +373,19 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
const key = input.key.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
|
const key = input.key.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||||
if (!key) throw new Error('An NPC key is required')
|
if (!key) throw new Error('An NPC key is required')
|
||||||
const id = randomUUID()
|
const id = randomUUID()
|
||||||
await pool.query('INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key) VALUES ($1,NULL,$2,$3,$4,$5)',
|
await pool.query('INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key,phone_number,email) VALUES ($1,NULL,$2,$3,$4,$5,$6,$7)',
|
||||||
[id, key, input.name.trim() || key, input.role?.trim() || '', input.defaultPose || null])
|
[id, key, input.name.trim() || key, input.role?.trim() || '', input.defaultPose || null, input.phoneNumber?.trim() || null, input.email?.trim() || null])
|
||||||
return (await loadNpc(id))!
|
return (await loadNpc(id))!
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateNpc(id, input) {
|
async updateNpc(id, input) {
|
||||||
const existing = await loadNpc(id)
|
const existing = await loadNpc(id)
|
||||||
if (!existing) return null
|
if (!existing) return null
|
||||||
await pool.query('UPDATE osint.npcs SET name=$2,role=$3,default_pose_key=$4 WHERE id=$1 AND mystery_id IS NULL', [
|
await pool.query('UPDATE osint.npcs SET name=$2,role=$3,default_pose_key=$4,phone_number=$5,email=$6 WHERE id=$1 AND mystery_id IS NULL', [
|
||||||
id, input.name?.trim() ?? existing.name, input.role?.trim() ?? existing.role,
|
id, input.name?.trim() ?? existing.name, input.role?.trim() ?? existing.role,
|
||||||
input.defaultPose === undefined ? existing.defaultPose : (input.defaultPose || null),
|
input.defaultPose === undefined ? existing.defaultPose : (input.defaultPose || null),
|
||||||
|
input.phoneNumber === undefined ? existing.phoneNumber : (input.phoneNumber?.trim() || null),
|
||||||
|
input.email === undefined ? existing.email : (input.email?.trim() || null),
|
||||||
])
|
])
|
||||||
return loadNpc(id)
|
return loadNpc(id)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
|
||||||
|
export type TextExtractionResult = {
|
||||||
|
extractor: string
|
||||||
|
extractorVersion: string
|
||||||
|
language: string
|
||||||
|
status: 'succeeded' | 'unsupported' | 'failed'
|
||||||
|
text: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextExtractor {
|
||||||
|
provider: string
|
||||||
|
extract(file: { buffer: Buffer; mimetype: string }): Promise<TextExtractionResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainText(file: { buffer: Buffer; mimetype: string }, maximumCharacters: number): TextExtractionResult | null {
|
||||||
|
if (!file.mimetype.startsWith('text/')) return null
|
||||||
|
return { extractor: 'plain-text', extractorVersion: '1', language: 'und', status: 'succeeded', text: file.buffer.toString('utf8').slice(0, maximumCharacters) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTesseract(command: string, buffer: Buffer, languages: string, pageSegmentationMode: string, timeoutMs: number) {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const child = spawn(command, ['stdin', 'stdout', '-l', languages, '--psm', pageSegmentationMode], { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||||
|
const stdout: Buffer[] = []
|
||||||
|
const stderr: Buffer[] = []
|
||||||
|
let settled = false
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
child.kill('SIGKILL')
|
||||||
|
reject(new Error(`OCR timed out after ${timeoutMs}ms`))
|
||||||
|
}, timeoutMs)
|
||||||
|
child.stdout.on('data', chunk => stdout.push(Buffer.from(chunk)))
|
||||||
|
child.stderr.on('data', chunk => stderr.push(Buffer.from(chunk)))
|
||||||
|
child.once('error', error => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
child.once('close', code => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (code === 0) resolve(Buffer.concat(stdout).toString('utf8').trim())
|
||||||
|
else reject(new Error(Buffer.concat(stderr).toString('utf8').trim() || `OCR exited with status ${code}`))
|
||||||
|
})
|
||||||
|
child.stdin.end(buffer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTextExtractorFromEnv(): TextExtractor {
|
||||||
|
const enabled = process.env.OCR_ENABLED !== 'false'
|
||||||
|
const command = process.env.OCR_COMMAND || 'tesseract'
|
||||||
|
const languages = process.env.OCR_LANGUAGES || 'nor+eng'
|
||||||
|
const extractorVersion = process.env.OCR_ENGINE_VERSION || 'tesseract-cli-5'
|
||||||
|
const pageSegmentationMode = process.env.OCR_PAGE_SEGMENTATION_MODE || '3'
|
||||||
|
const timeoutMs = Math.max(1_000, Number(process.env.OCR_TIMEOUT_MS || 20_000))
|
||||||
|
const maximumBytes = Math.max(1, Number(process.env.MAX_OCR_BYTES || 15 * 1024 * 1024))
|
||||||
|
const maximumCharacters = Math.max(1_000, Number(process.env.MAX_EXTRACTED_TEXT_CHARACTERS || 200_000))
|
||||||
|
return {
|
||||||
|
provider: enabled ? 'tesseract' : 'disabled',
|
||||||
|
async extract(file) {
|
||||||
|
const direct = plainText(file, maximumCharacters)
|
||||||
|
if (direct) return direct
|
||||||
|
if (!file.mimetype.startsWith('image/')) return { extractor: 'none', extractorVersion: '1', language: 'und', status: 'unsupported', text: '' }
|
||||||
|
if (!enabled) return { extractor: 'tesseract', extractorVersion, language: languages, status: 'unsupported', text: '' }
|
||||||
|
if (file.buffer.byteLength > maximumBytes) return { extractor: 'tesseract', extractorVersion, language: languages, status: 'failed', text: '', error: `Image exceeds the ${maximumBytes}-byte OCR limit` }
|
||||||
|
try {
|
||||||
|
const text = (await runTesseract(command, file.buffer, languages, pageSegmentationMode, timeoutMs)).slice(0, maximumCharacters)
|
||||||
|
return { extractor: 'tesseract', extractorVersion, language: languages, status: 'succeeded', text }
|
||||||
|
} catch (error) {
|
||||||
|
return { extractor: 'tesseract', extractorVersion, language: languages, status: 'failed', text: '', error: error instanceof Error ? error.message : 'OCR failed' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,12 @@ export type GraphSpecNode = {
|
|||||||
}
|
}
|
||||||
export type GraphSpec = { entry: string; nodes: GraphSpecNode[] }
|
export type GraphSpec = { entry: string; nodes: GraphSpecNode[] }
|
||||||
|
|
||||||
export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
|
export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' | 'merit' | 'phone'
|
||||||
export type TerminalDto = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number }
|
export type TerminalDto = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number; npcId: string | null }
|
||||||
export type StoryNodeDto = {
|
export type StoryNodeDto = {
|
||||||
id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean
|
id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean
|
||||||
xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null; musicVolume: number
|
xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null; musicVolume: number
|
||||||
|
awardsFlag: string | null
|
||||||
terminals: TerminalDto[]
|
terminals: TerminalDto[]
|
||||||
}
|
}
|
||||||
export type StoryGraphDto = { mysteryId: string; entryNodeId: string | null; nodes: StoryNodeDto[] }
|
export type StoryGraphDto = { mysteryId: string; entryNodeId: string | null; nodes: StoryNodeDto[] }
|
||||||
@@ -32,15 +33,17 @@ const DEFAULT_TERMINALS: Record<StoryNodeType, { key: string; label: string }[]>
|
|||||||
level: [{ key: 'report_back', label: 'Report back' }],
|
level: [{ key: 'report_back', label: 'Report back' }],
|
||||||
det_gate: [{ key: 'pass', label: 'Pass' }],
|
det_gate: [{ key: 'pass', label: 'Pass' }],
|
||||||
llm_gate: [{ key: 'pass', label: 'Pass' }],
|
llm_gate: [{ key: 'pass', label: 'Pass' }],
|
||||||
|
merit: [{ key: 'continue', label: 'Continue' }],
|
||||||
|
phone: [], // a phone node's terminals are added per contact (each bound to an NPC)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoryGraphRepository {
|
export interface StoryGraphRepository {
|
||||||
getGraph(mysteryId: string): Promise<StoryGraphDto | null>
|
getGraph(mysteryId: string): Promise<StoryGraphDto | null>
|
||||||
createNode(mysteryId: string, input: { nodeType: StoryNodeType; xpos: number; ypos: number; label?: string }): Promise<StoryNodeDto | null>
|
createNode(mysteryId: string, input: { nodeType: StoryNodeType; xpos: number; ypos: number; label?: string }): Promise<StoryNodeDto | null>
|
||||||
updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null; musicAssetId: string | null; musicVolume: number }>): Promise<StoryNodeDto | null>
|
updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null; musicAssetId: string | null; musicVolume: number; awardsFlag: string | null }>): Promise<StoryNodeDto | null>
|
||||||
deleteNode(nodeId: string): Promise<boolean>
|
deleteNode(nodeId: string): Promise<boolean>
|
||||||
addTerminal(nodeId: string, input: { terminalKey: string; label?: string }): Promise<StoryNodeDto | null>
|
addTerminal(nodeId: string, input: { terminalKey: string; label?: string }): Promise<StoryNodeDto | null>
|
||||||
updateTerminal(terminalId: string, input: Partial<{ label: string; sortOrder: number; toNodeId: string | null }>): Promise<{ ok: boolean; error?: string }>
|
updateTerminal(terminalId: string, input: Partial<{ label: string; sortOrder: number; toNodeId: string | null; npcId: string | null }>): Promise<{ ok: boolean; error?: string }>
|
||||||
deleteTerminal(terminalId: string): Promise<boolean>
|
deleteTerminal(terminalId: string): Promise<boolean>
|
||||||
setEntryNode(mysteryId: string, nodeId: string | null): Promise<{ ok: boolean; error?: string }>
|
setEntryNode(mysteryId: string, nodeId: string | null): Promise<{ ok: boolean; error?: string }>
|
||||||
listLevelTemplates(): Promise<LevelTemplateOption[]>
|
listLevelTemplates(): Promise<LevelTemplateOption[]>
|
||||||
@@ -61,16 +64,16 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
|||||||
const mystery = await pool.query<{ id: string; entry_node_id: string | null }>('SELECT id,entry_node_id FROM osint.mysteries WHERE id=$1', [mysteryId])
|
const mystery = await pool.query<{ id: string; entry_node_id: string | null }>('SELECT id,entry_node_id FROM osint.mysteries WHERE id=$1', [mysteryId])
|
||||||
if (!mystery.rows[0]) return null
|
if (!mystery.rows[0]) return null
|
||||||
const [nodes, terminals] = await Promise.all([
|
const [nodes, terminals] = await Promise.all([
|
||||||
pool.query<{ id: string; node_type: StoryNodeType; label: string; has_utterances: boolean; xpos: number; ypos: number; level_template_version_id: string | null; component_key: string | null; music_asset_id: string | null; music_volume: number }>(
|
pool.query<{ id: string; node_type: StoryNodeType; label: string; has_utterances: boolean; xpos: number; ypos: number; level_template_version_id: string | null; component_key: string | null; music_asset_id: string | null; music_volume: number; awards_flag: string | null }>(
|
||||||
'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key,music_asset_id,music_volume FROM osint.story_nodes WHERE mystery_id=$1 ORDER BY created_at', [mysteryId]),
|
'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key,music_asset_id,music_volume,awards_flag FROM osint.story_nodes WHERE mystery_id=$1 ORDER BY created_at', [mysteryId]),
|
||||||
pool.query<{ id: string; parent_node_id: string; terminal_key: string; label: string; to_node_id: string | null; sort_order: number }>(
|
pool.query<{ id: string; parent_node_id: string; terminal_key: string; label: string; to_node_id: string | null; sort_order: number; npc_id: string | null }>(
|
||||||
`SELECT t.id,t.parent_node_id,t.terminal_key,t.label,t.to_node_id,t.sort_order FROM osint.story_node_terminals t
|
`SELECT t.id,t.parent_node_id,t.terminal_key,t.label,t.to_node_id,t.sort_order,t.npc_id FROM osint.story_node_terminals t
|
||||||
JOIN osint.story_nodes n ON n.id=t.parent_node_id WHERE n.mystery_id=$1 ORDER BY t.sort_order,t.terminal_key`, [mysteryId]),
|
JOIN osint.story_nodes n ON n.id=t.parent_node_id WHERE n.mystery_id=$1 ORDER BY t.sort_order,t.terminal_key`, [mysteryId]),
|
||||||
])
|
])
|
||||||
const byNode = new Map<string, TerminalDto[]>()
|
const byNode = new Map<string, TerminalDto[]>()
|
||||||
for (const row of terminals.rows) {
|
for (const row of terminals.rows) {
|
||||||
const list = byNode.get(row.parent_node_id) || []
|
const list = byNode.get(row.parent_node_id) || []
|
||||||
list.push({ id: row.id, terminalKey: row.terminal_key, label: row.label, toNodeId: row.to_node_id, sortOrder: row.sort_order })
|
list.push({ id: row.id, terminalKey: row.terminal_key, label: row.label, toNodeId: row.to_node_id, sortOrder: row.sort_order, npcId: row.npc_id })
|
||||||
byNode.set(row.parent_node_id, list)
|
byNode.set(row.parent_node_id, list)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -78,6 +81,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
|||||||
nodes: nodes.rows.map(row => ({
|
nodes: nodes.rows.map(row => ({
|
||||||
id: row.id, nodeType: row.node_type, label: row.label, hasUtterances: row.has_utterances,
|
id: row.id, nodeType: row.node_type, label: row.label, hasUtterances: row.has_utterances,
|
||||||
xpos: row.xpos, ypos: row.ypos, levelTemplateVersionId: row.level_template_version_id, componentKey: row.component_key, musicAssetId: row.music_asset_id, musicVolume: row.music_volume,
|
xpos: row.xpos, ypos: row.ypos, levelTemplateVersionId: row.level_template_version_id, componentKey: row.component_key, musicAssetId: row.music_asset_id, musicVolume: row.music_volume,
|
||||||
|
awardsFlag: row.awards_flag,
|
||||||
terminals: byNode.get(row.id) || [],
|
terminals: byNode.get(row.id) || [],
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
@@ -119,6 +123,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
|||||||
if (input.levelTemplateVersionId !== undefined) set('level_template_version_id', input.levelTemplateVersionId || null)
|
if (input.levelTemplateVersionId !== undefined) set('level_template_version_id', input.levelTemplateVersionId || null)
|
||||||
if (input.musicAssetId !== undefined) set('music_asset_id', input.musicAssetId || null)
|
if (input.musicAssetId !== undefined) set('music_asset_id', input.musicAssetId || null)
|
||||||
if (input.musicVolume !== undefined) set('music_volume', Math.max(0, Math.min(100, Math.round(input.musicVolume))))
|
if (input.musicVolume !== undefined) set('music_volume', Math.max(0, Math.min(100, Math.round(input.musicVolume))))
|
||||||
|
if (input.awardsFlag !== undefined) set('awards_flag', input.awardsFlag?.trim() || null)
|
||||||
if (sets.length) await pool.query(`UPDATE osint.story_nodes SET ${sets.join(',')} WHERE id=$1`, values)
|
if (sets.length) await pool.query(`UPDATE osint.story_nodes SET ${sets.join(',')} WHERE id=$1`, values)
|
||||||
const graph = await loadGraph(mysteryId)
|
const graph = await loadGraph(mysteryId)
|
||||||
return graph?.nodes.find(node => node.id === nodeId) ?? null
|
return graph?.nodes.find(node => node.id === nodeId) ?? null
|
||||||
@@ -154,6 +159,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
|||||||
if (input.label !== undefined) set('label', input.label.trim())
|
if (input.label !== undefined) set('label', input.label.trim())
|
||||||
if (input.sortOrder !== undefined) set('sort_order', input.sortOrder)
|
if (input.sortOrder !== undefined) set('sort_order', input.sortOrder)
|
||||||
if (input.toNodeId !== undefined) set('to_node_id', input.toNodeId)
|
if (input.toNodeId !== undefined) set('to_node_id', input.toNodeId)
|
||||||
|
if (input.npcId !== undefined) set('npc_id', input.npcId || null)
|
||||||
if (sets.length) await pool.query(`UPDATE osint.story_node_terminals SET ${sets.join(',')} WHERE id=$1`, values)
|
if (sets.length) await pool.query(`UPDATE osint.story_node_terminals SET ${sets.join(',')} WHERE id=$1`, values)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
},
|
},
|
||||||
|
|||||||
+198
-79
@@ -1,9 +1,7 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||||
import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView } from './types'
|
import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||||||
import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState, type PlaythroughSummary, type RuntimeNode } from './narrative'
|
|
||||||
import { AdminPanel } from './admin'
|
import { AdminPanel } from './admin'
|
||||||
import { audio } from './audio'
|
|
||||||
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
||||||
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
||||||
|
|
||||||
@@ -14,6 +12,11 @@ const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
|
|||||||
].map(value => ({ value: value as SourceFileType, label: documentWidget(value as SourceFileType).label }))
|
].map(value => ({ value: value as SourceFileType, label: documentWidget(value as SourceFileType).label }))
|
||||||
|
|
||||||
function uid(_prefix: string) { return crypto.randomUUID() }
|
function uid(_prefix: string) { return crypto.randomUUID() }
|
||||||
|
function screenshotFile(file: File, index: number) {
|
||||||
|
const extension = file.type === 'image/jpeg' ? 'jpg' : file.type === 'image/webp' ? 'webp' : 'png'
|
||||||
|
const timestamp = new Date().toISOString().replace('T', ' ').replace(/:/g, '.').slice(0, 19)
|
||||||
|
return new File([file], `Screenshot ${timestamp}${index ? ` ${index + 1}` : ''}.${extension}`, { type: file.type || 'image/png', lastModified: Date.now() })
|
||||||
|
}
|
||||||
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
|
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
|
||||||
function documentSearchText(document: CaseDocument) {
|
function documentSearchText(document: CaseDocument) {
|
||||||
return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.fileName, document.mimeType,
|
return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.fileName, document.mimeType,
|
||||||
@@ -63,11 +66,9 @@ export function App() {
|
|||||||
const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
|
const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
|
||||||
const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null)
|
const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null)
|
||||||
const [threadDraft, setThreadDraft] = useState<Connection | null>(null)
|
const [threadDraft, setThreadDraft] = useState<Connection | null>(null)
|
||||||
const [splashOpen, setSplashOpen] = useState(false)
|
const [flagsOpen, setFlagsOpen] = useState(false)
|
||||||
const [splashBusy, setSplashBusy] = useState(false)
|
const [matchRulesOpen, setMatchRulesOpen] = useState(false)
|
||||||
const [playthrough, setPlaythrough] = useState<PlaythroughSummary | null>(null)
|
const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
|
||||||
const [runtimeNode, setRuntimeNode] = useState<RuntimeNode | null>(null)
|
|
||||||
const [muted, setMuted] = useState(audio.isMuted())
|
|
||||||
const saveTimer = useRef<number | undefined>(undefined)
|
const saveTimer = useRef<number | undefined>(undefined)
|
||||||
const boardRef = useRef<HTMLDivElement>(null)
|
const boardRef = useRef<HTMLDivElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -80,6 +81,13 @@ export function App() {
|
|||||||
if (!response.ok) throw new Error('Level unavailable')
|
if (!response.ok) throw new Error('Level unavailable')
|
||||||
const data = normalizeCase(await response.json())
|
const data = normalizeCase(await response.json())
|
||||||
setCaseState(data)
|
setCaseState(data)
|
||||||
|
const arrivals = data.newlyVisibleDocumentIds || []
|
||||||
|
if (arrivals.length) {
|
||||||
|
setArrivingExhibitIds(arrivals)
|
||||||
|
void fetch(`/api/levels/${encodeURIComponent(data.id)}/reveals/seen`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: arrivals }),
|
||||||
|
})
|
||||||
|
}
|
||||||
return data
|
return data
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -88,25 +96,19 @@ export function App() {
|
|||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
|
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
|
||||||
|
|
||||||
const deepLinkLevel = params.get('level')
|
const pathLevel = window.location.pathname.match(/^\/level\/(.+)$/)
|
||||||
|
const deepLinkLevel = params.get('level') || (pathLevel ? decodeURIComponent(pathLevel[1]) : null)
|
||||||
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
|
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
|
||||||
const openLevel = async (slug: string) => {
|
const openLevel = async (slug: string) => {
|
||||||
const data = await loadLevelBySlug(slug, editQuery)
|
const data = await loadLevelBySlug(slug, editQuery)
|
||||||
if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
|
if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
|
||||||
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
|
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
|
||||||
}
|
}
|
||||||
// Player campaign entry: a live playthrough resumes silently; none shows the splash.
|
|
||||||
// An explicit ?level= deep link (admin/authoring) bypasses the campaign entirely.
|
|
||||||
const boot = async () => {
|
const boot = async () => {
|
||||||
if (deepLinkLevel) { await openLevel(deepLinkLevel); return }
|
if (deepLinkLevel) { await openLevel(deepLinkLevel); return }
|
||||||
const current = await fetch('/api/playthroughs/current')
|
const levels = await (await fetch('/api/levels')).json() as { id: string }[]
|
||||||
if (current.status === 204) { setSplashOpen(true); setStatus('AWAITING PRINCIPAL INVESTIGATOR'); return }
|
if (!levels[0]?.id) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return }
|
||||||
if (!current.ok) throw new Error('Playthrough unavailable')
|
await openLevel(levels[0].id)
|
||||||
const state: PlaythroughState = await current.json()
|
|
||||||
setPlaythrough(state.playthrough)
|
|
||||||
setRuntimeNode(state.node)
|
|
||||||
if (state.node?.kind === 'level' && state.node.levelSlug) await loadLevelBySlug(state.node.levelSlug)
|
|
||||||
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
|
|
||||||
}
|
}
|
||||||
boot().catch(async () => {
|
boot().catch(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -125,40 +127,6 @@ export function App() {
|
|||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [loadLevelBySlug, adminRoute])
|
}, [loadLevelBySlug, adminRoute])
|
||||||
|
|
||||||
const applyState = useCallback(async (state: PlaythroughState) => {
|
|
||||||
setPlaythrough(state.playthrough)
|
|
||||||
setRuntimeNode(state.node)
|
|
||||||
if (state.node?.kind === 'level' && state.node.levelSlug) await loadLevelBySlug(state.node.levelSlug)
|
|
||||||
if (!state.node && state.playthrough.status === 'finished') { setSplashOpen(true); setStatus('CASE CLOSED · GREYHAVEN FILE 87-10') }
|
|
||||||
}, [loadLevelBySlug])
|
|
||||||
|
|
||||||
const startNewGame = useCallback(async () => {
|
|
||||||
setSplashBusy(true)
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
|
||||||
if (!response.ok) throw new Error('Could not start')
|
|
||||||
await applyState(await response.json())
|
|
||||||
setSplashOpen(false)
|
|
||||||
} catch { setStatus('COULD NOT OPEN CASE FILE') } finally { setSplashBusy(false) }
|
|
||||||
}, [applyState])
|
|
||||||
|
|
||||||
// Advance the story graph through a terminal (a dialogue supplies the chosen exit;
|
|
||||||
// cutscene/level advance through the node's single terminal).
|
|
||||||
const advance = useCallback(async (terminalKey?: string) => {
|
|
||||||
if (!playthrough) return
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/playthroughs/${encodeURIComponent(playthrough.id)}/advance`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(terminalKey ? { terminalKey } : {}) })
|
|
||||||
if (!response.ok) throw new Error()
|
|
||||||
await applyState(await response.json())
|
|
||||||
} catch { setStatus('COULD NOT ADVANCE') }
|
|
||||||
}, [playthrough, applyState])
|
|
||||||
|
|
||||||
// Scene music follows the current node (null inherits; a finished playthrough stops).
|
|
||||||
useEffect(() => {
|
|
||||||
if (runtimeNode?.musicUrl) audio.setMusic(runtimeNode.musicUrl, runtimeNode.musicVolume)
|
|
||||||
else if (playthrough?.status === 'finished') audio.setMusic(null)
|
|
||||||
}, [runtimeNode, playthrough])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!adminMenuOpen) return
|
if (!adminMenuOpen) return
|
||||||
const close = (event: PointerEvent) => { if (!adminMenuRef.current?.contains(event.target as Node)) setAdminMenuOpen(false) }
|
const close = (event: PointerEvent) => { if (!adminMenuRef.current?.contains(event.target as Node)) setAdminMenuOpen(false) }
|
||||||
@@ -178,6 +146,12 @@ export function App() {
|
|||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [recentlyCreatedConnectionId])
|
}, [recentlyCreatedConnectionId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!arrivingExhibitIds.length) return
|
||||||
|
const timer = window.setTimeout(() => setArrivingExhibitIds([]), 1800)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [arrivingExhibitIds])
|
||||||
|
|
||||||
const update = useCallback((fn: (state: CaseState) => CaseState) => {
|
const update = useCallback((fn: (state: CaseState) => CaseState) => {
|
||||||
setCaseState(current => {
|
setCaseState(current => {
|
||||||
if (!current) return current
|
if (!current) return current
|
||||||
@@ -371,33 +345,65 @@ export function App() {
|
|||||||
window.location.assign(`${window.location.pathname}?${params.toString()}`)
|
window.location.assign(`${window.location.pathname}?${params.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadFiles = async (files: FileList | File[]) => {
|
const uploadFiles = useCallback(async (files: FileList | File[], source: 'file' | 'clipboard' = 'file') => {
|
||||||
if (!caseState || !requestedEditMode || !caseState.editingAllowed) return
|
if (!caseState) return
|
||||||
const queue = Array.from(files)
|
const queue = Array.from(files)
|
||||||
setUploading(queue.length)
|
setUploading(queue.length)
|
||||||
setDraggingFiles(false)
|
setDraggingFiles(false)
|
||||||
for (const file of queue) {
|
for (const [queueIndex, file] of queue.entries()) {
|
||||||
|
const position = nextOpenBoardPosition(caseState.exhibits, {
|
||||||
|
x: Math.max(100, (520 - caseState.viewport.x) / caseState.viewport.zoom) + queueIndex * 24,
|
||||||
|
y: Math.max(100, (310 - caseState.viewport.y) / caseState.viewport.zoom) + queueIndex * 24,
|
||||||
|
}, { width: 174, height: 145 })
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
|
form.append('x', String(position.x))
|
||||||
|
form.append('y', String(position.y))
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents?edit=1`, { method: 'POST', body: form })
|
const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents`, { method: 'POST', body: form })
|
||||||
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
|
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
|
||||||
const document: CaseDocument = await response.json()
|
const uploaded: UploadedCaseDocument = await response.json()
|
||||||
update(s => ({ ...s, exhibits: [...s.exhibits, document] }))
|
const { analysis, ...document } = uploaded
|
||||||
setStatus(`IMPORTED · ${file.name.toUpperCase()}`)
|
update(s => {
|
||||||
|
return { ...s, exhibits: [...s.exhibits, { ...document, ...position }] }
|
||||||
|
})
|
||||||
|
setSelected(document.id)
|
||||||
|
setArrivingExhibitIds(current => [...new Set([...current, document.id])])
|
||||||
|
void fetch(`/api/levels/${encodeURIComponent(caseState.id)}/reveals/seen`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: [document.id] }),
|
||||||
|
})
|
||||||
|
if (analysis.awardedFlags.length) {
|
||||||
|
window.clearTimeout(saveTimer.current)
|
||||||
|
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
|
||||||
|
await loadLevelBySlug(caseState.id, editQuery)
|
||||||
|
setSelected(document.id)
|
||||||
|
setStatus(`EVIDENCE MATCHED · ${analysis.awardedFlags.join(', ').toUpperCase()} · NEW MATERIAL UNLOCKED`)
|
||||||
|
} else if (analysis.matchedFlags.length) setStatus('EVIDENCE MATCHED · ACHIEVEMENT ALREADY RECORDED')
|
||||||
|
else if (source === 'clipboard' && analysis.extractionStatus === 'succeeded') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
|
||||||
|
else if (source === 'clipboard' && analysis.extractionStatus === 'failed') setStatus('SCREENSHOT SAVED · TEXT ANALYSIS UNAVAILABLE')
|
||||||
|
else setStatus(source === 'clipboard' ? 'SCREENSHOT PASTED · NEW IMAGE DOCUMENT' : `IMPORTED · ${file.name.toUpperCase()}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED')
|
setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED')
|
||||||
} finally { setUploading(count => count - 1) }
|
} finally { setUploading(count => count - 1) }
|
||||||
}
|
}
|
||||||
}
|
}, [caseState, loadLevelBySlug, requestedEditMode, update])
|
||||||
|
|
||||||
const audioToggle = playthrough ? <button className={`audio-toggle${muted ? ' muted' : ''}`} title={muted ? 'Unmute' : 'Mute'} onClick={() => setMuted(audio.toggleMute())}>♪</button> : null
|
useEffect(() => {
|
||||||
|
if (!caseState) return
|
||||||
|
const handlePaste = (event: ClipboardEvent) => {
|
||||||
|
const images = Array.from(event.clipboardData?.items || []).flatMap(item => {
|
||||||
|
const file = item.kind === 'file' && item.type.startsWith('image/') ? item.getAsFile() : null
|
||||||
|
return file ? [file] : []
|
||||||
|
})
|
||||||
|
if (!images.length) return
|
||||||
|
event.preventDefault()
|
||||||
|
void uploadFiles(images.map(screenshotFile), 'clipboard')
|
||||||
|
}
|
||||||
|
window.addEventListener('paste', handlePaste)
|
||||||
|
return () => window.removeEventListener('paste', handlePaste)
|
||||||
|
}, [caseState, uploadFiles])
|
||||||
|
|
||||||
if (adminRoute) return <AdminPanel />
|
if (adminRoute) return <AdminPanel />
|
||||||
if (splashOpen) return <SplashScreen hasResume={false} busy={splashBusy} status={status} onNewGame={startNewGame} onResume={() => setSplashOpen(false)} />
|
|
||||||
// Story-graph runtime: cutscene and dialogue nodes play full-screen (no board).
|
|
||||||
if (runtimeNode?.kind === 'cutscene') return <>{audioToggle}<CutsceneHost componentKey={runtimeNode.componentKey} label={runtimeNode.label} onComplete={() => advance()} /></>
|
|
||||||
if (runtimeNode?.kind === 'dialogue') return <>{audioToggle}<DialoguePlayer node={{ utterances: runtimeNode.utterances || [], rootId: runtimeNode.rootId ?? null }} onExit={advance} /></>
|
|
||||||
if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} />
|
if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} />
|
||||||
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
|
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
|
||||||
|
|
||||||
@@ -421,7 +427,6 @@ export function App() {
|
|||||||
})
|
})
|
||||||
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
|
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
|
||||||
return <main className="desktop">
|
return <main className="desktop">
|
||||||
{audioToggle}
|
|
||||||
<header className="menubar">
|
<header className="menubar">
|
||||||
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
||||||
<nav>
|
<nav>
|
||||||
@@ -429,13 +434,13 @@ export function App() {
|
|||||||
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button>
|
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button>
|
||||||
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
|
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
|
||||||
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||||
{runtimeNode?.kind === 'level' && <button className="report-back" onClick={() => advance()} title="Finish investigating and continue the story">REPORT BACK ▸</button>}
|
|
||||||
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
||||||
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
|
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
|
||||||
{adminMenuOpen && <div className="admin-menu-items" role="menu">
|
{adminMenuOpen && <div className="admin-menu-items" role="menu">
|
||||||
<button role="menuitem" onClick={() => window.location.assign('/admin')}>NPC & MYSTERY ADMIN</button>
|
<button role="menuitem" onClick={() => { setFlagsOpen(true); setAdminMenuOpen(false) }}>LEVEL FLAGS</button>
|
||||||
{!canAuthor ? <button role="menuitem" onClick={enterLevelEditor}>ENTER LEVEL EDITOR</button> : <>
|
{!canAuthor ? <button role="menuitem" onClick={enterLevelEditor}>ENTER LEVEL EDITOR</button> : <>
|
||||||
<button role="menuitem" onClick={() => { setEditingBrief(true); setAdminMenuOpen(false) }}>EDIT BRIEF & CONCEPTS</button>
|
<button role="menuitem" onClick={() => { setEditingBrief(true); setAdminMenuOpen(false) }}>EDIT BRIEF & CONCEPTS</button>
|
||||||
|
<button role="menuitem" onClick={() => { setMatchRulesOpen(true); setAdminMenuOpen(false) }}>EVIDENCE MATCHING</button>
|
||||||
<button role="menuitem" onClick={() => { fileInputRef.current?.click(); setAdminMenuOpen(false) }}>IMPORT DOCUMENTS</button>
|
<button role="menuitem" onClick={() => { fileInputRef.current?.click(); setAdminMenuOpen(false) }}>IMPORT DOCUMENTS</button>
|
||||||
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void saveAsTemplate() }}>SAVE AS TEMPLATE</button>
|
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void saveAsTemplate() }}>SAVE AS TEMPLATE</button>
|
||||||
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void instantiateTemplate() }}>NEW FROM TEMPLATE</button>
|
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void instantiateTemplate() }}>NEW FROM TEMPLATE</button>
|
||||||
@@ -450,9 +455,9 @@ export function App() {
|
|||||||
<aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}>
|
<aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}>
|
||||||
<div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{normalizedDocumentQuery ? `${filteredDocuments.length}/${documents.length}` : documents.length}</sup></h2></div><button aria-label="Close documents" onClick={() => setDocsOpen(false)}><X size={17}/></button></div>
|
<div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{normalizedDocumentQuery ? `${filteredDocuments.length}/${documents.length}` : documents.length}</sup></h2></div><button aria-label="Close documents" onClick={() => setDocsOpen(false)}><X size={17}/></button></div>
|
||||||
<label className="search"><Search size={15}/><input type="search" aria-label="Search inside documents" placeholder="Search inside documents…" value={documentQuery} onChange={event => setDocumentQuery(event.target.value)}/>{documentQuery && <button type="button" aria-label="Clear document search" onClick={() => setDocumentQuery('')}><X size={13}/></button>}</label>
|
<label className="search"><Search size={15}/><input type="search" aria-label="Search inside documents" placeholder="Search inside documents…" value={documentQuery} onChange={event => setDocumentQuery(event.target.value)}/>{documentQuery && <button type="button" aria-label="Clear document search" onClick={() => setDocumentQuery('')}><X size={13}/></button>}</label>
|
||||||
{canAuthor && <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}…` : 'IMPORT DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} /></>}
|
<><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}…` : 'ADD DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) void uploadFiles(e.target.files); e.target.value = '' }} /></>
|
||||||
<div className="doc-list">
|
<div className="doc-list">
|
||||||
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
|
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''} ${arrivingExhibitIds.includes(doc.id) ? 'arriving' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
|
||||||
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.fileType.slice(0, 3)}</b></div>
|
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.fileType.slice(0, 3)}</b></div>
|
||||||
<div><strong>{doc.title}</strong><span>{documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
|
<div><strong>{doc.title}</strong><span>{documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
|
||||||
</button>)}
|
</button>)}
|
||||||
@@ -461,9 +466,9 @@ export function App() {
|
|||||||
<div className="panel-foot"><FolderOpen size={15}/> ARCHIVE MOUNTED <span>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING' : 'READ ONLY'}</span></div>
|
<div className="panel-foot"><FolderOpen size={15}/> ARCHIVE MOUNTED <span>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING' : 'READ ONLY'}</span></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && canAuthor) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (canAuthor) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
|
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) void uploadFiles(e.dataTransfer.files) }}>
|
||||||
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
||||||
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} arrivingExhibitIds={arrivingExhibitIds} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
||||||
{briefOpen && <BriefPanel
|
{briefOpen && <BriefPanel
|
||||||
brief={caseState.brief}
|
brief={caseState.brief}
|
||||||
parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
|
parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
|
||||||
@@ -521,7 +526,7 @@ export function App() {
|
|||||||
setStatus('FOLDER UPDATED')
|
setStatus('FOLDER UPDATED')
|
||||||
}}
|
}}
|
||||||
/>}
|
/>}
|
||||||
{editingFileId && <FileEditor key={editingFileId} document={documents.find(document => document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>
|
{editingFileId && <FileEditor key={editingFileId} document={documents.find(document => document.id === editingFileId)!} canEditGates={canAuthor} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>
|
||||||
}
|
}
|
||||||
{editingEventId && <EventEditor
|
{editingEventId && <EventEditor
|
||||||
key={editingEventId}
|
key={editingEventId}
|
||||||
@@ -602,6 +607,8 @@ export function App() {
|
|||||||
onRemove={() => removeThread(threadDraft.id)}
|
onRemove={() => removeThread(threadDraft.id)}
|
||||||
/>}
|
/>}
|
||||||
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
|
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
|
||||||
|
{flagsOpen && <LevelFlagsEditor levelId={caseState.id} onClose={() => setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />}
|
||||||
|
{matchRulesOpen && <EvidenceMatchRulesEditor levelId={caseState.id} onClose={() => setMatchRulesOpen(false)}/>}
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,7 +627,7 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le
|
|||||||
return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main>
|
return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main>
|
||||||
}
|
}
|
||||||
|
|
||||||
function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; locatorDocumentId: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
|
function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedExhibitId, arrivingExhibitIds, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; locatorDocumentId: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; arrivingExhibitIds: string[]; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
|
||||||
const drag = useRef<{ kind: 'pan' | 'widget' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
|
const drag = useRef<{ kind: 'pan' | 'widget' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
|
||||||
const suppressClick = useRef(false)
|
const suppressClick = useRef(false)
|
||||||
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
|
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
|
||||||
@@ -808,14 +815,14 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
|
|||||||
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
|
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
|
||||||
{containmentRelations.map(relation => { const folder = byId.get(relation.fromExhibitId), document = byId.get(relation.toExhibitId); if (folder?.type !== 'folder' || document?.type !== 'document') return null; const origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={folder.isOpen ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={folder.isOpen ? document.x + document.width / 2 : origin.x} y2={folder.isOpen ? document.y + document.height / 2 : origin.y}/> })}
|
{containmentRelations.map(relation => { const folder = byId.get(relation.fromExhibitId), document = byId.get(relation.toExhibitId); if (folder?.type !== 'folder' || document?.type !== 'document') return null; const origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={folder.isOpen ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={folder.isOpen ? document.x + document.width / 2 : origin.x} y2={folder.isOpen ? document.y + document.height / 2 : origin.y}/> })}
|
||||||
</svg>
|
</svg>
|
||||||
{evidenceExhibits(state.exhibits).filter(exhibit => !exhibit.hidden).map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = byId.get(id); return document?.type === 'document' ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !ev.isOpen && containedDocuments.some(document => document.id === locatorDocumentId) ? locatorDocumentId : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} tabIndex={ev.type === 'folder' ? 0 : undefined} aria-expanded={ev.type === 'folder' ? ev.isOpen : undefined} title={ev.type === 'folder' ? 'Double-click or hold to open or close this folder' : undefined} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }}
|
{evidenceExhibits(state.exhibits).filter(exhibit => !exhibit.hidden).map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = byId.get(id); return document?.type === 'document' ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !ev.isOpen && containedDocuments.some(document => document.id === locatorDocumentId) ? locatorDocumentId : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; const containsArrival = ev.type === 'folder' && containedDocuments.some(document => arrivingExhibitIds.includes(document.id)); return <article key={ev.id} tabIndex={ev.type === 'folder' ? 0 : undefined} aria-expanded={ev.type === 'folder' ? ev.isOpen : undefined} title={ev.type === 'folder' ? 'Double-click or hold to open or close this folder' : undefined} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id || arrivingExhibitIds.includes(ev.id) || containsArrival ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }}
|
||||||
onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (ev.type === 'folder') startFolderLongPress(e, ev.id); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
|
onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (ev.type === 'folder') startFolderLongPress(e, ev.id); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
|
||||||
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
|
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
|
||||||
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (ev.type === 'folder' && e.detail > 1) return; if (tool === 'move') onCardClick(ev.id) }} onDoubleClick={e => { e.stopPropagation(); if (ev.type === 'folder' && tool === 'move' && !linkFrom && !(e.target as HTMLElement).closest('button')) toggleFolder(ev.id) }} onKeyDown={e => { if (ev.type === 'folder' && e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); toggleFolder(ev.id) } }}>
|
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (ev.type === 'folder' && e.detail > 1) return; if (tool === 'move') onCardClick(ev.id) }} onDoubleClick={e => { e.stopPropagation(); if (ev.type === 'folder' && tool === 'move' && !linkFrom && !(e.target as HTMLElement).closest('button')) toggleFolder(ev.id) }} onKeyDown={e => { if (ev.type === 'folder' && e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); toggleFolder(ev.id) } }}>
|
||||||
<header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
|
<header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
|
||||||
<Widget exhibit={ev} context={widgetContext}/>
|
<Widget exhibit={ev} context={widgetContext}/>
|
||||||
</article>})}
|
</article>})}
|
||||||
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
|
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
|
||||||
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
|
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
|
||||||
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
|
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
|
||||||
<header><span>{definition.label.toUpperCase()}</span><i>{String((membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
<header><span>{definition.label.toUpperCase()}</span><i>{String((membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
||||||
@@ -1072,15 +1079,18 @@ function EventEditor({ event, exhibits, relations, onClose, onSave }: { event: E
|
|||||||
</form></div>
|
</form></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onClose: () => void; onSave: (document: CaseDocument) => void }) {
|
function FileEditor({ document, canEditGates, onClose, onSave }: { document: CaseDocument; canEditGates: boolean; onClose: () => void; onSave: (document: CaseDocument) => void }) {
|
||||||
const [title, setTitle] = useState(document.title)
|
const [title, setTitle] = useState(document.title)
|
||||||
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
|
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
|
||||||
const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt))
|
const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt))
|
||||||
|
const [requiredFlags, setRequiredFlags] = useState((document.requiredFlags || []).join(', '))
|
||||||
const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value })))
|
const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value })))
|
||||||
const submit = (event: React.FormEvent) => {
|
const submit = (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const publishedAt = publishedTime ? new Date(publishedTime).toISOString() : undefined
|
const publishedAt = publishedTime ? new Date(publishedTime).toISOString() : undefined
|
||||||
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt, metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
|
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt,
|
||||||
|
requiredFlags: canEditGates ? [...new Set(requiredFlags.split(',').map(value => value.trim().toLowerCase()).filter(Boolean))] : document.requiredFlags,
|
||||||
|
metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
|
||||||
}
|
}
|
||||||
return <div className="modal-shade"><form className="window file-editor" onSubmit={submit}>
|
return <div className="modal-shade"><form className="window file-editor" onSubmit={submit}>
|
||||||
<header><ImageIcon size={16}/><b>Edit source-file metadata</b><span/><button type="button" aria-label="Close file editor" onClick={onClose}><X size={14}/></button></header>
|
<header><ImageIcon size={16}/><b>Edit source-file metadata</b><span/><button type="button" aria-label="Close file editor" onClick={onClose}><X size={14}/></button></header>
|
||||||
@@ -1091,6 +1101,7 @@ function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onC
|
|||||||
<label className="field"><span>FILE TYPE</span><select value={fileType} onChange={event => setFileType(event.target.value as SourceFileType)}>{SOURCE_FILE_TYPES.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
|
<label className="field"><span>FILE TYPE</span><select value={fileType} onChange={event => setFileType(event.target.value as SourceFileType)}>{SOURCE_FILE_TYPES.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
|
||||||
</div>
|
</div>
|
||||||
<label className="field"><span><CalendarClock size={13}/> PUBLISHED TIME · LOCAL</span><input type="datetime-local" value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
|
<label className="field"><span><CalendarClock size={13}/> PUBLISHED TIME · LOCAL</span><input type="datetime-local" value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
|
||||||
|
{canEditGates && <label className="field gate-field"><span>REVEAL FLAGS · ALL REQUIRED</span><input value={requiredFlags} placeholder="tip.received, archive.unlocked" pattern="[a-z0-9_.\-, ]*" onChange={event => setRequiredFlags(event.target.value)}/><small>Leave blank to show this document when the level first loads.</small></label>}
|
||||||
<div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div>
|
<div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div>
|
||||||
<div className="metadata-rows">{metadata.length === 0 && <p>NO ADDITIONAL METADATA</p>}{metadata.map(row => <div className="metadata-row" key={row.id}><input aria-label="Metadata key" placeholder="FIELD" value={row.key} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, key: event.target.value } : candidate))}/><input aria-label="Metadata value" placeholder="VALUE" value={row.value} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, value: event.target.value } : candidate))}/><button type="button" aria-label="Remove metadata field" onClick={() => setMetadata(rows => rows.filter(candidate => candidate.id !== row.id))}><Trash2 size={13}/></button></div>)}</div>
|
<div className="metadata-rows">{metadata.length === 0 && <p>NO ADDITIONAL METADATA</p>}{metadata.map(row => <div className="metadata-row" key={row.id}><input aria-label="Metadata key" placeholder="FIELD" value={row.key} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, key: event.target.value } : candidate))}/><input aria-label="Metadata value" placeholder="VALUE" value={row.value} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, value: event.target.value } : candidate))}/><button type="button" aria-label="Remove metadata field" onClick={() => setMetadata(rows => rows.filter(candidate => candidate.id !== row.id))}><Trash2 size={13}/></button></div>)}</div>
|
||||||
<p className="folder-editor-note">This metadata belongs to the source file, not to any folder that contains it.</p>
|
<p className="folder-editor-note">This metadata belongs to the source file, not to any folder that contains it.</p>
|
||||||
@@ -1099,6 +1110,114 @@ function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onC
|
|||||||
</form></div>
|
</form></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LevelFlagsEditor({ levelId, onClose, onChanged }: { levelId: string; onClose: () => void; onChanged: () => void | Promise<void> }) {
|
||||||
|
const [flags, setFlags] = useState<LevelFlag[]>([])
|
||||||
|
const [newKey, setNewKey] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/flags`)
|
||||||
|
if (!response.ok) throw new Error('Could not load level flags')
|
||||||
|
setFlags(await response.json())
|
||||||
|
}, [levelId])
|
||||||
|
useEffect(() => { void load().catch(error => setError(error instanceof Error ? error.message : 'Could not load flags')) }, [load])
|
||||||
|
const setEarned = async (key: string, earned: boolean) => {
|
||||||
|
setBusy(true); setError('')
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/flags/${encodeURIComponent(key)}`, { method: earned ? 'PUT' : 'DELETE' })
|
||||||
|
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || 'Could not update flag') }
|
||||||
|
await Promise.all([load(), onChanged()])
|
||||||
|
setNewKey('')
|
||||||
|
} catch (error) { setError(error instanceof Error ? error.message : 'Could not update flag') } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const submit = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const key = newKey.trim().toLowerCase()
|
||||||
|
if (key) void setEarned(key, true)
|
||||||
|
}
|
||||||
|
return <div className="modal-shade"><section className="window flags-editor">
|
||||||
|
<header><Network size={16}/><b>Level flags</b><span/><button type="button" aria-label="Close level flags" onClick={onClose}><X size={14}/></button></header>
|
||||||
|
<div className="flags-editor-body"><small>ACHIEVEMENTS / DOCUMENT REVEALS</small>
|
||||||
|
<p>Documents remain server-hidden until every flag assigned in their metadata has been earned.</p>
|
||||||
|
<div className="flag-list">{flags.length === 0 && <div className="flag-empty">NO FLAGS OR DOCUMENT GATES IN THIS LEVEL</div>}{flags.map(flag => <div className={`flag-row ${flag.earnedAt ? 'earned' : ''}`} key={flag.key}><div><b>{flag.key}</b><small>{flag.gatedDocumentCount} GATED DOCUMENT{flag.gatedDocumentCount === 1 ? '' : 'S'}</small></div><button disabled={busy} onClick={() => void setEarned(flag.key, !flag.earnedAt)}>{flag.earnedAt ? 'REVOKE' : 'AWARD'}</button></div>)}</div>
|
||||||
|
<form className="flag-add" onSubmit={submit}><input aria-label="New flag key" value={newKey} placeholder="tip.received" pattern="[a-z][a-z0-9_.-]{0,63}" onChange={event => setNewKey(event.target.value.toLowerCase())}/><button disabled={busy || !newKey.trim()} type="submit">AWARD FLAG</button></form>
|
||||||
|
{error && <p className="flag-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</section></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchRuleDraft = {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
flagKey: string
|
||||||
|
minimumAnchorMatches: number
|
||||||
|
enabled: boolean
|
||||||
|
anchors: { id: string; phrase: string; minimumSimilarity: number }[]
|
||||||
|
}
|
||||||
|
const emptyMatchRule = (): MatchRuleDraft => ({ name: '', flagKey: '', minimumAnchorMatches: 1, enabled: true,
|
||||||
|
anchors: [{ id: uid('anchor'), phrase: '', minimumSimilarity: 0.72 }] })
|
||||||
|
|
||||||
|
function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClose: () => void }) {
|
||||||
|
const [rules, setRules] = useState<EvidenceMatchRuleDefinition[]>([])
|
||||||
|
const [draft, setDraft] = useState<MatchRuleDraft>(emptyMatchRule)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules`)
|
||||||
|
if (!response.ok) throw new Error('Could not load evidence match rules')
|
||||||
|
setRules(await response.json())
|
||||||
|
}, [levelId])
|
||||||
|
useEffect(() => { void load().catch(error => setError(error instanceof Error ? error.message : 'Could not load rules')) }, [load])
|
||||||
|
const edit = (rule: EvidenceMatchRuleDefinition) => setDraft({ id:rule.id,name:rule.name,flagKey:rule.flagKey,
|
||||||
|
minimumAnchorMatches:rule.minimumAnchorMatches,enabled:rule.enabled,
|
||||||
|
anchors:rule.anchors.map(anchor => ({ id:anchor.id,phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) })
|
||||||
|
const submit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault(); setBusy(true); setError('')
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules${draft.id ? `/${encodeURIComponent(draft.id)}` : ''}`, {
|
||||||
|
method: draft.id ? 'PUT' : 'POST', headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name:draft.name,flagKey:draft.flagKey.toLowerCase(),minimumAnchorMatches:draft.minimumAnchorMatches,
|
||||||
|
enabled:draft.enabled,anchors:draft.anchors.map(anchor => ({ phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) }),
|
||||||
|
})
|
||||||
|
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || 'Could not save evidence rule') }
|
||||||
|
await load(); setDraft(emptyMatchRule())
|
||||||
|
} catch (error) { setError(error instanceof Error ? error.message : 'Could not save evidence rule') } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const remove = async (rule: EvidenceMatchRuleDefinition) => {
|
||||||
|
if (!window.confirm(`Delete evidence match rule “${rule.name}”?`)) return
|
||||||
|
setBusy(true); setError('')
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules/${encodeURIComponent(rule.id)}`, { method:'DELETE' })
|
||||||
|
if (!response.ok) throw new Error('Could not delete evidence rule')
|
||||||
|
await load(); if (draft.id === rule.id) setDraft(emptyMatchRule())
|
||||||
|
} catch (error) { setError(error instanceof Error ? error.message : 'Could not delete evidence rule') } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const validAnchors = draft.anchors.filter(anchor => anchor.phrase.trim().length >= 12)
|
||||||
|
const canSave = draft.name.trim() && /^[a-z][a-z0-9_.-]{0,63}$/.test(draft.flagKey) && validAnchors.length === draft.anchors.length
|
||||||
|
&& draft.minimumAnchorMatches >= 1 && draft.minimumAnchorMatches <= draft.anchors.length
|
||||||
|
return <div className="modal-shade"><section className="window match-rules-editor">
|
||||||
|
<header><Search size={16}/><b>Evidence text matching</b><span/><button type="button" aria-label="Close evidence matching" onClick={onClose}><X size={14}/></button></header>
|
||||||
|
<div className="match-rules-body"><small>OCR / FUZZY PASSAGE RULES</small>
|
||||||
|
<p>When OCR from a player-uploaded source matches enough distinctive passages, the configured flag is awarded. Matching ignores case, punctuation, accents, and ordinary OCR noise.</p>
|
||||||
|
<div className="match-rule-layout"><div className="match-rule-list">
|
||||||
|
{rules.length === 0 && <div className="flag-empty">NO AUTOMATIC EVIDENCE RULES</div>}
|
||||||
|
{rules.map(rule => <div className={`match-rule-row ${rule.enabled ? '' : 'disabled'}`} key={rule.id}><div><b>{rule.name}</b><small>{rule.flagKey} · {rule.minimumAnchorMatches}/{rule.anchors.length} ANCHORS</small></div><button type="button" onClick={() => edit(rule)}>EDIT</button><button type="button" disabled={busy} onClick={() => void remove(rule)}><Trash2 size={12}/></button></div>)}
|
||||||
|
</div>
|
||||||
|
<form className="match-rule-form" onSubmit={event => void submit(event)}>
|
||||||
|
<div className="match-rule-form-heading"><b>{draft.id ? 'EDIT RULE' : 'NEW RULE'}</b>{draft.id && <button type="button" onClick={() => setDraft(emptyMatchRule())}>NEW</button>}</div>
|
||||||
|
<label className="field"><span>RULE NAME</span><input value={draft.name} maxLength={160} onChange={event => setDraft(value => ({ ...value,name:event.target.value }))} placeholder="Contemporary fire report"/></label>
|
||||||
|
<div className="match-rule-fields"><label className="field"><span>AWARD FLAG</span><input value={draft.flagKey} pattern="[a-z][a-z0-9_.-]{0,63}" onChange={event => setDraft(value => ({ ...value,flagKey:event.target.value.toLowerCase() }))} placeholder="source.fire-report"/></label>
|
||||||
|
<label className="field"><span>REQUIRED HITS</span><input type="number" min="1" max={draft.anchors.length} value={draft.minimumAnchorMatches} onChange={event => setDraft(value => ({ ...value,minimumAnchorMatches:Number(event.target.value) }))}/></label></div>
|
||||||
|
<label className="match-rule-enabled"><input type="checkbox" checked={draft.enabled} onChange={event => setDraft(value => ({ ...value,enabled:event.target.checked }))}/> ENABLE THIS RULE</label>
|
||||||
|
<div className="anchor-heading"><b>REFERENCE PASSAGES</b><button type="button" onClick={() => setDraft(value => ({ ...value,anchors:[...value.anchors,{ id:uid('anchor'),phrase:'',minimumSimilarity:.72 }] }))}><Plus size={12}/> ADD PASSAGE</button></div>
|
||||||
|
<div className="anchor-list">{draft.anchors.map((anchor,index) => <div className="anchor-row" key={anchor.id}><div><small>ANCHOR {index + 1}</small><textarea value={anchor.phrase} rows={3} placeholder="Paste a distinctive passage of at least 12 characters…" onChange={event => setDraft(value => ({ ...value,anchors:value.anchors.map(item => item.id === anchor.id ? { ...item,phrase:event.target.value } : item) }))}/></div><label><span>SIMILARITY</span><input type="number" min="0.5" max="1" step="0.01" value={anchor.minimumSimilarity} onChange={event => setDraft(value => ({ ...value,anchors:value.anchors.map(item => item.id === anchor.id ? { ...item,minimumSimilarity:Number(event.target.value) } : item) }))}/></label><button type="button" aria-label="Remove reference passage" disabled={draft.anchors.length === 1} onClick={() => setDraft(value => ({ ...value,minimumAnchorMatches:Math.min(value.minimumAnchorMatches,value.anchors.length - 1),anchors:value.anchors.filter(item => item.id !== anchor.id) }))}><Trash2 size={13}/></button></div>)}</div>
|
||||||
|
{error && <p className="flag-error">{error}</p>}
|
||||||
|
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CLOSE</button><button className="primary" type="submit" disabled={busy || !canSave}>{busy ? 'SAVING…' : 'SAVE RULE'}</button></div>
|
||||||
|
</form></div>
|
||||||
|
</div>
|
||||||
|
</section></div>
|
||||||
|
}
|
||||||
|
|
||||||
function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocument; onClose: () => void; onExtract: (id: string) => void; extracted: (string | undefined)[] }) {
|
function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocument; onClose: () => void; onExtract: (id: string) => void; extracted: (string | undefined)[] }) {
|
||||||
const [pos, setPos] = useState({ x: Math.max(280, window.innerWidth * .34), y: 118 })
|
const [pos, setPos] = useState({ x: Math.max(280, window.innerWidth * .34), y: 118 })
|
||||||
const [minimized, setMinimized] = useState(false)
|
const [minimized, setMinimized] = useState(false)
|
||||||
|
|||||||
+4
-1
@@ -210,6 +210,7 @@ type LegacyCaseState = {
|
|||||||
sourceTemplateVersionId?: string
|
sourceTemplateVersionId?: string
|
||||||
editingAllowed?: boolean
|
editingAllowed?: boolean
|
||||||
revision?: number
|
revision?: number
|
||||||
|
newlyVisibleDocumentIds?: string[]
|
||||||
exhibits?: Exhibit[]
|
exhibits?: Exhibit[]
|
||||||
views?: BoardView[]
|
views?: BoardView[]
|
||||||
documents?: Array<Record<string, unknown>>
|
documents?: Array<Record<string, unknown>>
|
||||||
@@ -240,6 +241,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
|||||||
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
|
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
|
||||||
exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit, ...placement(exhibit as unknown as Record<string, unknown>, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })),
|
exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit, ...placement(exhibit as unknown as Record<string, unknown>, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })),
|
||||||
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
|
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
|
||||||
|
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,5 +279,6 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
|||||||
fromExhibitId: String(connection.fromExhibitId || connection.fromEvidenceId), toExhibitId: String(connection.toExhibitId || connection.toEvidenceId) } as Connection))
|
fromExhibitId: String(connection.fromExhibitId || connection.fromEvidenceId), toExhibitId: String(connection.toExhibitId || connection.toEvidenceId) } as Connection))
|
||||||
return { id: state.id, title: state.title, subtitle: state.subtitle, exhibits: [...documents, ...evidence], relations: derivedRelations, connections,
|
return { id: state.id, title: state.title, subtitle: state.subtitle, exhibits: [...documents, ...evidence], relations: derivedRelations, connections,
|
||||||
views: [defaultTimelineView(state.timelineRange)], viewport: state.viewport, brief: state.brief || { body: '', concepts: [] }, revision: Number(state.revision || 0),
|
views: [defaultTimelineView(state.timelineRange)], viewport: state.viewport, brief: state.brief || { body: '', concepts: [] }, revision: Number(state.revision || 0),
|
||||||
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed }
|
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
|
||||||
|
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [] }
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -1,13 +1,18 @@
|
|||||||
import { StrictMode, Suspense, lazy } from 'react'
|
import { StrictMode, Suspense, lazy } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { App } from './App'
|
import { App } from './App'
|
||||||
|
import { Play } from './play'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
// three.js is lazy-loaded so the board never pays for it up front.
|
// three.js is lazy-loaded so the board never pays for it up front.
|
||||||
const PhonePreview = lazy(() => import('./phone').then(m => ({ default: m.PhonePreview })))
|
const PhonePreview = lazy(() => import('./phone').then(m => ({ default: m.PhonePreview })))
|
||||||
|
|
||||||
// Visual spike: /?phone=1 renders the handset standalone, isolated from the board.
|
// Routing: the bare root is the game's front door (splash + campaign); /level/:id,
|
||||||
const root = new URLSearchParams(window.location.search).has('phone')
|
// /admin, and the legacy ?level= deep link open the board; ?phone=1 is the spike.
|
||||||
|
const path = window.location.pathname
|
||||||
|
const search = new URLSearchParams(window.location.search)
|
||||||
|
const isBoard = path.startsWith('/level/') || path === '/admin' || search.has('level')
|
||||||
|
const root = search.has('phone')
|
||||||
? <Suspense fallback={null}><PhonePreview /></Suspense>
|
? <Suspense fallback={null}><PhonePreview /></Suspense>
|
||||||
: <App />
|
: isBoard ? <App /> : <Play />
|
||||||
createRoot(document.getElementById('root')!).render(<StrictMode>{root}</StrictMode>)
|
createRoot(document.getElementById('root')!).render(<StrictMode>{root}</StrictMode>)
|
||||||
|
|||||||
+20
-1
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState, type FC } from 'react'
|
|||||||
import { audio } from './audio'
|
import { audio } from './audio'
|
||||||
|
|
||||||
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null }
|
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null }
|
||||||
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number; utterances?: RuntimeUtterance[]; rootId?: string | null }
|
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number; awardsFlag?: string | null; utterances?: RuntimeUtterance[]; rootId?: string | null }
|
||||||
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
|
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
|
||||||
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
|
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
|
||||||
|
|
||||||
@@ -48,6 +48,25 @@ const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) =
|
|||||||
const CUTSCENE_REGISTRY: Record<string, FC<{ onComplete: () => void }>> = { 'glass-harbour-diversion': GlassHarbourDiversion }
|
const CUTSCENE_REGISTRY: Record<string, FC<{ onComplete: () => void }>> = { 'glass-harbour-diversion': GlassHarbourDiversion }
|
||||||
export const CUTSCENE_COMPONENT_KEYS = Object.keys(CUTSCENE_REGISTRY)
|
export const CUTSCENE_COMPONENT_KEYS = Object.keys(CUTSCENE_REGISTRY)
|
||||||
|
|
||||||
|
// Merit ceremony components, keyed by a merit node's component_key (e.g. a 3D
|
||||||
|
// award model). The achievement itself is granted server-side on arrival; this is
|
||||||
|
// purely the presentation of receiving it.
|
||||||
|
const MERIT_REGISTRY: Record<string, FC<{ label: string; onComplete: () => void }>> = {}
|
||||||
|
export const MERIT_COMPONENT_KEYS = Object.keys(MERIT_REGISTRY)
|
||||||
|
|
||||||
|
export function MeritHost({ componentKey, label, awardsFlag, onComplete }: { componentKey: string | null | undefined; label: string; awardsFlag?: string | null; onComplete: () => void }) {
|
||||||
|
const Component = componentKey ? MERIT_REGISTRY[componentKey] : undefined
|
||||||
|
if (Component) return <Component label={label} onComplete={onComplete} />
|
||||||
|
return <div className="cutscene-card merit-card" onClick={onComplete}>
|
||||||
|
<div className="title-card-inner">
|
||||||
|
<small className="merit-eyebrow">◆ MERIT AWARDED ◆</small>
|
||||||
|
<h1>{label}</h1>
|
||||||
|
{awardsFlag && <p className="merit-flag">🏅 {awardsFlag}</p>}
|
||||||
|
<button className="cutscene-begin" onClick={event => { event.stopPropagation(); onComplete() }}>Accept ▸</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
export function CutsceneHost({ componentKey, label, onComplete }: { componentKey: string | null | undefined; label: string; onComplete: () => void }) {
|
export function CutsceneHost({ componentKey, label, onComplete }: { componentKey: string | null | undefined; label: string; onComplete: () => void }) {
|
||||||
const Component = componentKey ? CUTSCENE_REGISTRY[componentKey] : undefined
|
const Component = componentKey ? CUTSCENE_REGISTRY[componentKey] : undefined
|
||||||
if (Component) return <Component onComplete={onComplete} />
|
if (Component) return <Component onComplete={onComplete} />
|
||||||
|
|||||||
+51
-11
@@ -228,16 +228,16 @@ function PhoneDevice({ open, onKey }: { open: boolean; onKey: (k: string) => voi
|
|||||||
return <div ref={stageRef} className="phone-canvas" />
|
return <div ref={stageRef} className="phone-canvas" />
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- directory (spike stub) ---------------------------------------------------
|
// ---- directory ----------------------------------------------------------------
|
||||||
// Real version: numbers are the phone node's terminals -> dialogue nodes; `requires`
|
// The number->node directory stays authored config for now; `requires` are the
|
||||||
// are the target node's flag requirements, checked against the playthrough flags.
|
// target node's flag requirements, checked live against the player's ACHIEVEMENTS
|
||||||
|
// (the real playthrough case-state). Wiring numbers to actual dialogue nodes and
|
||||||
|
// moving the directory server-side is the next slice.
|
||||||
type Contact = { name: string; requires: string[] }
|
type Contact = { name: string; requires: string[] }
|
||||||
const DIRECTORY: Record<string, Contact> = {
|
const DIRECTORY: Record<string, Contact> = {
|
||||||
'55501': { name: 'Elias Board', requires: [] }, // always enabled -> connects
|
'55501': { name: 'Elias Board', requires: ['elias_number_callable'] }, // dev-grant unlocks -> connects
|
||||||
'55502': { name: 'Voss Antiquities', requires: ['found_voss_number'] }, // enabled below -> connects
|
'55502': { name: 'Voss Antiquities', requires: ['voss_number_known'] }, // not earned -> voicemail
|
||||||
'55503': { name: 'Preservation Soc.', requires: ['society_clearance'] }, // not enabled -> voicemail
|
|
||||||
}
|
}
|
||||||
const FLAGS = new Set<string>(['found_voss_number']) // toggle to demo connect vs. voicemail
|
|
||||||
|
|
||||||
type Mode = 'home' | 'dial' | 'calling' | 'unknown' | 'voicemail' | 'connected'
|
type Mode = 'home' | 'dial' | 'calling' | 'unknown' | 'voicemail' | 'connected'
|
||||||
|
|
||||||
@@ -270,6 +270,9 @@ export function PhonePreview() {
|
|||||||
const [mode, setMode] = useState<Mode>('home')
|
const [mode, setMode] = useState<Mode>('home')
|
||||||
const [dialed, setDialed] = useState('')
|
const [dialed, setDialed] = useState('')
|
||||||
const [callee, setCallee] = useState('')
|
const [callee, setCallee] = useState('')
|
||||||
|
const [playthroughId, setPlaythroughId] = useState<string | null>(null)
|
||||||
|
const [achieved, setAchieved] = useState<Set<string>>(new Set())
|
||||||
|
const [called, setCalled] = useState<Set<string>>(new Set())
|
||||||
const callTimer = useRef<number | undefined>(undefined)
|
const callTimer = useRef<number | undefined>(undefined)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -277,6 +280,38 @@ export function PhonePreview() {
|
|||||||
setScreenOn(false); setMode('home'); setDialed('')
|
setScreenOn(false); setMode('home'); setDialed('')
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
const refreshAchievements = async (id: string) => {
|
||||||
|
const res = await fetch(`/api/playthroughs/${id}/achievements`)
|
||||||
|
if (res.ok) setAchieved(new Set(await res.json() as string[]))
|
||||||
|
}
|
||||||
|
// Attach to the player's live playthrough (or create one) and load its case-state.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
let id: string | null = null
|
||||||
|
const cur = await fetch('/api/playthroughs/current')
|
||||||
|
if (cur.ok && cur.status !== 204) id = (await cur.json())?.playthrough?.id ?? null
|
||||||
|
if (!id) {
|
||||||
|
const made = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: 'glass-harbor' }) })
|
||||||
|
if (made.ok) id = (await made.json())?.playthrough?.id ?? null
|
||||||
|
}
|
||||||
|
if (cancelled || !id) return
|
||||||
|
setPlaythroughId(id)
|
||||||
|
await refreshAchievements(id)
|
||||||
|
})()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const connectable = (num: string) => { const c = DIRECTORY[num]; return c ? c.requires.every(f => achieved.has(f)) : false }
|
||||||
|
// Glow the handset when an enabled, not-yet-called number is waiting.
|
||||||
|
const glow = Object.keys(DIRECTORY).some(num => connectable(num) && !called.has(num))
|
||||||
|
|
||||||
|
const grantElias = async () => {
|
||||||
|
if (!playthroughId) return
|
||||||
|
await fetch(`/api/playthroughs/${playthroughId}/achievements`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ flagKey: 'elias_number_callable' }) })
|
||||||
|
await refreshAchievements(playthroughId)
|
||||||
|
}
|
||||||
|
|
||||||
const resolveCall = (num: string) => {
|
const resolveCall = (num: string) => {
|
||||||
sfx.ring()
|
sfx.ring()
|
||||||
setMode('calling')
|
setMode('calling')
|
||||||
@@ -285,8 +320,8 @@ export function PhonePreview() {
|
|||||||
callTimer.current = window.setTimeout(() => {
|
callTimer.current = window.setTimeout(() => {
|
||||||
if (!contact) { sfx.unobtainable(); setMode('unknown'); return }
|
if (!contact) { sfx.unobtainable(); setMode('unknown'); return }
|
||||||
setCallee(contact.name)
|
setCallee(contact.name)
|
||||||
const enabled = contact.requires.every(f => FLAGS.has(f))
|
setCalled(prev => new Set(prev).add(num))
|
||||||
if (enabled) setMode('connected')
|
if (connectable(num)) setMode('connected')
|
||||||
else { sfx.voicemail(); setMode('voicemail') }
|
else { sfx.voicemail(); setMode('voicemail') }
|
||||||
}, 950)
|
}, 950)
|
||||||
}
|
}
|
||||||
@@ -319,7 +354,12 @@ export function PhonePreview() {
|
|||||||
<PhoneDevice open={open} onKey={press} />
|
<PhoneDevice open={open} onKey={press} />
|
||||||
<PhoneScreen visible={screenOn} mode={mode} dialed={dialed} callee={callee} />
|
<PhoneScreen visible={screenOn} mode={mode} dialed={dialed} callee={callee} />
|
||||||
</div>
|
</div>
|
||||||
<button className="phone-open-btn" onClick={() => setOpen(o => !o)}>{open ? 'CLOSE' : 'OPEN'}</button>
|
<button className={`phone-open-btn${glow && !open ? ' glow' : ''}`} onClick={() => setOpen(o => !o)}>{open ? 'CLOSE' : 'OPEN'}</button>
|
||||||
<p className="phone-hint">spike — dial <code>55501</code> connects · <code>55503</code> voicemail · anything else unobtainable</p>
|
<div className="phone-dev">
|
||||||
|
<button className="phone-dev-btn" disabled={!playthroughId || achieved.has('elias_number_callable')} onClick={grantElias}>
|
||||||
|
{achieved.has('elias_number_callable') ? '✓ elias_number_callable' : '▸ grant elias_number_callable'}
|
||||||
|
</button>
|
||||||
|
<p className="phone-hint">dial <code>55501</code> Elias (voicemail → connect once granted) · <code>55502</code> Voss (voicemail) · else unobtainable</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { CutsceneHost, DialoguePlayer, MeritHost, type PlaythroughState } from './narrative'
|
||||||
|
|
||||||
|
type MysterySummary = { slug: string; title: string }
|
||||||
|
|
||||||
|
// The game's front door and campaign runtime. Shows the splash when there's no
|
||||||
|
// active playthrough; otherwise walks the story graph — cutscene and dialogue
|
||||||
|
// nodes render here, and when the graph reaches a LEVEL node it hands off to the
|
||||||
|
// board route (/level/<clone id>), which App renders.
|
||||||
|
export function Play() {
|
||||||
|
const [state, setState] = useState<PlaythroughState | null>(null)
|
||||||
|
const [resumable, setResumable] = useState<PlaythroughState | null>(null)
|
||||||
|
const [mysteries, setMysteries] = useState<MysterySummary[]>([])
|
||||||
|
const [splash, setSplash] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
|
||||||
|
const apply = useCallback((next: PlaythroughState | null) => {
|
||||||
|
if (!next) { setSplash(true); return }
|
||||||
|
if (next.node?.kind === 'level' && next.node.levelSlug) { window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`); return }
|
||||||
|
if (!next.node) { setState(null); setSplash(true); setStatus('CASE CLOSED — begin another'); return }
|
||||||
|
setState(next); setSplash(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const ensurePlaythroughId = async (): Promise<string | null> => {
|
||||||
|
const cur = await fetch('/api/playthroughs/current')
|
||||||
|
if (cur.ok && cur.status !== 204) return (await cur.json())?.playthrough?.id ?? null
|
||||||
|
const made = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: 'glass-harbor' }) })
|
||||||
|
return made.ok ? (await made.json())?.playthrough?.id ?? null : null
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
// /node/:id — dev teleport to a specific story node (level nodes -> the board).
|
||||||
|
const nodeMatch = window.location.pathname.match(/^\/node\/(.+)$/)
|
||||||
|
if (nodeMatch) {
|
||||||
|
const id = await ensurePlaythroughId()
|
||||||
|
if (cancelled || !id) { if (!cancelled) setStatus('NO PLAYTHROUGH'); return }
|
||||||
|
const res = await fetch(`/api/playthroughs/${id}/goto`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nodeId: decodeURIComponent(nodeMatch[1]) }) })
|
||||||
|
if (!cancelled) res.ok ? apply(await res.json()) : setStatus('NODE NOT FOUND')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The bare root is always the front door: list the playable cases, and offer
|
||||||
|
// Resume on the one already in progress rather than auto-resuming into it.
|
||||||
|
const [cases, current] = await Promise.all([fetch('/api/mysteries'), fetch('/api/playthroughs/current')])
|
||||||
|
if (cancelled) return
|
||||||
|
if (cases.ok) setMysteries(await cases.json())
|
||||||
|
if (current.ok && current.status !== 204) setResumable(await current.json())
|
||||||
|
setSplash(true)
|
||||||
|
setStatus('SELECT A CASE FILE')
|
||||||
|
})()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [apply])
|
||||||
|
|
||||||
|
const newGame = async (slug: string) => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: slug }) })
|
||||||
|
if (res.ok) apply(await res.json())
|
||||||
|
else setStatus('COULD NOT OPEN CASE')
|
||||||
|
} finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const advance = async (terminalKey?: string) => {
|
||||||
|
if (!state) return
|
||||||
|
const res = await fetch(`/api/playthroughs/${state.playthrough.id}/advance`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ terminalKey }),
|
||||||
|
})
|
||||||
|
if (res.ok) apply(await res.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (splash) return <div className="splash">
|
||||||
|
<div className="splash-plate">
|
||||||
|
<div className="seal">GU</div>
|
||||||
|
<h1 className="splash-title">PRINCIPAL INVESTIGATOR</h1>
|
||||||
|
<p className="splash-sub">Glitch University · Case Files</p>
|
||||||
|
<div className="splash-cases">
|
||||||
|
{mysteries.map(mystery => {
|
||||||
|
const canResume = resumable?.playthrough.mysterySlug === mystery.slug
|
||||||
|
return <button key={mystery.slug} className="splash-case" disabled={busy}
|
||||||
|
onClick={() => (canResume && resumable) ? apply(resumable) : newGame(mystery.slug)}>
|
||||||
|
<span className="splash-case-title">{mystery.title}</span>
|
||||||
|
<span className="splash-case-action">{canResume ? 'RESUME ▸' : 'BEGIN ▸'}</span>
|
||||||
|
</button>
|
||||||
|
})}
|
||||||
|
{!mysteries.length && <p className="splash-status">NO CASE FILES AVAILABLE</p>}
|
||||||
|
</div>
|
||||||
|
<small className="splash-status">{busy ? 'OPENING CASE FILE…' : status}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
const node = state?.node
|
||||||
|
if (!node) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status || 'OPENING CASE FILE…'}</small></div>
|
||||||
|
if (node.kind === 'cutscene') return <CutsceneHost componentKey={node.componentKey} label={node.label} onComplete={() => { void advance() }} />
|
||||||
|
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} onComplete={() => { void advance() }} />
|
||||||
|
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }} onExit={terminalKey => { void advance(terminalKey) }} />
|
||||||
|
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
|
||||||
|
}
|
||||||
+34
-2
@@ -110,7 +110,10 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
@keyframes document-locator-target { from { outline-color: #cda936; filter: drop-shadow(0 0 5px #f3d75d66) brightness(1.03); } to { outline-color: #fff19a; filter: drop-shadow(0 0 14px #ffe66cbb) brightness(1.12); } }
|
@keyframes document-locator-target { from { outline-color: #cda936; filter: drop-shadow(0 0 5px #f3d75d66) brightness(1.03); } to { outline-color: #fff19a; filter: drop-shadow(0 0 14px #ffe66cbb) brightness(1.12); } }
|
||||||
.evidence-card.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; }
|
.evidence-card.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; }
|
||||||
.evidence-card.thread-target:hover, .source-file-widget.thread-target:hover { outline: 2px dashed #b8443e; outline-offset: 6px; }
|
.evidence-card.thread-target:hover, .source-file-widget.thread-target:hover { outline: 2px dashed #b8443e; outline-offset: 6px; }
|
||||||
.evidence-card.arriving { z-index: 6; animation: exhibit-arrival 1.15s cubic-bezier(.18,.85,.22,1) both; }
|
.evidence-card.arriving, .source-file-widget.arriving { z-index: 6; animation: exhibit-arrival 1.15s cubic-bezier(.18,.85,.22,1) both; }
|
||||||
|
.source-file-widget.arriving::before { content: 'NEW EVIDENCE'; position: absolute; z-index: 4; top: -19px; right: -8px; padding: 4px 6px; border: 1px solid #f0c16f; background: #9a3c2e; color: #fff4d6; box-shadow: 2px 3px #02090799; font: 600 7px IBM Plex Mono; letter-spacing: .08em; }
|
||||||
|
.doc-row.arriving { animation: document-row-arrival 1.15s ease both; }
|
||||||
|
@keyframes document-row-arrival { 0% { background: #a05328; box-shadow: inset 5px 0 #ffd48a; } 100% { background: transparent; box-shadow: inset 0 0 transparent; } }
|
||||||
@keyframes exhibit-arrival { 0% { opacity: 0; scale: .72; translate: 0 -24px; filter: brightness(1.7); box-shadow: 0 0 0 0 #eda85b00; } 45% { opacity: 1; scale: 1.035; translate: 0 2px; box-shadow: 0 0 0 12px #eda85b55, 7px 9px 0 #020b0980; } 100% { opacity: 1; scale: 1; translate: 0 0; filter: brightness(1); box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; } }
|
@keyframes exhibit-arrival { 0% { opacity: 0; scale: .72; translate: 0 -24px; filter: brightness(1.7); box-shadow: 0 0 0 0 #eda85b00; } 45% { opacity: 1; scale: 1.035; translate: 0 2px; box-shadow: 0 0 0 12px #eda85b55, 7px 9px 0 #020b0980; } 100% { opacity: 1; scale: 1; translate: 0 0; filter: brightness(1); box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; } }
|
||||||
.evidence-card header { border-bottom: 1px solid #989e94; display: flex; justify-content: space-between; padding-bottom: 6px; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #5d6763; }
|
.evidence-card header { border-bottom: 1px solid #989e94; display: flex; justify-content: space-between; padding-bottom: 6px; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #5d6763; }
|
||||||
.evidence-card h3 { font: 600 10px IBM Plex Mono; letter-spacing: .09em; margin: 12px 0 6px; color: #9a5d2e; }
|
.evidence-card h3 { font: 600 10px IBM Plex Mono; letter-spacing: .09em; margin: 12px 0 6px; color: #9a5d2e; }
|
||||||
@@ -272,6 +275,16 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.metadata-row { display: grid; grid-template-columns: 150px 1fr 28px; gap: 7px; padding: 7px; border-bottom: 1px solid #a1a69f; }
|
.metadata-row { display: grid; grid-template-columns: 150px 1fr 28px; gap: 7px; padding: 7px; border-bottom: 1px solid #a1a69f; }
|
||||||
.metadata-row input { padding: 6px; font-size: 9px; }
|
.metadata-row input { padding: 6px; font-size: 9px; }
|
||||||
.metadata-row button { display: grid; place-items: center; border: 0; background: #b8bcb5; color: #6c3a2c; cursor: pointer; }
|
.metadata-row button { display: grid; place-items: center; border: 0; background: #b8bcb5; color: #6c3a2c; cursor: pointer; }
|
||||||
|
.gate-field { margin: 18px 0; padding: 12px; border: 1px dashed #9b6638; background: #d2c8af; }.gate-field small { color: #6f5840; font: 8px/1.4 IBM Plex Mono; }
|
||||||
|
.flags-editor { width: min(560px, 90vw); }.flags-editor-body { padding: 24px 27px 26px; }.flags-editor-body > small { color: #805027; font: 600 8px IBM Plex Mono; letter-spacing: .16em; }.flags-editor-body > p { margin: 12px 0 18px; font: 12px/1.5 Special Elite; }
|
||||||
|
.flag-list { max-height: 290px; overflow: auto; border: 1px solid #8b938d; background: #d3d4cc; }.flag-empty { padding: 28px 16px; text-align: center; color: #6d7771; font: 8px IBM Plex Mono; }
|
||||||
|
.flag-row { min-height: 55px; padding: 8px 10px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #a0a69f; }.flag-row.earned { background: #d9dfce; box-shadow: inset 4px 0 #3f755f; }.flag-row > div { flex: 1; min-width: 0; display: grid; gap: 4px; }.flag-row b { overflow: hidden; text-overflow: ellipsis; color: #273d36; font: 600 10px IBM Plex Mono; }.flag-row small { color: #737d77; font: 7px IBM Plex Mono; }.flag-row button, .flag-add button { border: 1px outset #89938d; background: #c6cbc4; color: #30463f; padding: 7px 9px; cursor: pointer; font: 8px IBM Plex Mono; }.flag-row.earned button { color: #783f2e; }
|
||||||
|
.flag-add { margin-top: 13px; display: grid; grid-template-columns: 1fr auto; gap: 7px; }.flag-add input { min-width: 0; border: 1px solid #7d8780; background: #e8e5d8; padding: 8px 9px; font: 10px IBM Plex Mono; }.flag-add button { background: #244c41; color: white; }.flag-row button:disabled, .flag-add button:disabled { opacity: .5; cursor: default; }.flags-editor-body .flag-error { margin: 10px 0 0; color: #8a342e; font: 8px IBM Plex Mono; }
|
||||||
|
.match-rules-editor { width: min(1000px, 94vw); max-height: min(820px, 92vh); }
|
||||||
|
.match-rules-body { padding: 22px 25px 25px; overflow: auto; }.match-rules-body > small { color: #805027; font: 600 8px IBM Plex Mono; letter-spacing: .16em; }.match-rules-body > p { max-width: 760px; margin: 10px 0 17px; font: 11px/1.5 Special Elite; }
|
||||||
|
.match-rule-layout { display: grid; grid-template-columns: minmax(250px, .75fr) minmax(390px, 1.25fr); gap: 13px; align-items: start; }.match-rule-list { max-height: 560px; overflow: auto; border: 1px solid #8b938d; background: #d3d4cc; }
|
||||||
|
.match-rule-row { min-height: 58px; display: grid; grid-template-columns: minmax(0, 1fr) auto 28px; align-items: center; gap: 6px; padding: 7px; border-bottom: 1px solid #a0a69f; }.match-rule-row.disabled { opacity: .55; }.match-rule-row > div { min-width: 0; display: grid; gap: 4px; }.match-rule-row b, .match-rule-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.match-rule-row b { color: #273d36; font: 600 9px IBM Plex Mono; }.match-rule-row small { color: #737d77; font: 7px IBM Plex Mono; }.match-rule-row button, .match-rule-form button { min-height: 27px; border: 1px outset #89938d; background: #c6cbc4; color: #30463f; cursor: pointer; font: 8px IBM Plex Mono; }.match-rule-row button:last-child { display: grid; place-items: center; color: #783f2e; }
|
||||||
|
.match-rule-form { padding: 13px; border: 1px solid #8b938d; background: #cecfc7; }.match-rule-form-heading, .anchor-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 11px; color: #44504c; font: 8px IBM Plex Mono; letter-spacing: .08em; }.match-rule-form-heading button, .anchor-heading button { display: flex; align-items: center; gap: 4px; padding: 5px 8px; }.match-rule-fields { display: grid; grid-template-columns: minmax(0, 1fr) 110px; gap: 9px; }.match-rule-enabled { display: flex; align-items: center; gap: 7px; margin: 10px 0 15px; color: #59645e; font: 8px IBM Plex Mono; }.anchor-heading { margin: 0; padding: 8px 0; border-bottom: 2px solid #59625d; }.anchor-list { max-height: 295px; overflow: auto; border: 1px solid #929991; border-top: 0; background: #d7d8d0; }.anchor-row { display: grid; grid-template-columns: minmax(0, 1fr) 88px 28px; align-items: end; gap: 7px; padding: 8px; border-bottom: 1px solid #a1a69f; }.anchor-row > div, .anchor-row label { display: grid; gap: 4px; }.anchor-row small, .anchor-row label span { color: #6b756f; font: 7px IBM Plex Mono; }.anchor-row textarea { min-width: 0; resize: vertical; padding: 7px; background: #eeeadd; font: 9px/1.4 IBM Plex Mono; }.anchor-row input { min-width: 0; padding: 7px 4px; font: 8px IBM Plex Mono; }.anchor-row > button { display: grid; place-items: center; color: #783f2e; }.match-rule-form .folder-editor-actions { margin-top: 12px; }.match-rule-form .folder-editor-actions button { padding: 8px 11px; }.match-rule-form .folder-editor-actions button:disabled, .match-rule-row button:disabled, .anchor-row > button:disabled { opacity: .45; cursor: default; }.match-rule-form .flag-error { margin: 9px 0 0; color: #8a342e; font: 8px IBM Plex Mono; }
|
||||||
.boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; }
|
.boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; }
|
||||||
.empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; }
|
.empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; }
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
@@ -298,6 +311,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.brief-panel > header { position: sticky; z-index: 2; top: 0; min-height: 48px; padding-left: max(13px, env(safe-area-inset-left)); padding-right: max(7px, env(safe-area-inset-right)); }
|
.brief-panel > header { position: sticky; z-index: 2; top: 0; min-height: 48px; padding-left: max(13px, env(safe-area-inset-left)); padding-right: max(7px, env(safe-area-inset-right)); }
|
||||||
.brief-panel.minimized { inset: 82px 8px auto; width: auto; height: 48px; max-height: 48px; border: 2px solid #d8dbd4; box-shadow: 5px 6px 0 #020a08; }
|
.brief-panel.minimized { inset: 82px 8px auto; width: auto; height: 48px; max-height: 48px; border: 2px solid #d8dbd4; box-shadow: 5px 6px 0 #020a08; }
|
||||||
.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; }
|
.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; }
|
||||||
|
.match-rules-editor { width: 100vw; height: 100dvh; max-height: none; border: 0; }.match-rules-body { padding: 16px; }.match-rule-layout { grid-template-columns: 1fr; }.match-rule-list { max-height: 180px; }.match-rule-fields { grid-template-columns: 1fr 100px; }
|
||||||
.board-actions button { width: 38px; padding: 0; justify-content: center; gap: 0; font-size: 0; }
|
.board-actions button { width: 38px; padding: 0; justify-content: center; gap: 0; font-size: 0; }
|
||||||
.board-actions > b { display: none; }
|
.board-actions > b { display: none; }
|
||||||
.board-actions > span { margin: 0 2px; }
|
.board-actions > span { margin: 0 2px; }
|
||||||
@@ -307,7 +321,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.board-actions button { flex: 0 0 36px; width: 36px; justify-content: center; }
|
.board-actions button { flex: 0 0 36px; width: 36px; justify-content: center; }
|
||||||
.board-actions > span { flex: 0 0 1px; width: 30px; height: 1px; margin: 3px 0; }
|
.board-actions > span { flex: 0 0 1px; width: 30px; height: 1px; margin: 3px 0; }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
|
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .source-file-widget.arriving, .doc-row.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
|
||||||
|
|
||||||
/* Narrative layer: splash + NPC dialogue */
|
/* Narrative layer: splash + NPC dialogue */
|
||||||
.splash { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle at 50% 40%, #123029 0, #071916 68%); color: #9bb0a9; }
|
.splash { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle at 50% 40%, #123029 0, #071916 68%); color: #9bb0a9; }
|
||||||
@@ -506,6 +520,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
|
|
||||||
/* Story-graph runtime: cutscene title card + report-back */
|
/* Story-graph runtime: cutscene title card + report-back */
|
||||||
.cutscene-card { position: fixed; inset: 0; z-index: 200; display: grid; place-items: center; background: #04110e; cursor: pointer; animation: dialogue-in .3s ease; }
|
.cutscene-card { position: fixed; inset: 0; z-index: 200; display: grid; place-items: center; background: #04110e; cursor: pointer; animation: dialogue-in .3s ease; }
|
||||||
|
.merit-card { background: radial-gradient(120% 90% at 50% 35%, #16240f 0%, #0a1408 60%, #04110e 100%); }
|
||||||
|
.merit-card .merit-eyebrow { color: #cdea6a; }
|
||||||
|
.merit-flag { font: 13px IBM Plex Mono; letter-spacing: .12em; color: #d58a46; margin: 0; }
|
||||||
.title-card-inner { display: grid; justify-items: center; text-align: center; gap: 18px; animation: title-rise 1.1s cubic-bezier(.2,.7,.2,1); }
|
.title-card-inner { display: grid; justify-items: center; text-align: center; gap: 18px; animation: title-rise 1.1s cubic-bezier(.2,.7,.2,1); }
|
||||||
@keyframes title-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
|
@keyframes title-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
|
||||||
.title-card-inner small { font: 10px IBM Plex Mono; letter-spacing: .3em; color: #7f9a92; text-transform: uppercase; }
|
.title-card-inner small { font: 10px IBM Plex Mono; letter-spacing: .3em; color: #7f9a92; text-transform: uppercase; }
|
||||||
@@ -628,3 +645,18 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.phone-open-btn:hover { border-color: #cdea6a; }
|
.phone-open-btn:hover { border-color: #cdea6a; }
|
||||||
.phone-hint { color: #5f7b73; font-family: ui-monospace, monospace; font-size: 11px; margin: 0; }
|
.phone-hint { color: #5f7b73; font-family: ui-monospace, monospace; font-size: 11px; margin: 0; }
|
||||||
.phone-hint code { color: #8fae4a; }
|
.phone-hint code { color: #8fae4a; }
|
||||||
|
.phone-open-btn.glow { border-color: #cdea6a; color: #eafaa0; box-shadow: 0 0 14px #9fd02088, inset 0 0 8px #9fd02044; animation: phone-glow 1.4s ease-in-out infinite; }
|
||||||
|
@keyframes phone-glow { 50% { box-shadow: 0 0 22px #cdea6acc, inset 0 0 12px #9fd02066; } }
|
||||||
|
.phone-dev { display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||||
|
.phone-dev-btn { border: 1px dashed #6f8f85; background: #0a1a16cc; color: #9fd020; font-family: ui-monospace, monospace; font-size: 11px; letter-spacing: 1px; padding: 5px 12px; cursor: pointer; }
|
||||||
|
.phone-dev-btn:disabled { color: #5f7b73; border-style: solid; cursor: default; }
|
||||||
|
.phone-dev-btn:not(:disabled):hover { border-color: #cdea6a; color: #cdea6a; }
|
||||||
|
|
||||||
|
/* Splash case picker */
|
||||||
|
.splash-cases { display: flex; flex-direction: column; gap: 10px; width: 100%; margin: 20px 0 8px; }
|
||||||
|
.splash-case { display: flex; align-items: center; justify-content: space-between; gap: 14px; width: 100%; padding: 12px 16px;
|
||||||
|
border: 1px solid #3c5a52; background: #0a211d; color: #cfe8df; cursor: pointer; text-align: left; font: inherit; }
|
||||||
|
.splash-case:hover:not(:disabled) { border-color: #6f8f85; background: #0d2a24; }
|
||||||
|
.splash-case:disabled { opacity: .5; cursor: default; }
|
||||||
|
.splash-case-title { font-weight: 600; letter-spacing: .5px; }
|
||||||
|
.splash-case-action { color: #d58a46; font-size: 12px; letter-spacing: 1px; white-space: nowrap; }
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface FolderExhibit extends ExhibitBase {
|
|||||||
|
|
||||||
export interface DocumentExhibit extends ExhibitBase {
|
export interface DocumentExhibit extends ExhibitBase {
|
||||||
type: 'document'
|
type: 'document'
|
||||||
|
/** Author-mode reveal requirements. Omitted from play-mode payloads. */
|
||||||
|
requiredFlags?: string[]
|
||||||
publishedAt?: string
|
publishedAt?: string
|
||||||
capturedAt?: string
|
capturedAt?: string
|
||||||
sourceUri?: string
|
sourceUri?: string
|
||||||
@@ -47,6 +49,17 @@ export interface DocumentExhibit extends ExhibitBase {
|
|||||||
metadata: Record<string, string>
|
metadata: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DocumentUploadAnalysis {
|
||||||
|
extractionStatus: 'succeeded' | 'unsupported' | 'failed'
|
||||||
|
matchedFlags: string[]
|
||||||
|
awardedFlags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadedCaseDocument extends DocumentExhibit {
|
||||||
|
/** Transient upload response data; it is not part of persisted exhibit state. */
|
||||||
|
analysis: DocumentUploadAnalysis
|
||||||
|
}
|
||||||
|
|
||||||
export interface NoteExhibit extends ExhibitBase {
|
export interface NoteExhibit extends ExhibitBase {
|
||||||
type: 'note'
|
type: 'note'
|
||||||
content: string
|
content: string
|
||||||
@@ -152,6 +165,31 @@ export interface CaseState {
|
|||||||
levelStatus?: string
|
levelStatus?: string
|
||||||
sourceTemplateVersionId?: string
|
sourceTemplateVersionId?: string
|
||||||
editingAllowed?: boolean
|
editingAllowed?: boolean
|
||||||
|
/** Visible documents that have not previously played their arrival flourish. */
|
||||||
|
newlyVisibleDocumentIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LevelFlag {
|
||||||
|
key: string
|
||||||
|
earnedAt?: string
|
||||||
|
gatedDocumentCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvidenceMatchAnchorDefinition {
|
||||||
|
id: string
|
||||||
|
phrase: string
|
||||||
|
minimumSimilarity: number
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvidenceMatchRuleDefinition {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
flagKey: string
|
||||||
|
matcherVersion: 'char_trigram_v1'
|
||||||
|
minimumAnchorMatches: number
|
||||||
|
enabled: boolean
|
||||||
|
anchors: EvidenceMatchAnchorDefinition[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDocumentExhibit(exhibit: Exhibit): exhibit is DocumentExhibit { return exhibit.type === 'document' }
|
export function isDocumentExhibit(exhibit: Exhibit): exhibit is DocumentExhibit { return exhibit.type === 'document' }
|
||||||
|
|||||||
+13
-4
@@ -1,10 +1,19 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
export default defineConfig({
|
// Ports are read from .env so two branch checkouts can run `npm run dev` side by
|
||||||
|
// side: give each folder its own WEB_PORT (Vite) and PORT (API). The /api proxy
|
||||||
|
// follows PORT so the web server always talks to its own backend.
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
const webPort = Number(env.WEB_PORT || 5173)
|
||||||
|
const apiPort = Number(env.PORT || 8787)
|
||||||
|
return {
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: webPort,
|
||||||
proxy: { '/api': 'http://127.0.0.1:8787' },
|
strictPort: true, // fail loudly on a clash instead of silently picking another port
|
||||||
|
proxy: { '/api': `http://127.0.0.1:${apiPort}` },
|
||||||
},
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user