Author SHA1 Message Date
gitprovandClaude Opus 4.8 3b72f38d00 Make the dialogue/utterance editor vertical, matching the mystery graph
Input on top, output on the bottom, exit sinks in a row below; wires flow
downward. Cards auto-expand, so their heights are measured (offsetHeight) to
place the bottom output port. Seed and Tab-created utterances now stack downward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 17:59:17 +02:00
gitprovandClaude Opus 4.8 aa789bbadb Cleanup: drop vestigial utterance columns, sync story-graph doc
- migration 022 drops utterances.advances_to_utterance_id and .effect, which the
  parent-only/child-count dialogue model never used; purge their references.
- rewrite docs/story-graph.md to match what was built (parent-child utterances,
  vertical mystery graph, graph runtime, gate stubs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 16:28:11 +02:00
gitprovandClaude Opus 4.8 ddb3a386f0 Add story-graph narrative system (campaigns, editors, runtime)
Introduce the narrative layer as a directed story-flow graph: an authored
campaign a player walks node by node, replacing the interim slot/chapter model.

Schema (migrations 015-021):
- mysteries, global NPC templates + named poses, per-user playthroughs
- story_nodes, terminals, utterances (the flow graph and dialogue trees)
- clean cutover: retire slot cutscenes/chapters/seen_dialogue

Runtime:
- New Game creates a playthrough bound to the JWT identity (dev test-user fallback)
- advance() walks the graph cutscene -> dialogue -> level -> ..., auto-skipping gates
- branching dialogue: player choices route out through node terminals

Admin authoring:
- NPC editor: upload named poses to the gupi MinIO bucket
- mystery graph editor: vertical node canvas, wiring, entrypoint, delete-by-click
- dialogue crafter: utterance tree, Tab to add child, 1/2 speaker, undo

Content authored via the manifest importer / admin panel and seeded for Glass
Harbour. MinIO added to the dev stack; dev container runs in development mode.

Also includes a folder-widget simplification (removes open/close) and a
resolveUserId auth helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 15:37:46 +02:00
gitprov 0237da74cf Adding working board 2026-08-17 09:24:53 +02:00
gitprov 35685f765a Add document locator beam 2026-08-15 09:34:53 +02:00
gitprov 8f1f5a8743 Gate admin menu and authoring with shared JWT 2026-08-15 09:29:08 +02:00
gitprov eeaa4138fa Improve timeline date legibility 2026-08-15 09:13:30 +02:00
gitprov d99deb3c06 Allow undated reconstructed events 2026-08-14 19:57:22 +02:00
gitprov 703b9652c9 Add drag-to-trash exhibit disposal 2026-08-14 19:37:43 +02:00
gitprov 18705fe55f Render temporal links above timeline 2026-08-14 18:59:50 +02:00
gitprov d722ecae9d fix: stack board tools in portrait 2026-08-14 18:35:54 +02:00
gitprov 21a74e243c fix: add visible brief window controls 2026-08-14 18:33:04 +02:00
gitprov ffa81284df feat: add handwritten cues to text exhibits 2026-08-14 18:30:31 +02:00
gitprov b39d2efa75 feat: drag relation tags along threads 2026-08-14 18:26:20 +02:00
gitprov 5d59af1dd9 feat: create parties outside brief concepts 2026-08-14 18:17:31 +02:00
gitprov 57c8c91f21 fix: connect source documents with red thread 2026-08-14 18:11:51 +02:00
gitprov 38f50848d0 fix: pan board from empty space 2026-08-14 17:59:03 +02:00
gitprov a15b463589 feat: add document content search 2026-08-14 17:57:00 +02:00
gitprov 6f183daca8 feat: support compact and luggage relation tags 2026-08-14 17:07:30 +02:00
gitprov ccef0955d4 refactor: reuse luggage tags for thread labels 2026-08-14 17:02:17 +02:00
gitprov a4b22a574b fix: anchor board zoom to pointer 2026-08-14 16:44:43 +02:00
gitprov f97dceb4fe feat: add tagged red thread connections 2026-08-14 16:37:50 +02:00
gitprov 46bb2ba5ec feat: improve case brief classification flow 2026-08-14 16:18:13 +02:00
gitprov a51b508118 fix: separate desktop wheel and mobile pinch zoom 2026-08-14 15:45:03 +02:00
gitprov 45c7c6a292 feat: add configurable timeline range 2026-08-14 15:40:54 +02:00
gitprov b284275e98 feat: author first playable mystery 2026-08-14 15:02:00 +02:00
gitprov d46a425401 feat: add brief concept party classification 2026-08-14 14:44:58 +02:00
gitprov e333d3b634 feat: add event narrative and evidence workflow 2026-08-14 14:31:52 +02:00
gitprov edfa4c866c refactor: add typed frontend exhibit registry 2026-08-14 14:20:41 +02:00
gitprov c8a870549d feat: add immutable level template lifecycle 2026-08-14 14:17:54 +02:00
gitprov 5599d330d8 refactor: cut over to normalized exhibit schema 2026-08-14 14:09:24 +02:00
gitprov 9702b9c3d9 refactor: isolate level persistence from HTTP routes 2026-08-14 13:57:51 +02:00
gitprov 545eb013bd test: complete persistence and browser safety net 2026-08-14 13:43:37 +02:00
gitprov 9a4da41b49 test: add board and postgres safety net 2026-08-14 12:57:06 +02:00
65 changed files with 7828 additions and 638 deletions
+9
View File
@@ -2,4 +2,13 @@ DATABASE_URL=postgres://osint:osint_secret@localhost:5433/osint_dev
PORT=8787 PORT=8787
CORS_ORIGIN=http://localhost:5173 CORS_ORIGIN=http://localhost:5173
LEVEL_EDITING_ENABLED=true LEVEL_EDITING_ENABLED=true
JWT_SECRET=osint-local-dev-secret
MAX_DOCUMENT_BYTES=26214400 MAX_DOCUMENT_BYTES=26214400
# Game asset storage (MinIO). Start it with: docker compose -f docker-compose.dev.yml up -d minio createbuckets
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=gupi
S3_SECRET_KEY=gupi_secret
S3_BUCKET=gupi
S3_FORCE_PATH_STYLE=true
+2
View File
@@ -3,3 +3,5 @@ dist/
.env .env
.DS_Store .DS_Store
*.tsbuildinfo *.tsbuildinfo
playwright-report/
test-results/
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmjs.org/
+1
View File
@@ -0,0 +1 @@
22
+48 -8
View File
@@ -30,32 +30,66 @@ npm run dev
Open `http://localhost:5173`; Vite proxies `/api` to port 8787. Open `http://localhost:5173`; Vite proxies `/api` to port 8787.
## Test
Run the fast domain tests without external services:
```bash
npm test
```
With the development PostgreSQL container running, exercise the migration ledger and persistence API against uniquely named disposable databases:
```bash
npm run test:integration
```
The integration suite drops only the temporary databases it creates. Override `TEST_DATABASE_URL` when PostgreSQL is not available at the development default on port 5433.
Run the isolated Chromium smoke test after installing its browser runtime once with `npx playwright install chromium`:
```bash
npm run test:e2e
```
The smoke harness builds the application, creates a disposable PostgreSQL database and level, exercises move, hand-pan, desktop wheel zoom, mobile touch pinch, folder expansion, file movement, and reload persistence, then removes the test database.
The browser suite also imports and solves the bundled Glass Harbor mystery against that disposable database. Mystery manifests are database-authoring content rather than compiled frontend cases; see [`mysteries/README.md`](mysteries/README.md).
## Data and API ## Data and API
The server stores its tables and migration ledger in the dedicated `osint` schema of PostgreSQL. `DATABASE_URL` is required. The server stores its tables and migration ledger in the dedicated `osint` schema of PostgreSQL. `DATABASE_URL` is required.
The accepted model stores each playable or editable level as an isolated mutable board of normalized exhibits. Immutable template-version boards are cloned to create levels, and mutable levels can be cloned back into new template versions. Assets remain immutable and reusable across those copies. The accepted model stores each playable or editable level as an isolated mutable board of normalized exhibits. Immutable template-version boards are cloned to create levels, and mutable levels can be cloned back into new template versions. Assets remain immutable and reusable across those copies.
The running POC still uses the earlier `widgets` and `playthrough_*` tables while the exhibit-schema cutover is prepared. They are explicitly transitional; new domain concepts should follow the exhibit model rather than extending those tables. The running POC uses the canonical exhibit schema directly. The earlier JSON case store, `widgets`, and `playthrough_*` tables were removed in migration 006.
The API surface is: The API surface is:
- `GET /api/levels` - `GET /api/levels`
- `POST /api/levels` (editor only) - `POST /api/levels` (editor only)
- `GET /api/templates`
- `POST /api/templates/:slug/levels` (instantiate a version; editor only)
- `GET /api/levels/:id` - `GET /api/levels/:id`
- `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/documents` (editor only)
- `GET /api/assets/:id`
- `GET /api/session` (verified session and admin capability summary)
- `GET /api/health` - `GET /api/health`
The browser also keeps a local emergency copy so a network interruption does not lose an in-progress board. The browser also keeps a local emergency copy so a network interruption does not lose an in-progress board.
## Level editor ## Level editor
Set `LEVEL_EDITING_ENABLED=true` and open `/?edit=1`. If the database is empty, this surface creates the first blank level. The target model intentionally makes editing and playing the same operation against a mutable level; authoring additionally exposes “save as template”. The current playthrough overlay remains only until the exhibit-schema migration is complete. Set `LEVEL_EDITING_ENABLED=true` and open `/?edit=1` while signed in with a JWT carrying `role: "admin"`. The shared `auth_token` cookie is verified with `JWT_SECRET`; the legacy `isAdmin: true` claim remains compatible. If the database is empty, this surface creates the first blank level. Editing and playing are the same operation against a mutable level. Authoring actions live under the **Admin** menu. Saving a template creates a new immutable version; instantiating gives every exhibit a fresh ID while sharing immutable binary assets. Reset restores the exact template version from which a level was created.
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`. 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`.
Production defaults editing to disabled. Set `LEVEL_EDITING_ENABLED=true` in `/opt/gu_common/.env.prod` only when the authoring surface should be available. This is a capability switch, not authentication; add authentication before exposing production editing to untrusted users. 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.
## POC exhibit and widget contract ## POC exhibit and widget contract
@@ -64,19 +98,25 @@ The current POC defines four exhibit families and corresponding frontend widgets
- **Folder** — a titled, annotated collection. Opening it expands its contained source files to their saved relation-bound board positions; closing it retracts and hides them. - **Folder** — a titled, annotated collection. Opening it expands its contained source files to their saved relation-bound board positions; closing it retracts and hides them.
- **Source file** — immutable binary evidence plus user-editable title, publication time, file type, and arbitrary key/value metadata. Image is the first fully visual file renderer. PDF, web capture, email, article, filing, price list, text, and generic file are registered types with a generic fallback until their renderers are built. - **Source file** — immutable binary evidence plus user-editable title, publication time, file type, and arbitrary key/value metadata. Image is the first fully visual file renderer. PDF, web capture, email, article, filing, price list, text, and generic file are registered types with a generic fallback until their renderers are built.
- **Note** — investigator-authored interpretation, visually distinct from source evidence. - **Note** — investigator-authored interpretation, visually distinct from source evidence.
- **Event** — an investigator-authored “this happened” statement with narrative text, an occurrence time, and normalized links to its supporting evidence. Chronologically ordered events become the emerging story. - **Event** — an investigator-authored “this happened” statement with narrative text, an occurrence time, and normalized links to supporting exhibits. The editor manages citations, dashed support lines keep them visually distinct from red investigative thread, and chronologically ordered events form the reconstructed-story strip.
The planned **Party** family adds distinct Person and Organization exhibits (businesses are organizations). Their dossier-like widgets reveal normalized evidence associations, but parties remain identity objects rather than special folders. The **Party** family has distinct Person and Organization subtypes (businesses are organizations). Names begin as concepts in the level brief. The investigator classifies each concept, which creates the appropriate dossier exhibit and records the resolution. Expected classifications remain author-only. Dossiers store aliases and normalized evidence associations, but parties remain identity objects rather than special folders.
**Red thread** is a normalized connection between any two exhibits, including expanded source documents. Each connection may carry an investigator-authored relation tag, a persisted tightness percentage, and a presentation choice. `LUGGAGE` reuses the investigator note's expressive hanging tag, rotating to read before opening the editor; `COMPACT` keeps the original knot-and-label treatment and opens the editor directly. Both remain available while the POC establishes which works best. When a connected source document is retracted into its folder, the visible endpoint follows it to the folder until it is expanded again.
Folder ownership is stored as a normalized membership. The contained document exhibit owns its expanded `xpos` and `ypos`. For deterministic collapse behavior, one exhibit has at most one owning folder; two exhibits may reuse the same immutable asset when the same source file must appear in multiple folders. Folder ownership is stored as a normalized membership. The contained document exhibit owns its expanded `xpos` and `ypos`. For deterministic collapse behavior, one exhibit has at most one owning folder; two exhibits may reuse the same immutable asset when the same source file must appear in multiple folders.
An open folder draws a pale red containment band to each expanded file. Each dated file independently projects a grey line to the temporal index. This allows the player to arrange files until those grey lines are vertical, close the folder, and later reopen the same arrangement. An open folder draws a pale red containment band to each expanded file. Each dated file independently projects a grey line to the temporal index. This allows the player to arrange files until those grey lines are vertical, close the folder, and later reopen the same arrangement.
The frontend widget registry maps an exhibit type, and optionally a document type, to its React visualization. Widgets do not own investigation-domain data. The timeline uses an optional board-level start and end date. Click **Timeline** in the menu or the displayed range in the footer to adjust it; **Use Automatic Range** returns to evidence-derived bounds. Configured bounds are normalized board data and follow template cloning and reset. Evidence outside the visible interval remains available and is pinned to the nearest timeline edge.
The typed frontend widget registry maps each exhibit type, and each document type, to its React visualization. Adding a domain type now produces a compile-time requirement to register its renderer. Widgets do not own investigation-domain data.
On desktop, an ordinary mouse wheel or two-finger trackpad scroll zooms the board. Desktop pinch gestures are consumed so they cannot zoom both the browser and board. Touch devices use a two-finger pinch on the board; toolbar controls remain available on every device.
## Deploy at osint.glitch.university ## Deploy at osint.glitch.university
The production service depends on `gu_common`: it joins the external `gnommo` Docker network, uses `gnommo-db`, and is routed by the shared nginx container. Deploy `gu_common` after its nginx configuration changes, then deploy this repository: The production service depends on `gu_common`: it joins the external `gnommo` Docker network, uses `gnommo-db`, shares its JWT secret and cross-subdomain authentication cookie, and is routed by the shared nginx container. Deploy `gu_common` after its nginx configuration changes, then deploy this repository:
```bash ```bash
./deploy.sh ./deploy.sh
@@ -86,4 +126,4 @@ The deploy script builds and syncs the application, reads production database cr
## Deliberate POC boundaries ## Deliberate POC boundaries
There are no accounts, arbitrary uploads, 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, 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.
+43 -2
View File
@@ -23,18 +23,59 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: osint-board-app container_name: osint-board-app
environment: environment:
NODE_ENV: production NODE_ENV: development
PORT: 8787 PORT: 8787
DATABASE_URL: postgres://osint:osint_secret@db:5432/osint_dev DATABASE_URL: postgres://osint:osint_secret@db:5432/osint_dev
CORS_ORIGIN: http://localhost:8787 CORS_ORIGIN: http://localhost:8787
JWT_SECRET: ${JWT_SECRET:-osint-local-dev-secret}
LEVEL_EDITING_ENABLED: "true" LEVEL_EDITING_ENABLED: "true"
MAX_DOCUMENT_BYTES: 26214400 MAX_DOCUMENT_BYTES: 26214400
S3_ENDPOINT: http://minio:9000
S3_REGION: us-east-1
S3_ACCESS_KEY: gupi
S3_SECRET_KEY: gupi_secret
S3_BUCKET: gupi
S3_FORCE_PATH_STYLE: "true"
ports: ports:
- "8787:8787" - "8787:8787"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
command: ["sh", "-c", "npm run migrate:up && npm start"] createbuckets:
condition: service_completed_successfully
# Run directly (not `npm start`, which forces NODE_ENV=production) so the
# NODE_ENV=development above applies and dev conveniences like the admin-session
# route are available.
command: ["sh", "-c", "npm run migrate:up && npx tsx server/index.ts"]
minio:
image: minio/minio:latest
container_name: osint-board-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: gupi
MINIO_ROOT_PASSWORD: gupi_secret
ports:
- "9000:9000" # S3 API
- "9001:9001" # web console
volumes:
- osint_minio_data:/data
# One-shot: wait for MinIO, then ensure the gupi bucket exists. Assets are
# served through the app's /api/assets proxy, so the bucket stays private.
createbuckets:
image: minio/mc:latest
container_name: osint-board-createbuckets
depends_on:
- minio
entrypoint: >
/bin/sh -c "
until mc alias set gupi http://minio:9000 gupi gupi_secret; do echo 'waiting for minio...'; sleep 1; done;
mc mb --ignore-existing gupi/gupi;
echo 'gupi bucket ready';
"
volumes: volumes:
osint_postgres_data: osint_postgres_data:
osint_minio_data:
+1
View File
@@ -10,6 +10,7 @@ services:
PORT: 8787 PORT: 8787
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@gnommo-db:5432/${POSTGRES_DB} DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@gnommo-db:5432/${POSTGRES_DB}
CORS_ORIGIN: https://osint.${DOMAIN} CORS_ORIGIN: https://osint.${DOMAIN}
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}
expose: expose:
+109 -28
View File
@@ -2,6 +2,8 @@
This is the ordered implementation roadmap following the accepted exhibit model. Work generally proceeds from top to bottom. It is not a substitute for migrations or implementation issues. This is the ordered implementation roadmap following the accepted exhibit model. Work generally proceeds from top to bottom. It is not a substitute for migrations or implementation issues.
The narrative layer — campaigns, NPC cutscenes, the admin authoring panel, and the LLM cognitive shim that keeps players oriented through complex real-world scam cases — is tracked separately in [narrative-todo.md](narrative-todo.md). It depends on Milestone 5 (Case Report / Claims) for the assistant's read-only view of the player's reasoning.
## Working agreement ## Working agreement
- Spend roughly 60% of development time on features and model work, 25% on tests and bug fixing, and 15% on repository and deployment hygiene. - Spend roughly 60% of development time on features and model work, 25% on tests and bug fixing, and 15% on repository and deployment hygiene.
@@ -14,49 +16,128 @@ This is the ordered implementation roadmap following the accepted exhibit model.
- [x] Track `.gitignore`, `.dockerignore`, and `.env.example`; confirm that secrets and generated artifacts cannot enter Git accidentally. - [x] Track `.gitignore`, `.dockerignore`, and `.env.example`; confirm that secrets and generated artifacts cannot enter Git accidentally.
- [x] Commit the current working POC and tag the checkpoint `poc-pre-exhibit-model`. - [x] Commit the current working POC and tag the checkpoint `poc-pre-exhibit-model`.
- [x] Confirm `main` is pushed to the Ramanujan-hosted `origin` repository. - [x] Confirm `main` is pushed to the Ramanujan-hosted `origin` repository.
- [ ] Keep `npm run build` green and make `npm test` run real tests rather than an empty suite. - [x] Keep `npm run build` green and make `npm test` run real tests rather than an empty suite.
## Milestone 1: focused safety net ## Milestone 1: focused safety net
- [ ] Test screen/board coordinate conversion across pan and zoom levels. - [x] Test screen/board coordinate conversion across pan and zoom levels.
- [ ] Test timeline date-to-pixel projection and recomputation after viewport resizing. - [x] Test timeline date-to-pixel projection and recomputation after viewport resizing.
- [ ] Test the interaction boundary between exhibit dragging, hand-tool panning, and board-only pinch zoom. - [x] Test the interaction boundary between exhibit dragging, hand-tool panning, desktop wheel zoom, and mobile-only pinch zoom.
- [ ] Test folder open/close behavior, retained file positions, and containment bands. - [x] Test folder open/close behavior, retained file positions, and containment-band state.
- [ ] Test document upload, metadata persistence, board save/reload, and reset. - [x] Test document upload, metadata persistence, board save/reload, and reset.
- [ ] Run migrations and API integration tests against disposable PostgreSQL, not SQLite or mocked persistence. - [x] Run migrations and API integration tests against disposable PostgreSQL, not SQLite or mocked persistence.
- [ ] Add one browser smoke test: open a level, drag an exhibit, zoom, open a folder, move a file, reload, and verify persistence. - [x] Add one browser smoke test: open an isolated level, drag an exhibit, pan, board-zoom, open a folder, move a file, reload, and verify persistence.
## Milestone 2: exhibit-schema cutover ## Milestone 2: exhibit-schema cutover
- [ ] Add boards, exhibits, exhibit types, subtype tables, immutable template versions, and cloned mutable levels. - [x] Add boards, exhibits, exhibit types, subtype tables, immutable template versions, and mutable levels.
- [ ] Migrate transitional `widgets`, `widget_relations`, and `playthrough_*` data with equivalence checks. - [x] Cut over the explicitly disposable POC database directly; no transitional data existed to backfill or compare.
- [ ] Implement template instantiation and “save level as template” as transactional clone operations. - [x] Implement template instantiation, version selection, reset, and “save level as template” as transactional clone operations.
- [ ] Introduce a server-side repository/service boundary so SQL and cloning transactions do not live in Express route handlers. - [x] Introduce a server-side repository/service boundary so SQL and cloning transactions do not live in Express route handlers.
- [ ] Move the frontend to an exhibit/widget registry backed by the normalized API. - [x] Move exhibit and document rendering to a typed frontend widget registry backed by the normalized API.
- [ ] Remove transitional tables only after automated data-equivalence and behavior checks pass. - [x] Remove transitional `widgets`, `widget_relations`, and `playthrough_*` tables in the canonical cutover migration.
## Milestone 3: events and parties ## Milestone 3: events and parties
### Events and narrative ### Events and narrative
- [ ] Implement Event exhibits with occurrence time and investigator-authored narrative text. - [x] Implement Event exhibits with occurrence time and investigator-authored narrative text.
- [ ] Implement normalized Event-to-Evidence links and their board visualization. - [x] Implement normalized Event-to-Evidence links and their distinct board visualization.
- [ ] Present chronologically ordered events as the emerging investigation story. - [x] Present chronologically ordered events as the emerging investigation story.
### Party exhibits ### Party exhibits
- [ ] Add a Party exhibit supertype representing an investigation participant. - [x] Add a Party exhibit supertype representing an investigation participant.
- [ ] Add a Person subtype with display name, normalized aliases, and extensible structured identity fields. - [x] Add a Person subtype with display name, normalized aliases, and extensible structured identity fields.
- [ ] Add an Organization subtype covering businesses, public bodies, associations, and informal groups. - [x] Add an Organization subtype covering businesses, public bodies, associations, and informal groups.
- [ ] Add normalized many-to-many Party-to-Evidence associations with an optional explanatory note. - [x] Add normalized many-to-many Party-to-Evidence associations with an optional explanatory note.
- [ ] Add typed Party-to-Party relationships such as employment, ownership, membership, control, and representation. - [ ] Add typed Party-to-Party relationships such as employment, ownership, membership, control, and representation.
- [ ] Build distinct Person and Organization widgets that can open as dossiers and reveal associated evidence without using folder ownership semantics. - [x] Build distinct Person and Organization dossier presentations that reveal associated evidence without using folder ownership semantics.
- [ ] Include parties, aliases, evidence associations, and party relationships in template/level cloning. - [x] Include brief concepts, parties, aliases, evidence associations, and party relationships in template/level cloning.
## Milestone 4: first playable mystery ## Milestone 4: first playable mystery
- [ ] Design a small mystery that exercises documents, folders, notes, events, people, organizations, connections, and the timeline. - [x] Design a small mystery that exercises documents, folders, notes, events, people, organizations, connections, and the timeline.
- [ ] Create it as an immutable level-template version using the same supported operations available to an author. - [x] Create it as an immutable level-template version using the same supported operations available to an author.
- [ ] Instantiate and solve a cloned level without modifying the template or relying on hard-coded case behavior. - [x] Instantiate and solve a cloned level without modifying the template or relying on hard-coded case behavior.
- [ ] Turn the successful solve path into an end-to-end acceptance test. - [x] Turn the successful solve path into an end-to-end acceptance test.
- [ ] Perform a manual playability and visual-polish pass before production deployment. - [x] Perform a manual playability and visual-polish pass before production deployment.
## Milestone 5: claim-driven case report
The purpose of this milestone is the gameplay loop, not a knowledge graph: the player connects two exhibits, explains that one specific thread with a luggage-tag Claim, and later discovers that their accumulated explanations have become a nearly complete case report.
### 5.1 Lock the gameplay and temporal rules
- [ ] Define a Claim as an entity owned by exactly one investigative thread; a thread has zero or one Claim. (The text associated with a claim can prove multiple points, handed by the text.
- [ ] Keep untagged threads as ordinary connections that do not appear in the report.
- [ ] Use the same Claim text on the luggage tag and in the report. Editing either presentation updates the same database value.
- [ ] Derive the Claim date from the earliest non-null temporal date of its two endpoint exhibits; never use `created_at` or the current time.
- [ ] Use Event `occurred_at` and a Document's primary timeline date as direct endpoint dates. For a Folder, use the earliest dated contained Document. Leave a Claim undated when neither endpoint supplies a date.
- [ ] Place undated Claims after dated Claims in the initial report order while keeping them fully editable and reorderable.
- [ ] Treat the derived date as the initial chronological suggestion only. Manual report ordering must not rewrite exhibit or Claim dates.
### 5.2 Add normalized persistence
- [ ] Add a `claims` table with a unique foreign key to `exhibit_connections`, text, tag style, position percentage, lateral offset, and timestamps.
- [ ] Move luggage-tag-specific text and placement fields out of `exhibit_connections`; retain curve tightness and endpoints on the connection.
- [ ] Add one level-owned `case_report` and normalized `case_report_claims` rows with explicit `sort_order`.
- [ ] Assign stable, level-local display numbers to cite exhibits as `Exhibit 3` independently of board position, z-index, or report order.
- [ ] Enforce same-board ownership for the Claim's connection, both endpoint exhibits, report, and report membership.
- [ ] Delete a Claim and its report membership transactionally when its luggage tag is removed, while retaining the now-untagged thread.
- [ ] Delete both the Claim and connection when the thread itself is removed.
- [ ] Clone board-owned Claims with fresh IDs during template creation and instantiation; rebuild level report membership against the cloned Claim IDs.
- [ ] Make reset discard player-created Claims and restore exactly the Claims present in the source template version.
- [ ] Keep uploaded binary evidence in MinIO. Reports and Claims reference Document exhibits and asset metadata; they never duplicate or embed asset bytes.
### 5.3 Expose a focused API contract
- [ ] Add a typed Claim DTO to an investigative connection instead of exposing a free-form connection `label`.
- [ ] Return each Claim's derived date and its two stable exhibit citations in the report response.
- [ ] Add granular operations to create, edit, and remove a Claim without replacing the complete board state.
- [ ] Add a report endpoint that returns ordered Claim rows and an atomic reorder operation.
- [ ] Reject empty Claim text, invalid connection ownership, duplicate Claims on one connection, and report orders containing foreign or duplicate Claim IDs.
- [ ] Protect Claim and report writes with the board revision so concurrent saves cannot silently overwrite reasoning.
### 5.4 Turn luggage tags into Claim widgets
- [ ] Change the thread prompt from “Add tag” to “Explain this connection,” with a secondary option to leave the thread untagged.
- [ ] Create the Claim and its luggage-tag presentation in one interaction, preserving the current tightening animation.
- [ ] Keep both `LUGGAGE` and `COMPACT` as visual presentations of the same Claim entity while playtesting them.
- [ ] Preserve draggable percentage and tension-constrained lateral offset as Claim presentation state.
- [ ] Rename tag-oriented frontend types and commands to Claim terminology without changing the established visual design.
- [ ] Give a newly created Claim a subtle “added to report” ink animation or badge without interrupting board work.
### 5.5 Build the typewriter case report
The end goal is that a case report is prepopulated by the claims the player made during the investigation so that a skeleon of the case solution is present. The player must simply edit the case report and submit it.
- [ ] Add **Case Report** as a primary menu item and implement it as a persistent board view, separate from exhibits and the timeline.
- [ ] Provide an empty state that explains that explaining red threads will create the report, without revealing a solution or forcing a tutorial.
- [ ] Initially arrange Claim rows chronologically by derived date, using the order parameters on the claim as tie-breaker and undated Claims last, below a horizontal rule that says (missing date)
- [ ] Render each row as a typewritten Claim with its date and endpoint citations, for example: `14.10.1987 — Maria Voss redirected the shipment. Exhibits 4 and 7.`
- [ ] It needs to be possible to add free text before and after the claims. Coloured inline text (use span elements) have a specific class and id can be edited
- [ ] -Make Claim text editable inline. Persist through the Claim API so the luggage tag updates immediately.
- [ ] Support pointer and keyboard reordering of Claim rows and persist the resulting explicit report order into order column of the claim.
- [ ] Clicking a Claim must minimize the report as appropriate, center its thread, and briefly illuminate the curve and luggage tag.
- [ ] Clicking an exhibit citation must locate and highlight that exhibit using the existing tray/board locator treatment.
- [ ] Ensure the report is legible and operable on portrait mobile layouts as well as desktop.
- [ ] Add restrained typewriter, paper, and ink feedback while respecting reduced-motion preferences.
### 5.6 Test the reasoning loop
- [ ] Unit-test endpoint date resolution for Event, Document, Folder, partially dated, fully undated, and invalid-date cases.
- [ ] Migration-test the one-Claim-per-thread constraint, cascading behavior, same-board enforcement, and report ordering constraints.
- [ ] Integration-test create/edit/remove Claim, two-way text synchronization, report reorder, reload persistence, reset, and template cloning.
- [ ] Verify that Claims citing uploaded Document exhibits survive cloning while their immutable assets remain shared through MinIO.
- [ ] Add a browser test that creates several connections out of chronological order, explains them, opens the report, edits and reorders the Claims, then returns to each highlighted thread.
- [ ] Play the Glass Harbor mystery using Claims as the primary reasoning mechanism and record whether the generated report makes the conclusion emerge naturally.
- [ ] Revise prompts, animation, initial ordering, and report typography based on that playtest before adding automated evaluation.
### 5.7 Deliberately deferred
- [ ] Design a hidden, template-versioned solution rubric only after the deterministic Claim/report loop is enjoyable and dependable.
- [ ] Add LLM report evaluation as a later milestone with structured citations, calibrated uncertainty, and reproducible evaluator output.
- [ ] Do not add general-purpose semantic edge roles, Claim hubs, Claim-to-Claim links, or a knowledge-graph ontology as part of this milestone.
### Milestone 5 definition of done
A player can connect exhibits, explain each connection with a luggage-tag Claim, discover those exact words in a chronological typewriter report, improve and reorder the Claims, follow every citation back to the board, reload without loss, and reset safely to the template. No LLM is required for this experience to work.
+10 -15
View File
@@ -1,6 +1,6 @@
# GUPI OSINT Board: canonical exhibit data model # GUPI OSINT Board: canonical exhibit data model
Status: accepted design foundation. Status: accepted design foundation; the core schema, template lifecycle, frontend registry, Event workflow, brief concepts, and Party subtypes are implemented. Interactive Party-to-Party relationships remain roadmap work.
## Vocabulary ## Vocabulary
@@ -9,6 +9,7 @@ Status: accepted design foundation.
- A **widget** is the frontend visualization and interaction implementation selected for an exhibit type. - A **widget** is the frontend visualization and interaction implementation selected for an exhibit type.
- A **document type** specializes a document exhibit: image, PDF, web capture, email, article, filing, price list, text, or generic file. - A **document type** specializes a document exhibit: image, PDF, web capture, email, article, filing, price list, text, or generic file.
- A **board** is a neutral container for exhibits. Both mutable levels and immutable template versions own boards. - A **board** is a neutral container for exhibits. Both mutable levels and immutable template versions own boards.
- A board may define a temporal viewport (`board_timeline_settings`). If absent, the client derives a range from dated evidence; if present, the range clones and resets with the board.
- A **level** is a mutable board copy used for either play or authoring. - A **level** is a mutable board copy used for either play or authoring.
- A **level template version** is an immutable board snapshot. - A **level template version** is an immutable board snapshot.
@@ -147,7 +148,7 @@ CREATE TABLE osint.event_exhibits (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE, exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
title TEXT NOT NULL, title TEXT NOT NULL,
narrative_text TEXT NOT NULL, narrative_text TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL occurred_at TIMESTAMPTZ
); );
``` ```
@@ -158,10 +159,10 @@ The service validates that every base exhibit has exactly one subtype row matchi
An event is an investigator-authored assertion: **“this happened.”** It is not source evidence and must not silently inherit a document's publication time. An event is an investigator-authored assertion: **“this happened.”** It is not source evidence and must not silently inherit a document's publication time.
- `narrative_text` states what the investigator believes happened. - `narrative_text` states what the investigator believes happened.
- `occurred_at` places that assertion in reconstructed time. - Nullable `occurred_at` places that assertion in reconstructed time when known. It must never inherit the exhibit's creation timestamp.
- One event may cite several supporting exhibits. - One event may cite several supporting exhibits.
- One exhibit may support several events. - One exhibit may support several events.
- Events ordered by `occurred_at` form the emerging case narrative; there is no duplicated story-text record. - Dated events ordered by `occurred_at` form the temporal narrative. Undated events remain visible in the reconstructed story without affecting the timeline range; there is no duplicated story-text record.
Supporting evidence is an explicit normalized relationship: Supporting evidence is an explicit normalized relationship:
@@ -180,7 +181,7 @@ A deferred constraint trigger verifies that both exhibits belong to the same boa
In the frontend, the Event widget shows its occurrence time, narrative text, and evidence count. Opening or selecting it reveals its supporting exhibits. Lines between an event and its evidence visualize `event_evidence`; they are not ordinary folder containment bands. In the frontend, the Event widget shows its occurrence time, narrative text, and evidence count. Opening or selecting it reveals its supporting exhibits. Lines between an event and its evidence visualize `event_evidence`; they are not ordinary folder containment bands.
### Party semantics (planned) ### Party and brief-concept semantics
A party is a person or organization that participates in the investigation. Businesses are organizations. A party is a first-class exhibit, not a special folder: identity and evidence association must not be represented as file ownership. A party is a person or organization that participates in the investigation. Businesses are organizations. A party is a first-class exhibit, not a special folder: identity and evidence association must not be represented as file ownership.
@@ -193,6 +194,8 @@ The intended normalized shape is a `party_exhibits` supertype with one-to-one `p
The frontend provides distinct Person and Organization widgets through the exhibit registry. They may visually behave like dossiers—opening one can reveal associated evidence—but that interaction is derived from `party_evidence`; it does not turn the party into a folder or cause evidence to be owned by or disappear into the party. The frontend provides distinct Person and Organization widgets through the exhibit registry. They may visually behave like dossiers—opening one can reveal associated evidence—but that interaction is derived from `party_evidence`; it does not turn the party into a folder or cause evidence to be owned by or disappear into the party.
A name appearing in the level brief begins as a normalized `brief_concept`, not as an exhibit. The author may record the expected Party classification, which is omitted from play-mode API responses. When the investigator classifies a concept as Person or Organization, the level creates a Party exhibit and records it in `resolved_party_exhibit_id`. This keeps the reasoning action explicit: the game does not pre-create a correctly typed party and merely hide its widget.
## Assets and document content ## Assets and document content
`assets` stores immutable uploaded bytes, checksum, MIME type, original filename, and size. Multiple cloned document exhibits may reference one asset. `assets` stores immutable uploaded bytes, checksum, MIME type, original filename, and size. Multiple cloned document exhibits may reference one asset.
@@ -317,14 +320,6 @@ The following are computed and must not become duplicate source-of-truth tables:
`save_level_as_template(level_id)` runs the same clone operation into a new immutable template-version board. `save_level_as_template(level_id)` runs the same clone operation into a new immutable template-version board.
## Migration direction ## Implemented cutover
The current `widgets`, `widget_relations`, and `playthrough_*` tables are transitional. The cutover should: Migration 006 made this the sole persistence model. Because the POC database contained no canonical or legacy content worth preserving, the cutover intentionally dropped the JSON case store, `widgets`, `widget_relations`, and `playthrough_*` tables without a backfill period. Template save, version selection, instantiation, and reset use one transactional board-cloning service. Exhibit and document types resolve through the typed frontend registry; the remaining model work is the planned exhibit families and richer behavior.
1. Introduce boards, exhibit tables, subtype tables, templates, and typed metadata.
2. Convert existing authored widgets to exhibits.
3. Materialize each existing playthrough as its own cloned mutable level.
4. Move document layout from relation JSON into document exhibit coordinates.
5. Convert `contains` relations to folder memberships and generic connections to exhibit connections.
6. Update the API to read and write exhibits directly.
7. Remove the transitional widget/playthrough tables only after data equivalence checks pass.
+104
View File
@@ -0,0 +1,104 @@
# First slice: splash → New Game → briefing → board
Scope: the thinnest end-to-end narrative thread. A player lands on a
**PRINCIPAL INVESTIGATOR** splash, clicks **New Game**, watches one scripted
briefing cutscene from the Glitch University professor (with a pose swap), and
arrives on the existing Glass Harbor board. Identity is a hard-coded test user.
All cutscene content is authored through the manifest importer — nothing
hard-coded in React.
Explicitly **out of scope** for this slice: the admin authoring panel, multiple
chapters, the debrief/back-to-board scenes, dialogue branching, and any LLM. The
schema below only reserves those (`kind`, nullable `advances_to`) — it does not
build them.
## Ordered tasks
### 1. Identity: hard-coded test user (do this first)
- [ ] Add `resolveUserId(req)` to `server/auth.ts`: return `authClaims.sub` when
present, else a single fixed dev `TEST_USER_ID`. Leave `requireAdmin` and the
symmetric-secret admin path untouched.
- [ ] Note in code that the external `glitch.university` key-exchange path
replaces only the fallback branch later; the `user_id` column does not change.
### 2. Migration `015_narrative_layer.sql`
Minimal tables to run one scripted scene against one chapter.
- [ ] `mysteries (id, slug UNIQUE, title)` and `mystery_chapters (mystery_id,
chapter_index, level_template_version_id, PK (mystery_id, chapter_index))`.
- [ ] `npcs (id, mystery_id, name, role, default_pose_key)`.
- [ ] `poses (id, npc_id, pose_key, asset_id → osint.assets, UNIQUE (npc_id,
pose_key))`.
- [ ] `cutscenes (id, mystery_id, chapter_index NULLABLE, slot, title)`.
- [ ] `dialogue_steps (id, cutscene_id, step_key, sort_order, npc_id, pose_key,
kind DEFAULT 'scripted', text, advances_to NULLABLE, UNIQUE (cutscene_id,
sort_order))`.
- [ ] `playthroughs (id, user_id, mystery_id, current_chapter_index,
current_level_id, created_at)`.
- [ ] `seen_dialogue (playthrough_id, cutscene_id, seen_at, PK (playthrough_id,
cutscene_id))`. (Cutscene-level for the slice; step-level deferred.)
### 3. Repository (`server/narrativeRepository.ts`, or extend levelRepository)
- [ ] `createPlaythrough(userId, mysterySlug)`: instantiate chapter 1's template
version into a fresh level (reuse `instantiate_template_version`), insert the
playthrough, return it.
- [ ] `getCurrentPlaythrough(userId)`: newest unfinished playthrough for the user.
- [ ] `getPendingCutscene(playthrough)`: earliest unseen cutscene matching the
current slot/chapter, with steps joined to NPC + resolved pose asset URL.
- [ ] `resolvePose(npc, poseKey)`: pure, unit-testable — requested `pose_key` →
NPC `default_pose_key` → `null` (no artwork). Return the asset URL or null.
- [ ] `markCutsceneSeen(playthroughId, cutsceneId)`: idempotent insert.
### 4. API (`server/index.ts`), all scoped to `resolveUserId(req)`
- [ ] `POST /api/playthroughs` → create for the user, returns playthrough +
`pendingCutscene` + current level id.
- [ ] `GET /api/playthroughs/current` → the user's playthrough or 204/empty.
- [ ] `POST /api/playthroughs/:id/cutscenes/:cutsceneId/seen` → idempotent; 403 if
the playthrough's `user_id` is not the caller.
### 5. Manifest importer (content path)
- [ ] Extend `MysteryManifest` in `scripts/importMysteryTemplate.ts` with `cast[]`
(NPCs, each with `defaultPose` and `poses: { pose_key → asset path }`) and
`cutscenes[]` (`{ slot, chapter?, steps: [{ npc, pose, text }] }`).
- [ ] Freeze a `mysteries` row + one `mystery_chapters` entry pointing at the
Glass Harbor template version, plus the cast and the intro cutscene, through the
same authenticated operations already used for assets.
- [ ] Tolerate a cast with **no pose images** (author an NPC with just a name/role
so the slice runs art-free); upload images only if the manifest provides them.
- [ ] Add a `mystery_intro` cutscene to `mysteries/glass-harbor/mystery.json`: the
professor briefing, 35 scripted steps, referencing pose keys that may not exist
yet (fallback handles it).
### 6. Frontend
- [ ] `SplashScreen`: title **PRINCIPAL INVESTIGATOR** over "Glitch University" in
the existing boot aesthetic; **New Game** always, **Resume** when `current`
returns a playthrough.
- [ ] `DialogueOverlay`: render `pendingCutscene` steps; portrait from the
resolved pose (fallback to name+text when null); advance on click/Space/Enter;
typewriter with instant-reveal on first press; `prefers-reduced-motion`
respected; on finish call `…/seen` then reveal the board.
- [ ] App boot: `GET …/current` → no playthrough shows splash; New Game POSTs,
loads the returned level (reuse existing level fetch/render), and plays
`pendingCutscene` before handing control to the board.
### 7. Tests
- [ ] Unit: `resolvePose` across requested / default / none; `getPendingCutscene`
returns only unseen scenes.
- [ ] Integration: New Game creates a playthrough bound to the test user;
`current` returns it; `seen` is idempotent; a second user id cannot read or mark
the first user's playthrough.
- [ ] Integration: importing the extended manifest freezes the NPC + intro
cutscene, and a New Game surfaces exactly those steps.
- [ ] (Optional) Browser smoke: splash → New Game → overlay appears and advances →
board visible; reload does not replay the seen briefing.
## Definition of done (slice)
Against the test user, New Game creates a playthrough, the professor's scripted
briefing plays with a pose swap (or clean name+text when art is absent), the Glass
Harbor board loads, and a reload resumes without replaying the briefing — with the
cutscene authored via the manifest, not hard-coded.
## Open defaults (flag before coding if you disagree)
- Second New Game **resumes** an unfinished playthrough (explicit Restart), rather
than always starting fresh.
- `seen_dialogue` is **cutscene-level**, not step-level (no mid-scene resume yet).
- One migration `015` holds all slice tables rather than several.
+119
View File
@@ -0,0 +1,119 @@
# Narrative layer: campaigns, NPC cutscenes, and authoring
This roadmap covers the narrative layer end to end: the **campaign** that chains
levels into a mystery, the **NPCs / poses / cutscenes** the player watches
between levels, and the **admin authoring panel** that lets a game designer build
all of it in-app. Work generally proceeds top to bottom.
**Design intent.** These mysteries are real-world scam cases that must actually be
investigated to be understood. Such cases cannot be simplified, they
can only be staged for didactic discovery into levels.
The LLM is not a decoration on the cutscenes — it
is a **cognitive shim**: the assistant that keeps a player oriented as case
complexity grows. A player can use this feature many times, but some very bright
players might get it right on the first go.
This is what lets a mystery be as intricate as the real case
demands without the player getting lost. The scripted narrative layer below is the
delivery channel and the fallback; the LLM is the layer that scales comprehension.
Section 9 is deferred in build order but primary in intent, so the model is shaped
now to accommodate it (the `generated` step kind, the read-only context contract,
and the authored case model).
## Working agreement
- All narrative content — campaigns, NPCs, poses, cutscenes, dialogue text — is
authored template data frozen through supported operations. None of it is
hard-coded into React components or SQL seed literals. The exception to this rule is
custom cutscenes (react components) which are registered as components and referenced by the node.
- The admin panel is a GUI over the **same** operations available to the manifest
importer; both paths freeze the same immutable template data.
- A cutscene never mutates a board. The professor's scene *narrates* the new
document and goal; those exhibits and the updated brief already live in the
next chapter's template.
- A narrative behavior is complete only when its PostgreSQL representation, API
behavior, frontend presentation, persistence, and focused tests agree.
- Pose portraits are immutable shared bytes, stored and cloned exactly like
document image assets (MinIO + `objectStorage`); scenes reference assets, they
never duplicate them.
## First vertical slice: "The Glass Harbour Diversion"
Build the smallest end-to-end narrative loop before generalizing. Ship these in
order; each is playable on its own.
- [ ] Add the **splash screen** — "PRINCIPAL INVESTIGATOR", Glitch University —
with a single **New Game** action that creates a playthrough and launches the
first scene (sections 1, 6, 7).
- [ ] Create a mystery named **The Glass Harbour Diversion** as a one-chapter
campaign wrapping the existing Glass Harbor level template (sections 12).
- [ ] Add a **briefing NPC** (the Glitch University professor) and a
`mystery_intro` cutscene that briefs the player, using at least two poses to
prove pose-per-utterance (sections 23, 7).
- [ ] Wire the briefing to play once on load and mark itself seen, then reveal the
first level's board (sections 1, 6, 7).
- [ ] Author **two `level_debrief` end-scenes** the player reaches by reporting
back: one that **sends them back to the board** and one that **concludes the
mystery**. Model these as two end-of-scene outcomes (buttons), not branching.
- [ ] Leave the back-to-board scene as **scripted** for now, but author it as a
`generated`-ready step (section 9) so the professor's hint can later be produced
from the player's Case Report explanation.
## 1. Campaign / progression backbone
- [ ] Define a **cutscene** node as something a) references a custom react component.
That react can use potential **utterances** such that for example, it can play
an ordered sequence utterance that brief the player: `(speaker NPC, pose, text)`.
A node can be marked has_utterances which permits the admin user to add utterances in order.
[ ] A dialogue is another type of node that invokes the standard NPC dialogue component. This
has utterances, and consist of a graph where utterances either are spoken by the NPC or available for selection.
Example : if the NPC utters "Are you ready?" this has two child utterances marked "player" which could be "yes" and no. The user may select these. "No" could in principle point back to the same utterance and "yes" to the next. If an utterance has a non NULL terminal id, then the game advances to the node pointed to by that terminal. Available terminals are only those who have the current dialogue node as it parent.
- [ ] There exists "det_gate" nodes and "llm_gate" nodes. We begin with the deterministic gate only. The end result of a level is sent to a "det_gate". The det gate can inspect the output of the level and determine if the story should advance through one of its terminals. For now, the det-gate always returns the happy path terminal leading to the mystery being solved.
## 2. NPC and pose catalog
- [ ] Add an **NPC** entity (display name, short role e.g. "Glitch University
professor", default pose) owned by the mystery/template family, so casts are
authored rather than global magic strings.
- [ ] Add **poses** as named portrait variants of an NPC (`pose_key` such as
`neutral`, `concerned`, `wry`, `pointing`), each backed by one immutable image
asset via the existing `assets` table + MinIO path.
- [ ] Clone NPCs and pose→asset references (asset bytes reused, not copied) during
template freeze and instantiation, mirroring document image cloning.
- [ ] Enforce that every dialogue step names an NPC that exists in the mystery's
cast; a **missing pose is never an error**.
- [ ] Resolve a step's portrait at render time with graceful fallback: the
requested `pose_key`, else the NPC's **`default`** pose, else **no artwork**
(speaker name + text only). This lets authors add poses incrementally and keeps
the first slice playable with zero uploaded art.
## 6. API contract
- [ ] **New Game / session:** add `POST …/playthroughs` (create for the current
`user_id`, per section 1) and `GET …/playthroughs/current` so the splash can
offer New Game or Resume; scope every playthrough read/write to the caller's
`user_id` so one player cannot touch another's game state.
- [ ] **Play mode:** return a compact `pendingCutscene` payload (slot, ordered
steps with resolved NPC name + pose asset URL) when one is due and unseen; add
`POST …/playthroughs/:id/cutscenes/:cutsceneId/seen` (idempotent) and
`POST …/playthroughs/:id/advance` implementing section 1's transactional advance.
- [ ] **Admin mode:** add authenticated CRUD for mysteries, chapter ordering,
NPCs and poses, and dialogue scenes/steps, plus the campaign freeze operation —
all behind the existing admin JWT and `edit=1` gate.
- [ ] Keep authoring-only fields (raw pose keys, expected-solution data, unfrozen
drafts) out of play-mode responses, consistent with how brief concept
`expectedPartyKind` is already hidden in play mode.
## Definition of done
A game designer can, in the admin panel, create a mystery, select existing levels
as ordered chapters, create NPCs and upload named poses, craft dialogue scenes
choosing a pose per step, and attach those scenes to chapter slots — then freeze
it. A player lands on the "PRINCIPAL INVESTIGATOR" splash, chooses New Game to
create a playthrough bound to their identity, watches the professor speak
line-by-line with a
changing portrait, investigates each board, reports back to advance a chapter that
introduces a new document and goal, and reloads at any point without replaying
seen scenes — with no dialogue text hard-coded in React and no cutscene mutating a
board.
+191
View File
@@ -0,0 +1,191 @@
# Story flow graph: the mystery editor
Status: **implemented** (migrations `018``021`). A mystery plays as a walk of an
authored node graph; the slot/chapter model has been retired in a clean cutover.
Gates are stubbed and the LLM gate is not built (see *Implemented vs deferred*).
Builds on the NPC/dialogue work in [narrative-todo.md](narrative-todo.md).
## Concept
A mystery is a **directed graph of nodes**. Each node has one implicit input and N
output **terminals**; a terminal carries its single outgoing wire (`to_node_id`) —
there is no separate edges table. A playthrough walks the graph from the mystery's
single **entrypoint node** (`mysteries.entry_node_id`).
- At the **node level** cycles are allowed (a gate can route back to a level). The
runtime only advances a node when the player acts, so loops never spin on their own.
- Within a **dialogue node** the utterances form a **tree** (each utterance has one
parent), so back-edges/loops are not expressible there yet.
## Node types
| type | what it does | terminals |
|---|---|---|
| `cutscene` | Renders a bespoke React component chosen from a frontend registry (`component_key`), e.g. a title card. Opaque to the graph so set-pieces don't clutter the dialogue tree. | usually 1 (`continue`) |
| `dialogue` | The standard NPC dialogue box, driven by an utterance tree (NPC lines + player choices). | 1+ (e.g. `continue`, or `proceed`/`retry` for a branch) |
| `level` | Instantiates a playable board from a level template version and hands over to the investigation. | 1 (`report_back`) |
| `det_gate` | Deterministic gate. **Currently a stub**: auto-follows its first terminal. Intended to inspect the previous node's output (a level's case report, a dialogue's chosen path) and route accordingly. | 1+ |
| `llm_gate` | LLM gate — **not implemented**. Intended: read allowlisted player state, return one terminal key (constrained output). | 1+ |
Gates are resolved server-side during `advance` and never surfaced to the player.
## Data model
Two core tables plus `utterances`; type-specific scalars are folded onto the node
(kept honest by CHECKs) rather than in per-type subtype tables.
```sql
CREATE TABLE osint.story_nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mystery_id UUID NOT NULL REFERENCES osint.mysteries(id) ON DELETE CASCADE,
node_type TEXT NOT NULL CHECK (node_type IN ('cutscene','dialogue','level','det_gate','llm_gate')),
label TEXT NOT NULL DEFAULT '',
has_utterances BOOLEAN NOT NULL DEFAULT FALSE,
xpos DOUBLE PRECISION NOT NULL,
ypos DOUBLE PRECISION NOT NULL,
-- Folded, type-specific scalars. Nullable during authoring; "required for type"
-- is a publish-time concern, so only these exclusion CHECKs are enforced.
level_template_version_id UUID REFERENCES osint.level_template_versions(id),
component_key TEXT, -- cutscene: frontend component; gate: (future) backend gate fn
CHECK (level_template_version_id IS NULL OR node_type = 'level'),
CHECK (component_key IS NULL OR node_type IN ('cutscene','det_gate','llm_gate'))
);
-- Output ports. A terminal owns its single outgoing wire; no edges table.
CREATE TABLE osint.story_node_terminals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_node_id UUID NOT NULL REFERENCES osint.story_nodes(id) ON DELETE CASCADE,
terminal_key TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
to_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL, -- NULL = unwired/end
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE (parent_node_id, terminal_key)
);
-- Same-mystery integrity for to_node_id is enforced in the repository (not a
-- DB trigger). entry_node_id is nullable so nodes can be inserted before it is set.
ALTER TABLE osint.mysteries ADD COLUMN entry_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL;
```
### Utterances — a dialogue node's content
One table for both NPC lines and player choices, distinguished by `utterer`.
```sql
CREATE TABLE osint.utterances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
node_id UUID NOT NULL REFERENCES osint.story_nodes(id) ON DELETE CASCADE,
utterer TEXT NOT NULL DEFAULT 'npc' CHECK (utterer IN ('npc','player')),
npc_id UUID REFERENCES osint.npcs(id), -- speaker for NPC lines; NULL for player choices
pose_key TEXT, -- resolved with the usual pose fallback
text TEXT NOT NULL DEFAULT '',
parent_utterance_id UUID REFERENCES osint.utterances(id) ON DELETE CASCADE, -- the utterance this one follows
advances_to_utterance_id UUID REFERENCES osint.utterances(id) ON DELETE SET NULL, -- VESTIGIAL / unused (see below)
terminal_id UUID REFERENCES osint.story_node_terminals(id) ON DELETE SET NULL, -- exit: leave the node via this terminal
effect TEXT, -- reserved side-effect hook (unused)
xpos DOUBLE PRECISION NOT NULL DEFAULT 0, -- position in the utterance sub-canvas
ypos DOUBLE PRECISION NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
);
```
**The model is parent-only with count-based meaning.** An utterance's *children*
(the utterances whose `parent_utterance_id` points to it) are what come after it:
- **0 children** + `terminal_id` set ⇒ this line **exits** the node via that terminal.
- **1 child** ⇒ **linear** next line (rendered as a solid wire).
- **2+ children** ⇒ **player options** (rendered as dotted wires); by convention the
children are `player` utterances and the parent is an NPC prompt.
The root of a node's tree is the utterance with no parent. `advances_to_utterance_id`
is a leftover column from an earlier design and is **not used**; a follow-up
migration can drop it. Because each utterance has a single parent the graph is a
tree — no back-edges/loops within a node yet.
### Cutscene component registry (frontend)
Mirrors the exhibit registry: `component_key → React component`. Each owns its
presentation and signals completion with an optional terminal key.
```ts
type CutsceneComponent = React.FC<{ onComplete: (terminalKey?: string) => void }>
// registry: { 'glass-harbour-diversion': GlassHarbourDiversion }
```
For a single-terminal cutscene, `onComplete()` follows the only terminal.
## Editing UX
The same canvas engine (pan, zoom, drag, curved wires, click-a-wire-to-delete) is
reused at two levels.
**Mystery graph**`story_nodes` as cards, wired by `terminal.to_node_id`. Laid out
**vertically**: the input port is on **top**, output terminals along the **bottom**,
and flow runs downward. Double-clicking a dialogue node drills into its utterances.
**Utterance crafter** — a dialogue node's utterances as draggable cards; the node's
output terminals appear as **exit sinks** docked to the right. Each card has one
output port:
- Drag a card's port to another card ⇒ that card becomes a **child** (1 child = solid
linear; 2+ = dotted options).
- Drag to an exit sink ⇒ the card leaves the node via that `terminal_id`.
- **Tab** on a selected utterance adds a child (a lone child stays a linear NPC line;
a second flips them to player options). **1** / **2** set the selected card's
speaker. **Ctrl/Cmd+Z** undoes connection/creation edits. Cards colour by speaker
and auto-expand to full text.
## Runtime traversal
The playthrough tracks position with `current_node_id` (and `current_level_id`,
set while on a level node); the slot fields were dropped.
```sql
ALTER TABLE osint.playthroughs ADD COLUMN current_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL;
```
- **New Game** creates a playthrough at the entrypoint (bound to the JWT identity,
with a dev test-user fallback).
- **`getCurrentPlaythrough`** returns the resolved current node: a cutscene
(`componentKey`), a level (`levelSlug`), or a **dialogue tree** (all utterances with
resolved speaker/pose, ordered `childIds`, and each exit's `terminalKey`, plus the
`rootId`).
- **`advance(terminalKey?)`** follows a terminal, **auto-skips gates** (the stub
det_gate takes its first terminal), instantiates the board when entering a level,
and finishes when a followed terminal has no target.
- **Dialogue is walked on the client** (`DialoguePlayer`): play NPC lines with the
typewriter; at a branch (2+ player children) present choice buttons; a chosen child
leads to its next line or, if it has a `terminalKey`, calls `advance(terminalKey)`
to leave the node — routing the graph to a different next node.
## Glass Harbour seed
Authored in `mysteries/glass-harbor/mystery.json` (`narrative.graph`) and seeded by
the importer, so it survives re-imports. It is linear:
```
[cutscene: glass-harbour-diversion] → [dialogue: Briefing] → [level: glass-harbor] → [dialogue: Debrief] → (end)
```
Branching (player options routing to different terminals) is supported and tested,
just not used in the seed.
## Implemented vs deferred
**Implemented:** graph schema, node/terminal/utterance CRUD + admin editors, the
runtime cutover (cutscene / dialogue-tree / level traversal, branching dialogue),
the vertical mystery-graph canvas, the utterance crafter, and the seed.
**Deferred:**
- **Real gates.** `det_gate` is a hardcoded "first terminal" stub; `llm_gate` is
unbuilt. Both want the **Case Report / Claims** (Milestone 5) as input, plus a
gate-function registry keyed like cutscene components.
- **Within-node loops** (an utterance's single parent makes each dialogue a tree).
- **Drop `advances_to_utterance_id`** and the reserved `effect` column.
- **Cutscene params** and real pose art.
## Validation rules
- One `entry_node_id` per mystery (soft-recommended to be a `cutscene`).
- A terminal's `to_node_id` must share its parent node's mystery (repo-enforced).
- An utterance's `terminal_id` must belong to its node; parent links stay in-node.
- Unreachable nodes and unwired terminals are allowed (deliberate ends).
+250
View File
@@ -0,0 +1,250 @@
import { expect, test, type Locator, type Page } from '@playwright/test'
async function dragBy(page: Page, locator: Locator, deltaX: number, deltaY: number) {
const box = await locator.boundingBox()
if (!box) throw new Error('Drag target is not visible')
const start = { x: box.x + Math.min(30, box.width / 3), y: box.y + Math.min(14, box.height / 4) }
await page.mouse.move(start.x, start.y)
await page.mouse.down()
await page.mouse.move(start.x + deltaX / 2, start.y + deltaY / 2, { steps: 3 })
await page.mouse.move(start.x + deltaX, start.y + deltaY, { steps: 3 })
await page.mouse.up()
}
async function boardPosition(locator: Locator) {
return locator.evaluate(element => ({
left: Number.parseFloat((element as HTMLElement).style.left),
top: Number.parseFloat((element as HTMLElement).style.top),
}))
}
async function waitForSave(page: Page, action: () => Promise<void>) {
const response = page.waitForResponse(candidate => candidate.request().method() === 'PUT' && candidate.url().includes('/api/levels/') && candidate.ok())
await action()
await response
}
test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch, and reload persistence', async ({ page }) => {
await page.goto('/?level=e2e-level&edit=1')
await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible()
await expect(page.getByRole('button', { name: 'ADMIN', exact: true })).toBeVisible()
expect((await page.locator('.menubar nav > button, .menubar nav > .admin-menu > button').allTextContents()).map(label => label.replace(/\d+$/, ''))).toEqual(['EVIDENCE', 'CASE BRIEF', 'TIMELINE', 'HELP', 'ADMIN'])
const temporalLayering = await page.evaluate(() => ({
links: Number.parseInt(getComputedStyle(document.querySelector('.temporal-links')!).zIndex, 10),
timeline: Number.parseInt(getComputedStyle(document.querySelector('.timeline')!).zIndex, 10),
}))
expect(temporalLayering.links).toBeGreaterThan(temporalLayering.timeline)
expect(await page.locator('.timeline .year').first().evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBeGreaterThanOrEqual(12)
expect(await page.locator('.timeline-label button').evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBeGreaterThanOrEqual(11)
await expect(page.locator('.documents-panel')).toHaveClass(/\bclosed\b/)
await expect(page.locator('.brief-panel')).toBeVisible()
await expect(page.getByRole('button', { name: 'Case brief', exact: true })).toContainText('2')
await page.getByRole('button', { name: 'Minimize brief', exact: true }).click()
await expect(page.locator('.brief-panel')).toHaveClass(/\bminimized\b/)
await expect(page.locator('.brief-panel > p')).toBeHidden()
await expect(page.getByRole('button', { name: 'Close brief', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Restore brief', exact: true }).click()
await expect(page.locator('.brief-panel')).not.toHaveClass(/\bminimized\b/)
await page.getByRole('button', { name: 'BEGIN INVESTIGATION', exact: true }).click()
await page.getByRole('button', { name: 'EVIDENCE', exact: true }).click()
const documentRow = page.locator('[data-document-row-id="22222222-2222-4222-8222-222222222222"]')
await documentRow.click()
await expect(documentRow).toHaveClass(/\bselected\b/)
await expect(page.locator('[data-temporal-id="widget:11111111-1111-4111-8111-111111111111"]')).toHaveClass(/\bdocument-located\b/)
await expect(page.locator('.document-locator-beam .document-locator-ray')).toBeVisible()
await documentRow.dblclick()
await expect(page.getByRole('button', { name: 'Close document', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Close document', exact: true }).click()
await page.getByRole('button', { name: 'Close documents', exact: true }).click()
page.once('dialog', dialog => dialog.accept('Disposable working theory'))
await waitForSave(page, () => page.getByRole('button', { name: 'NEW NOTE', exact: true }).click())
const note = page.locator('.evidence-card.note').filter({ hasText: 'Disposable working theory' })
const trash = page.locator('.board-trash')
await expect(note).toBeVisible()
const noteBox = await note.boundingBox(), trashBox = await trash.boundingBox()
if (!noteBox || !trashBox) throw new Error('Note or exhibit trash is not visible')
const noteDragStart = { x: noteBox.x + noteBox.width / 2, y: noteBox.y + noteBox.height / 2 }
const discardSave = page.waitForResponse(candidate => candidate.request().method() === 'PUT' && candidate.url().includes('/api/levels/') && candidate.ok())
await page.mouse.move(noteDragStart.x, noteDragStart.y)
await page.mouse.down()
await page.mouse.move(trashBox.x + trashBox.width / 2, trashBox.y + trashBox.height / 2, { steps: 8 })
await expect(trash).toHaveClass(/\bactive\b/)
await page.mouse.up()
await discardSave
await expect(note).toHaveCount(0)
await page.reload()
await expect(page.locator('.evidence-card.note')).toHaveCount(0)
const folder = page.locator('[data-temporal-id="widget:11111111-1111-4111-8111-111111111111"]')
const file = page.locator('[data-temporal-id="file:contains:11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222"]')
const board = page.locator('.board')
const boardViewport = page.locator('.board-viewport')
const containmentBand = page.locator('.folder-bands line')
await expect(folder).toBeVisible()
await expect(containmentBand).toHaveClass(/\bclosed\b/)
const folderBefore = await boardPosition(folder)
await waitForSave(page, () => dragBy(page, folder, 70, 42))
const movedFolder = await boardPosition(folder)
expect(movedFolder.left).toBeGreaterThan(folderBefore.left)
expect(movedFolder.top).toBeGreaterThan(folderBefore.top)
await page.reload()
await expect(folder).toBeVisible()
expect(await boardPosition(folder)).toEqual(movedFolder)
await waitForSave(page, () => folder.getByRole('button', { name: 'OPEN', exact: true }).click())
await expect(file).toHaveClass(/\bopen\b/)
await expect(containmentBand).toHaveClass(/\bopen\b/)
const fileBefore = await boardPosition(file)
await waitForSave(page, () => dragBy(page, file, 56, 35))
const movedFile = await boardPosition(file)
expect(movedFile.left).toBeGreaterThan(fileBefore.left)
expect(movedFile.top).toBeGreaterThan(fileBefore.top)
await page.reload()
await expect(file).toHaveClass(/\bopen\b/)
expect(await boardPosition(file)).toEqual(movedFile)
const folderBeforePan = await boardPosition(folder)
const transformBeforePan = await board.getAttribute('style')
await expect(page.getByRole('button', { name: 'MOVE', exact: true })).toHaveClass(/\bactive\b/)
const viewportBox = await boardViewport.boundingBox()
if (!viewportBox) throw new Error('Board viewport is not visible')
await waitForSave(page, async () => {
await boardViewport.dispatchEvent('pointerdown', { pointerId: 31, pointerType: 'mouse', isPrimary: true, button: 0, clientX: viewportBox.x + 240, clientY: viewportBox.y + 230 })
await boardViewport.dispatchEvent('pointermove', { pointerId: 31, pointerType: 'mouse', isPrimary: true, button: 0, clientX: viewportBox.x + 290, clientY: viewportBox.y + 195 })
await boardViewport.dispatchEvent('pointerup', { pointerId: 31, pointerType: 'mouse', isPrimary: true, button: 0, clientX: viewportBox.x + 290, clientY: viewportBox.y + 195 })
})
expect(await boardPosition(folder)).toEqual(folderBeforePan)
const transformAfterPan = await board.getAttribute('style')
expect(transformAfterPan).not.toEqual(transformBeforePan)
await page.reload()
await expect(folder).toBeVisible()
expect(await board.getAttribute('style')).toEqual(transformAfterPan)
const transformBeforeWheel = await board.getAttribute('style')
const browserMetricsBeforeWheel = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))
const folderAnchorBefore = await folder.boundingBox()
if (!folderAnchorBefore) throw new Error('Folder is not visible for cursor-centered zoom')
const wheelAnchor = { x: folderAnchorBefore.x + folderAnchorBefore.width / 2, y: folderAnchorBefore.y + folderAnchorBefore.height / 2 }
await page.mouse.move(wheelAnchor.x, wheelAnchor.y)
await page.mouse.wheel(0, -80)
await expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforeWheel)
const folderAnchorAfter = await folder.boundingBox()
if (!folderAnchorAfter) throw new Error('Folder disappeared after zoom')
expect(folderAnchorAfter.x + folderAnchorAfter.width / 2).toBeCloseTo(wheelAnchor.x, 0)
expect(folderAnchorAfter.y + folderAnchorAfter.height / 2).toBeCloseTo(wheelAnchor.y, 0)
expect(await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))).toEqual(browserMetricsBeforeWheel)
const transformBeforeDesktopPinch = await board.getAttribute('style')
await page.keyboard.down('Control')
await page.mouse.wheel(0, -80)
await page.keyboard.up('Control')
await page.waitForTimeout(100)
expect(await board.getAttribute('style')).toEqual(transformBeforeDesktopPinch)
expect(await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))).toEqual(browserMetricsBeforeWheel)
const transformBeforeTouchPinch = await board.getAttribute('style')
const touchOrigin = { x: viewportBox.x + 350, y: viewportBox.y + 240 }
await boardViewport.dispatchEvent('pointerdown', { pointerId: 41, pointerType: 'touch', isPrimary: true, button: 0, clientX: touchOrigin.x, clientY: touchOrigin.y })
await boardViewport.dispatchEvent('pointerdown', { pointerId: 42, pointerType: 'touch', isPrimary: false, button: 0, clientX: touchOrigin.x + 100, clientY: touchOrigin.y })
await boardViewport.dispatchEvent('pointermove', { pointerId: 42, pointerType: 'touch', isPrimary: false, button: 0, clientX: touchOrigin.x + 150, clientY: touchOrigin.y })
await expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforeTouchPinch)
await boardViewport.dispatchEvent('pointerup', { pointerId: 42, pointerType: 'touch', isPrimary: false, button: 0, clientX: touchOrigin.x + 150, clientY: touchOrigin.y })
await boardViewport.dispatchEvent('pointerup', { pointerId: 41, pointerType: 'touch', isPrimary: true, button: 0, clientX: touchOrigin.x, clientY: touchOrigin.y })
await page.getByRole('button', { name: 'Case brief', exact: true }).click()
await expect(page.locator('.brief-panel')).toContainText('Ada Lovelace')
const personConcept = page.locator('.brief-concepts section').filter({ hasText: 'Ada Lovelace' })
await waitForSave(page, () => personConcept.getByRole('button', { name: 'PERSON', exact: true }).click())
await expect(page.locator('.brief-panel')).toBeVisible()
await expect(page.locator('.evidence-card.party.arriving')).toContainText('Ada Lovelace')
await personConcept.getByRole('button', { name: 'EDIT DOSSIER', exact: true }).click()
await expect(page.getByText('Edit person dossier')).toBeVisible()
await page.getByLabel('Party aliases').fill('A. A. L.')
await page.locator('.party-editor .folder-members input[type="checkbox"]').first().check()
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click())
await expect(page.locator('.evidence-card.party')).toContainText('Ada Lovelace')
const organizationConcept = page.locator('.brief-concepts section').filter({ hasText: 'Difference Engine Bureau' })
await waitForSave(page, () => organizationConcept.getByRole('button', { name: 'ORGANIZATION', exact: true }).click())
await expect(page.locator('.brief-panel')).toBeVisible()
await organizationConcept.getByRole('button', { name: 'EDIT DOSSIER', exact: true }).click()
await page.getByLabel('Organization type').selectOption('public_body')
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click())
await page.getByRole('button', { name: 'CREATE PARTY NOT LISTED ABOVE', exact: true }).click()
await expect(page.getByText('Create party dossier', { exact: true })).toBeVisible()
await page.getByLabel('Party type').selectOption('person')
await page.getByLabel('Party name').fill('Mara Elise Voss')
await page.getByLabel('Party summary').fill('Identified by the investigator outside the supplied concept list.')
await page.locator('.party-editor .folder-members input[type="checkbox"]').first().check()
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click())
await expect(page.locator('.brief-panel')).toBeVisible()
await page.getByRole('button', { name: 'RETURN TO BOARD', exact: true }).click()
await page.reload()
await expect(page.locator('.brief-panel')).toBeHidden()
await expect(page.locator('.evidence-card.party')).toHaveCount(3)
await expect(page.locator('.evidence-card.party')).toContainText(['Ada Lovelace', 'Difference Engine Bureau', 'Mara Elise Voss'])
await page.getByRole('button', { name: 'NEW EVENT', exact: true }).click()
await expect(page.getByText('Edit reconstructed event')).toBeVisible()
await page.getByLabel('Event title').fill('The browser clue was connected')
await page.getByLabel('Event narrative').fill('The investigator connected the folder to a dated source.')
await page.locator('.event-support-list input[type="checkbox"]').first().check()
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE EVENT', exact: true }).click())
await page.reload()
await expect(page.locator('.evidence-card.event')).toContainText('The browser clue was connected')
await expect(page.locator('.evidence-card.event')).toContainText('UNDATED')
await expect(page.locator('.story-strip')).toContainText('The investigator connected the folder')
await expect(page.locator('.story-strip')).toContainText('UNDATED')
await expect(page.locator('.timeline .marker')).toHaveCount(1)
await expect(page.locator('.event-support-lines line')).toHaveCount(1)
page.once('dialog', dialog => dialog.accept('Browser Template'))
const templateSaved = page.waitForResponse(candidate => candidate.request().method() === 'POST' && candidate.url().includes('/templates?edit=1') && candidate.ok())
await page.getByRole('button', { name: 'ADMIN', exact: true }).click()
await page.getByRole('menuitem', { name: 'SAVE AS TEMPLATE', exact: true }).click()
await templateSaved
await expect(page.locator('.terminal-status')).toContainText('VERSION 1')
let promptIndex = 0
const prompts = ['browser-template', 'Browser Template Clone']
const promptHandler = (dialog: { accept(promptText?: string): Promise<void> }) => dialog.accept(prompts[promptIndex++])
page.on('dialog', promptHandler)
await page.getByRole('button', { name: 'ADMIN', exact: true }).click()
await page.getByRole('menuitem', { name: 'NEW FROM TEMPLATE', exact: true }).click()
await page.waitForURL(url => url.searchParams.get('level')?.startsWith('browser-template-') === true)
page.off('dialog', promptHandler)
await expect(page.getByRole('heading', { name: 'Browser Template Clone' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Reset' })).toBeEnabled()
await page.setViewportSize({ width: 390, height: 844 })
const portraitToolbar = await page.locator('.board-actions').boundingBox()
if (!portraitToolbar) throw new Error('Portrait toolbar is not visible')
expect(portraitToolbar.height).toBeGreaterThan(portraitToolbar.width * 2)
expect(portraitToolbar.x).toBeGreaterThan(280)
const portraitMove = await page.getByRole('button', { name: 'MOVE', exact: true }).boundingBox()
const portraitParty = await page.getByRole('button', { name: 'NEW PARTY', exact: true }).boundingBox()
expect(portraitMove).not.toBeNull()
expect(portraitParty).not.toBeNull()
expect(portraitParty!.y).toBeGreaterThan(portraitMove!.y)
await page.setViewportSize({ width: 844, height: 390 })
const landscapeToolbar = await page.locator('.board-actions').boundingBox()
if (!landscapeToolbar) throw new Error('Landscape toolbar is not visible')
expect(landscapeToolbar.width).toBeGreaterThan(landscapeToolbar.height * 2)
})
test('hides the admin menu without a verified admin JWT', async ({ browser }) => {
const context = await browser.newContext({ baseURL: 'http://127.0.0.1:18788', viewport: { width: 1280, height: 720 }, extraHTTPHeaders: { Cookie: 'anonymous_session=1' } })
const page = await context.newPage()
await page.goto('/?level=e2e-level&edit=1')
await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible()
await expect(page.getByRole('button', { name: 'ADMIN', exact: true })).toHaveCount(0)
expect((await page.locator('.menubar nav > button').allTextContents()).map(label => label.replace(/\d+$/, ''))).toEqual(['EVIDENCE', 'CASE BRIEF', 'TIMELINE', 'HELP'])
await context.close()
})
+183
View File
@@ -0,0 +1,183 @@
import { expect, test, type Page } from '@playwright/test'
async function dragBy(page: Page, locator: ReturnType<Page['locator']>, deltaX: number, deltaY: number) {
const box = await locator.boundingBox()
if (!box) throw new Error('Drag target is not visible')
const start = { x: box.x + box.width / 2, y: box.y + Math.min(18, box.height / 4) }
await page.mouse.move(start.x, start.y)
await page.mouse.down()
await page.mouse.move(start.x + deltaX, start.y + deltaY, { steps: 8 })
await page.mouse.up()
}
async function waitForSave(page: Page, action: () => Promise<void>) {
const response = page.waitForResponse(candidate => candidate.request().method() === 'PUT' && candidate.url().includes('/api/levels/') && candidate.ok())
await action()
await response
}
async function classify(page: Page, name: string, kind: 'PERSON' | 'ORGANIZATION', summary: string) {
if (!await page.locator('.brief-panel').isVisible()) await page.getByRole('button', { name: 'Case brief', exact: true }).click()
const concept = page.locator('.brief-concepts section').filter({ hasText: name })
await waitForSave(page, () => concept.getByRole('button', { name: kind, exact: true }).click())
await expect(page.locator('.brief-panel')).toBeVisible()
await expect(concept).toHaveClass(/\bjust-resolved\b/)
await concept.getByRole('button', { name: 'EDIT DOSSIER', exact: true }).click()
await page.getByLabel('Party summary').fill(summary)
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click())
}
async function createEvent(page: Page, title: string, narrative: string, occurredAt: string, sources: string[]) {
await page.getByRole('button', { name: 'NEW EVENT', exact: true }).click()
await page.getByLabel('Event title').fill(title)
await page.getByLabel('Event narrative').fill(narrative)
await page.getByLabel('Occurred at').fill(occurredAt)
for (const source of sources) {
await page.locator('.event-support-list .folder-member').filter({ hasText: source }).locator('input[type="checkbox"]').check()
}
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE EVENT', exact: true }).click())
}
test('a cloned Glass Harbor level can be solved without modifying its template', async ({ page, request }) => {
test.setTimeout(90_000)
await expect.poll(async () => {
const seededLevels = await (await request.get('/api/levels')).json() as { id: string }[]
return seededLevels.some(level => level.id.startsWith('glass-harbor-case-'))
}).toBe(true)
const levels = await (await request.get('/api/levels')).json() as { id: string }[]
const playable = levels.find(level => level.id.startsWith('glass-harbor-case-'))
if (!playable) throw new Error('Glass Harbor playable clone was not seeded')
await page.goto(`/?level=${playable.id}`)
await expect(page.getByRole('heading', { name: 'The Glass Harbor Diversion' })).toBeVisible()
await expect(page.locator('.documents-panel')).toHaveClass(/\bclosed\b/)
await page.locator('.open-files').click()
await page.getByRole('searchbox', { name: 'Search inside documents', exact: true }).fill('Elias')
await expect(page.locator('.doc-row')).toHaveCount(1)
await expect(page.locator('.doc-row')).toContainText('Carrier Dispatch Manifest · H&F 14')
await page.getByRole('button', { name: 'Clear document search', exact: true }).click()
await expect(page.locator('.doc-row')).toHaveCount(8)
await page.getByRole('button', { name: 'Close documents', exact: true }).click()
await expect(page.locator('.evidence-card.folder')).toHaveCount(3)
await expect(page.locator('.timeline .marker')).toHaveCount(8)
await expect(page.locator('.timeline-label button')).toHaveText('1987-10-01 — 1987-10-31')
const dispatchMarker = await page.getByRole('button', { name: '1987-10-16 — Carrier Dispatch Manifest · H&F 14', exact: true }).boundingBox()
const auctionMarker = await page.getByRole('button', { name: '1987-10-24 — Meridian Maritime Auction · Lot 117', exact: true }).boundingBox()
expect(dispatchMarker).not.toBeNull()
expect(auctionMarker).not.toBeNull()
expect(auctionMarker!.x - dispatchMarker!.x).toBeGreaterThan(100)
await page.getByRole('button', { name: 'TIMELINE', exact: true }).click()
await page.getByLabel('Timeline start date').fill('1987-10-10')
await page.getByLabel('Timeline end date').fill('1987-10-26')
await waitForSave(page, () => page.getByRole('button', { name: 'APPLY RANGE', exact: true }).click())
await expect(page.locator('.timeline-label button')).toHaveText('1987-10-10 — 1987-10-26')
await classify(page, 'Mara Voss', 'PERSON', 'Restoration project officer who sponsored the Warehouse 3 access pass.')
await classify(page, 'Elias Vale', 'PERSON', 'Driver of H&F 14; transported CO-771 to Warehouse 3.')
await classify(page, 'Greyhaven Preservation Society', 'ORGANIZATION', 'Commissioned the North Quay restoration and owned the missing lens.')
await classify(page, 'Calder Optical Works', 'ORGANIZATION', 'Supplier that released sealed crate CO-771 to the approved carrier.')
await classify(page, 'Harbor & Fell Logistics', 'ORGANIZATION', 'Approved carrier whose vehicle delivered the crate to the wrong address.')
await classify(page, 'Voss Antiquities Ltd', 'ORGANIZATION', 'Mara Vosss company, based at Warehouse 3 and named as auction consignor.')
await page.getByRole('button', { name: 'RETURN TO BOARD', exact: true }).click()
await createEvent(page, 'CO-771 left Calder for North Quay', 'Calder released the sealed lens to Elias Vale in H&F 14 with no alternate delivery authority.', '1987-10-16T16:40', ['Carrier Dispatch Manifest'])
await createEvent(page, 'CO-771 was diverted to Warehouse 3', 'Mara Voss sponsored access; H&F 14 entered loaded and left empty. The next-day memorandum was retrospective.', '1987-10-17T22:08', ['Old Glass Harbor Gate Ledger', 'Warehouse 3 Security Photograph', 'Delivery Redirection Memorandum'])
await createEvent(page, 'Voss Antiquities offered the lens for sale', 'The auction description and seller reference identify the missing assembly and the company that stood to profit.', '1987-10-24T12:00', ['Company Register Extract', 'Meridian Maritime Auction'])
page.once('dialog', dialog => dialog.accept('The redirection memorandum was written after the crate had already moved.'))
await waitForSave(page, () => page.getByRole('button', { name: 'NEW NOTE', exact: true }).click())
const elias = page.locator('.evidence-card.party').filter({ has: page.getByRole('heading', { name: 'Elias Vale', exact: true }) })
const movement = page.locator('.evidence-card.folder').filter({ hasText: 'MOVEMENT RECORDS' })
await waitForSave(page, () => page.getByRole('button', { name: 'Zoom out', exact: true }).click())
await waitForSave(page, () => movement.getByRole('button', { name: 'OPEN', exact: true }).click())
const manifest = page.locator('.source-file-widget.open').filter({ hasText: 'Carrier Dispatch Manifest' })
await expect(manifest.locator('.text-source-excerpt')).toContainText('HARBOR & FELL LOGISTICS')
await expect(manifest.locator('.source-file-preview svg')).toHaveCount(0)
const manifestCue = page.getByRole('textbox', { name: 'Memory cue for Carrier Dispatch Manifest · H&F 14', exact: true })
await waitForSave(page, () => manifestCue.fill('ELIAS · H&F 14'))
await elias.click()
await page.getByRole('button', { name: 'Red thread', exact: true }).click()
const boardBox = await page.locator('.board-viewport').boundingBox()
if (!boardBox) throw new Error('Board is not visible')
await page.mouse.move(boardBox.x + boardBox.width / 2, boardBox.y + boardBox.height / 2)
await expect(page.locator('.connections .thread-preview path')).toBeVisible()
const manifestBox = await manifest.boundingBox()
if (!manifestBox) throw new Error('Carrier Dispatch Manifest is not visible')
await page.mouse.move(manifestBox.x + manifestBox.width / 2, manifestBox.y + manifestBox.height / 2)
await manifest.click()
const threadEditor = page.locator('.thread-editor')
await expect(threadEditor.getByText('Elias Vale', { exact: true })).toBeVisible()
await expect(threadEditor.getByText('Carrier Dispatch Manifest · H&F 14', { exact: true })).toBeVisible()
await page.getByLabel('Thread tag').fill('Proof Elias is the driver')
await page.getByRole('slider', { name: 'Thread tightness', exact: true }).fill('85')
await waitForSave(page, () => page.getByRole('button', { name: 'ADD TAG & TIGHTEN', exact: true }).click())
await expect(page.locator('.connections g.tightening path')).toBeVisible()
const procurement = page.locator('.evidence-card.folder').filter({ hasText: 'PROCUREMENT & OWNERSHIP' })
await waitForSave(page, () => procurement.getByRole('button', { name: 'OPEN', exact: true }).click())
const companyRegister = page.locator('.source-file-widget.open').filter({ hasText: 'Company Register Extract' })
const memorandum = page.locator('.source-file-widget.open').filter({ hasText: 'Delivery Redirection Memorandum' })
const photograph = page.locator('.source-file-widget.open').filter({ hasText: 'Warehouse 3 Security Photograph' })
await companyRegister.click()
await expect(companyRegister).toHaveClass(/\bselected\b/)
await page.getByRole('button', { name: 'Red thread', exact: true }).click()
await expect(companyRegister).toHaveClass(/\blinking\b/)
await memorandum.click()
await expect(threadEditor.getByText('Company Register Extract · Voss Antiquities Ltd', { exact: true })).toBeVisible()
await expect(threadEditor.getByText('Delivery Redirection Memorandum', { exact: true })).toBeVisible()
await page.getByLabel('Thread tag').fill('Proves Voss owns Warehouse 3')
await waitForSave(page, () => page.getByRole('button', { name: 'ADD TAG & TIGHTEN', exact: true }).click())
await page.getByRole('button', { name: 'Red thread', exact: true }).click()
await photograph.click()
await expect(threadEditor.getByText('Company Register Extract · Voss Antiquities Ltd', { exact: true })).toBeVisible()
await expect(threadEditor.getByText('Warehouse 3 Security Photograph', { exact: true })).toBeVisible()
await page.getByLabel('Thread tag').fill('Proves Voss owns Warehouse 3')
await waitForSave(page, () => page.getByRole('button', { name: 'ADD TAG & TIGHTEN', exact: true }).click())
await page.reload()
await expect(page.locator('.evidence-card.party')).toHaveCount(6)
await expect(page.locator('.evidence-card.event')).toHaveCount(3)
await expect(page.locator('.evidence-card.note')).toHaveCount(1)
await expect(page.getByRole('textbox', { name: 'Memory cue for Carrier Dispatch Manifest · H&F 14', exact: true })).toHaveValue('ELIAS · H&F 14')
await expect(page.locator('.story-strip')).toContainText('CO-771 was diverted to Warehouse 3')
await expect(page.locator('.event-support-lines line')).toHaveCount(6)
await expect(page.locator('.connections path')).toHaveCount(3)
const relationTag = page.locator('.thread-tag').filter({ hasText: 'Proof Elias is the driver' })
await expect(relationTag).toContainText('Proof Elias is the driver')
const tagBefore = await relationTag.boundingBox()
await waitForSave(page, () => dragBy(page, relationTag, 75, -18))
const tagAfter = await relationTag.boundingBox()
expect(tagBefore).not.toBeNull()
expect(tagAfter).not.toBeNull()
expect(Math.abs(tagAfter!.x - tagBefore!.x) + Math.abs(tagAfter!.y - tagBefore!.y)).toBeGreaterThan(20)
await relationTag.click()
await expect(relationTag).toHaveClass(/\bexpanded\b/)
await expect(relationTag).toHaveAttribute('aria-expanded', 'true')
await relationTag.click()
await expect(page.getByText('Edit red thread')).toBeVisible()
expect(await page.getByRole('slider', { name: 'Tag position', exact: true }).inputValue()).not.toBe('50')
await page.locator('.tag-style-picker input[value="compact"]').check()
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE THREAD', exact: true }).click())
await expect(relationTag).toHaveClass(/\bcompact\b/)
await expect(relationTag).not.toHaveClass(/\bluggage-tag\b/)
await relationTag.click()
await expect(page.getByText('Edit red thread')).toBeVisible()
await page.getByRole('button', { name: 'Close thread editor', exact: true }).click()
const solved = await (await request.get(`/api/levels/${playable.id}`)).json()
const eliasThread = solved.connections.find((connection: { label?: string }) => connection.label === 'Proof Elias is the driver')
expect(eliasThread).toEqual(expect.objectContaining({ tightness: 85, tagStyle: 'compact', tagPosition: expect.any(Number), tagOffset: expect.any(Number) }))
expect(eliasThread.tagPosition).not.toBe(50)
expect(Math.abs(eliasThread.tagOffset)).toBeLessThanOrEqual(19)
expect(solved.connections.filter((connection: { label?: string }) => connection.label === 'Proves Voss owns Warehouse 3')).toHaveLength(2)
const template = await (await request.get('/api/templates')).json() as { slug: string; currentVersion: number }[]
expect(template).toContainEqual(expect.objectContaining({ slug: 'glass-harbor', currentVersion: 1 }))
const authoringLevels = levels.filter(level => level.id.startsWith('glass-harbor-authoring-'))
expect(authoringLevels).toHaveLength(1)
const untouched = await (await request.get(`/api/levels/${authoringLevels[0].id}?edit=1`)).json()
expect(untouched.evidence.filter((item: { type: string }) => item.type === 'party')).toHaveLength(0)
expect(untouched.evidence.filter((item: { type: string }) => item.type === 'event')).toHaveLength(0)
expect(untouched.brief.concepts.every((concept: { resolvedPartyExhibitId?: string }) => !concept.resolvedPartyExhibitId)).toBe(true)
})
@@ -0,0 +1,261 @@
-- Canonical exhibit model. The POC data predating this migration is disposable:
-- there is deliberately no compatibility view, backfill, or playthrough overlay.
DROP TABLE IF EXISTS osint.playthrough_widget_relation_state CASCADE;
DROP TABLE IF EXISTS osint.playthrough_widget_relations CASCADE;
DROP TABLE IF EXISTS osint.playthrough_widget_state CASCADE;
DROP TABLE IF EXISTS osint.playthrough_connections CASCADE;
DROP TABLE IF EXISTS osint.playthrough_widgets CASCADE;
DROP TABLE IF EXISTS osint.playthroughs CASCADE;
DROP TABLE IF EXISTS osint.level_connections CASCADE;
DROP TABLE IF EXISTS osint.widget_regions CASCADE;
DROP TABLE IF EXISTS osint.widget_relations CASCADE;
DROP TABLE IF EXISTS osint.widgets CASCADE;
DROP TABLE IF EXISTS osint.assets CASCADE;
DROP TABLE IF EXISTS osint.levels CASCADE;
DROP TABLE IF EXISTS osint.cases CASCADE;
CREATE TABLE osint.boards (
id UUID PRIMARY KEY,
board_kind TEXT NOT NULL CHECK (board_kind IN ('level', 'template_version')),
revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE osint.assets (
id UUID PRIMARY KEY,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
byte_size BIGINT NOT NULL CHECK (byte_size >= 0),
content BYTEA NOT NULL,
checksum_sha256 TEXT NOT NULL CHECK (checksum_sha256 ~ '^[0-9a-f]{64}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (checksum_sha256, byte_size)
);
CREATE TABLE osint.level_templates (
id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
current_version_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE osint.level_template_versions (
id UUID PRIMARY KEY,
template_id UUID NOT NULL REFERENCES osint.level_templates(id) ON DELETE CASCADE,
version INTEGER NOT NULL CHECK (version > 0),
board_id UUID NOT NULL UNIQUE REFERENCES osint.boards(id) ON DELETE RESTRICT,
title TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '',
created_from_level_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (template_id, version)
);
CREATE TABLE osint.levels (
id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
board_id UUID NOT NULL UNIQUE REFERENCES osint.boards(id) ON DELETE CASCADE,
source_template_version_id UUID REFERENCES osint.level_template_versions(id) ON DELETE RESTRICT,
title TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'complete', 'archived')),
viewport_x DOUBLE PRECISION NOT NULL DEFAULT 0,
viewport_y DOUBLE PRECISION NOT NULL DEFAULT 28,
viewport_zoom DOUBLE PRECISION NOT NULL DEFAULT 0.7 CHECK (viewport_zoom > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE osint.level_templates
ADD CONSTRAINT level_templates_current_version_fk
FOREIGN KEY (current_version_id) REFERENCES osint.level_template_versions(id) ON DELETE SET NULL;
ALTER TABLE osint.level_template_versions
ADD CONSTRAINT level_template_versions_source_level_fk
FOREIGN KEY (created_from_level_id) REFERENCES osint.levels(id) ON DELETE SET NULL;
CREATE TABLE osint.exhibit_types (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
is_spatial BOOLEAN NOT NULL DEFAULT TRUE
);
INSERT INTO osint.exhibit_types (id, name) VALUES
('folder', 'Folder'), ('document', 'Document'), ('note', 'Note'), ('event', 'Event');
CREATE TABLE osint.exhibits (
id UUID PRIMARY KEY,
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
exhibit_type_id TEXT NOT NULL REFERENCES osint.exhibit_types(id),
origin_exhibit_id UUID REFERENCES osint.exhibits(id) ON DELETE SET NULL,
xpos DOUBLE PRECISION NOT NULL DEFAULT 100,
ypos DOUBLE PRECISION NOT NULL DEFAULT 100,
width DOUBLE PRECISION NOT NULL DEFAULT 240 CHECK (width > 0),
height DOUBLE PRECISION NOT NULL DEFAULT 160 CHECK (height > 0),
rotation DOUBLE PRECISION NOT NULL DEFAULT 0,
z_index INTEGER NOT NULL DEFAULT 0,
hidden BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (board_id, id)
);
CREATE INDEX exhibits_board_idx ON osint.exhibits (board_id, z_index, created_at);
CREATE TABLE osint.folder_exhibits (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
title TEXT NOT NULL,
label_text TEXT NOT NULL DEFAULT '',
is_open BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE osint.document_types (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
INSERT INTO osint.document_types (id, name) VALUES
('image', 'Image'), ('pdf', 'PDF'), ('web_capture', 'Web capture'),
('email', 'Email'), ('article', 'Article'), ('filing', 'Filing'),
('price_list', 'Price list'), ('text', 'Text'), ('file', 'Generic file');
CREATE TABLE osint.document_exhibits (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
document_type_id TEXT NOT NULL REFERENCES osint.document_types(id),
asset_id UUID REFERENCES osint.assets(id) ON DELETE RESTRICT,
title TEXT NOT NULL,
published_at TIMESTAMPTZ,
captured_at TIMESTAMPTZ,
source_uri TEXT
);
CREATE INDEX document_exhibits_published_idx ON osint.document_exhibits (published_at);
CREATE TABLE osint.image_documents (
exhibit_id UUID PRIMARY KEY REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
pixel_width INTEGER CHECK (pixel_width > 0),
pixel_height INTEGER CHECK (pixel_height > 0),
alt_text TEXT NOT NULL DEFAULT ''
);
CREATE TABLE osint.note_exhibits (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
title TEXT NOT NULL,
note_text TEXT NOT NULL DEFAULT ''
);
CREATE TABLE osint.event_exhibits (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
title TEXT NOT NULL,
narrative_text TEXT NOT NULL DEFAULT '',
occurred_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE osint.document_content_blocks (
id UUID PRIMARY KEY,
document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL CHECK (sort_order >= 0),
content TEXT NOT NULL,
UNIQUE (document_exhibit_id, sort_order)
);
CREATE TABLE osint.document_regions (
id UUID PRIMARY KEY,
document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
region_key TEXT NOT NULL,
label TEXT NOT NULL,
excerpt TEXT NOT NULL,
occurred_at TIMESTAMPTZ,
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
UNIQUE (document_exhibit_id, region_key)
);
CREATE TABLE osint.folder_memberships (
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
folder_exhibit_id UUID NOT NULL REFERENCES osint.folder_exhibits(exhibit_id) ON DELETE CASCADE,
child_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
PRIMARY KEY (folder_exhibit_id, child_exhibit_id),
UNIQUE (board_id, child_exhibit_id),
CHECK (folder_exhibit_id <> child_exhibit_id)
);
CREATE TABLE osint.event_evidence (
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
event_exhibit_id UUID NOT NULL REFERENCES osint.event_exhibits(exhibit_id) ON DELETE CASCADE,
evidence_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
note TEXT,
PRIMARY KEY (event_exhibit_id, evidence_exhibit_id),
CHECK (event_exhibit_id <> evidence_exhibit_id)
);
CREATE TABLE osint.connection_types (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
directed BOOLEAN NOT NULL DEFAULT FALSE
);
INSERT INTO osint.connection_types (id, name) VALUES ('thread', 'Red thread');
CREATE TABLE osint.exhibit_connections (
id UUID PRIMARY KEY,
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
connection_type_id TEXT NOT NULL REFERENCES osint.connection_types(id),
from_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
to_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
label TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (from_exhibit_id <> to_exhibit_id)
);
CREATE TABLE osint.exhibit_sources (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
source_document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
source_region_id UUID REFERENCES osint.document_regions(id) ON DELETE SET NULL
);
CREATE TABLE osint.metadata_fields (
id UUID PRIMARY KEY,
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
field_key TEXT NOT NULL,
label TEXT NOT NULL,
value_type TEXT NOT NULL CHECK (value_type IN ('text', 'timestamp', 'number', 'boolean')),
UNIQUE (board_id, field_key)
);
CREATE TABLE osint.exhibit_metadata_text_values (
exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
field_id UUID NOT NULL REFERENCES osint.metadata_fields(id) ON DELETE CASCADE,
value TEXT NOT NULL,
PRIMARY KEY (exhibit_id, field_id)
);
CREATE TABLE osint.exhibit_metadata_timestamp_values (
exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
field_id UUID NOT NULL REFERENCES osint.metadata_fields(id) ON DELETE CASCADE,
value TIMESTAMPTZ NOT NULL,
PRIMARY KEY (exhibit_id, field_id)
);
CREATE TABLE osint.exhibit_metadata_number_values (
exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
field_id UUID NOT NULL REFERENCES osint.metadata_fields(id) ON DELETE CASCADE,
value NUMERIC NOT NULL,
PRIMARY KEY (exhibit_id, field_id)
);
CREATE TABLE osint.exhibit_metadata_boolean_values (
exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
field_id UUID NOT NULL REFERENCES osint.metadata_fields(id) ON DELETE CASCADE,
value BOOLEAN NOT NULL,
PRIMARY KEY (exhibit_id, field_id)
);
-- Relationships carry board_id so cross-board references can be rejected by FKs.
ALTER TABLE osint.folder_memberships
ADD CONSTRAINT folder_membership_folder_board_fk FOREIGN KEY (board_id, folder_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE,
ADD CONSTRAINT folder_membership_child_board_fk FOREIGN KEY (board_id, child_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE;
ALTER TABLE osint.event_evidence
ADD CONSTRAINT event_evidence_event_board_fk FOREIGN KEY (board_id, event_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE,
ADD CONSTRAINT event_evidence_evidence_board_fk FOREIGN KEY (board_id, evidence_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE;
ALTER TABLE osint.exhibit_connections
ADD CONSTRAINT exhibit_connections_from_board_fk FOREIGN KEY (board_id, from_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE,
ADD CONSTRAINT exhibit_connections_to_board_fk FOREIGN KEY (board_id, to_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE;
COMMENT ON TABLE osint.exhibits IS 'Canonical domain objects; frontend widgets are projections selected by exhibit_type_id.';
COMMENT ON TABLE osint.level_template_versions IS 'Immutable template snapshots. Application code must clone, never update, their boards.';
COMMENT ON TABLE osint.assets IS 'Immutable shared binary content referenced by document exhibits.';
+83
View File
@@ -0,0 +1,83 @@
INSERT INTO osint.exhibit_types (id,name) VALUES ('party','Party');
CREATE TABLE osint.party_exhibits (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
party_kind TEXT NOT NULL CHECK (party_kind IN ('person','organization')),
display_name TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT ''
);
CREATE TABLE osint.person_parties (
exhibit_id UUID PRIMARY KEY REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
given_name TEXT,
family_name TEXT
);
CREATE TABLE osint.organization_parties (
exhibit_id UUID PRIMARY KEY REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
organization_kind TEXT NOT NULL DEFAULT 'business'
CHECK (organization_kind IN ('business','public_body','association','informal_group','other'))
);
CREATE TABLE osint.party_aliases (
id UUID PRIMARY KEY,
party_exhibit_id UUID NOT NULL REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
alias TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
UNIQUE (party_exhibit_id,alias)
);
CREATE TABLE osint.party_evidence (
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
party_exhibit_id UUID NOT NULL REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
evidence_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
note TEXT,
PRIMARY KEY (party_exhibit_id,evidence_exhibit_id),
CHECK (party_exhibit_id <> evidence_exhibit_id),
FOREIGN KEY (board_id,party_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE,
FOREIGN KEY (board_id,evidence_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE
);
CREATE TABLE osint.party_relationship_types (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
directed BOOLEAN NOT NULL DEFAULT TRUE
);
INSERT INTO osint.party_relationship_types (id,name,directed) VALUES
('employment','Employment',TRUE), ('ownership','Ownership',TRUE),
('membership','Membership',TRUE), ('control','Control',TRUE),
('representation','Representation',TRUE), ('associated','Associated',FALSE);
CREATE TABLE osint.party_relationships (
id UUID PRIMARY KEY,
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
relationship_type_id TEXT NOT NULL REFERENCES osint.party_relationship_types(id),
from_party_exhibit_id UUID NOT NULL REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
to_party_exhibit_id UUID NOT NULL REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
note TEXT,
CHECK (from_party_exhibit_id <> to_party_exhibit_id),
FOREIGN KEY (board_id,from_party_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE,
FOREIGN KEY (board_id,to_party_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE
);
CREATE TABLE osint.level_briefs (
board_id UUID PRIMARY KEY REFERENCES osint.boards(id) ON DELETE CASCADE,
body TEXT NOT NULL DEFAULT ''
);
CREATE TABLE osint.brief_concepts (
id UUID PRIMARY KEY,
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
origin_concept_id UUID REFERENCES osint.brief_concepts(id) ON DELETE SET NULL,
label TEXT NOT NULL,
context_text TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
expected_party_kind TEXT CHECK (expected_party_kind IN ('person','organization')),
resolved_party_exhibit_id UUID REFERENCES osint.party_exhibits(exhibit_id) ON DELETE SET NULL,
UNIQUE (board_id,label),
FOREIGN KEY (board_id,resolved_party_exhibit_id) REFERENCES osint.exhibits(board_id,id)
);
COMMENT ON TABLE osint.brief_concepts IS 'Named concepts in the level brief which may be classified into Party exhibits by the investigator.';
COMMENT ON COLUMN osint.brief_concepts.expected_party_kind IS 'Author-only expected classification; omitted from play-mode API projections.';
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE osint.board_timeline_settings (
board_id UUID PRIMARY KEY REFERENCES osint.boards(id) ON DELETE CASCADE,
range_start DATE NOT NULL,
range_end DATE NOT NULL,
CHECK (range_end > range_start)
);
COMMENT ON TABLE osint.board_timeline_settings IS 'Optional authored temporal viewport. Absence means derive the timeline range from dated exhibits.';
+6
View File
@@ -0,0 +1,6 @@
ALTER TABLE osint.exhibit_connections
ADD COLUMN tightness SMALLINT NOT NULL DEFAULT 65
CHECK (tightness BETWEEN 0 AND 100);
COMMENT ON COLUMN osint.exhibit_connections.label IS 'Investigator-authored tag describing the meaning of this thread';
COMMENT ON COLUMN osint.exhibit_connections.tightness IS 'Visual thread tautness percentage from 0 (slack) to 100 (taut)';
+5
View File
@@ -0,0 +1,5 @@
ALTER TABLE osint.exhibit_connections
ADD COLUMN tag_style TEXT NOT NULL DEFAULT 'luggage'
CHECK (tag_style IN ('luggage', 'compact'));
COMMENT ON COLUMN osint.exhibit_connections.tag_style IS 'Player-selected relation label presentation while both POC treatments are evaluated';
@@ -0,0 +1,8 @@
ALTER TABLE osint.exhibit_connections
ADD COLUMN tag_position_percent SMALLINT NOT NULL DEFAULT 50
CHECK (tag_position_percent BETWEEN 0 AND 100),
ADD COLUMN tag_lateral_offset SMALLINT NOT NULL DEFAULT 0
CHECK (ABS(tag_lateral_offset) <= 10 + ROUND((100 - tightness) * 0.6));
COMMENT ON COLUMN osint.exhibit_connections.tag_position_percent IS 'Position of the relation tag along its red thread, measured from the source exhibit';
COMMENT ON COLUMN osint.exhibit_connections.tag_lateral_offset IS 'Signed perpendicular tag displacement, limited by thread tightness';
@@ -0,0 +1,4 @@
ALTER TABLE osint.event_exhibits
ALTER COLUMN occurred_at DROP NOT NULL;
COMMENT ON COLUMN osint.event_exhibits.occurred_at IS 'Optional reconstructed occurrence time; never inferred from exhibit creation time';
+58
View File
@@ -0,0 +1,58 @@
CREATE TABLE osint.board_view_types (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
INSERT INTO osint.board_view_types (id,name) VALUES ('timeline','Timeline');
CREATE TABLE osint.board_views (
id UUID PRIMARY KEY,
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
view_type_id TEXT NOT NULL REFERENCES osint.board_view_types(id),
origin_view_id UUID REFERENCES osint.board_views(id) ON DELETE SET NULL,
placement_mode TEXT NOT NULL DEFAULT 'docked' CHECK (placement_mode IN ('docked','canvas','window')),
dock_edge TEXT CHECK (dock_edge IN ('top','right','bottom','left')),
xpos DOUBLE PRECISION,
ypos DOUBLE PRECISION,
width DOUBLE PRECISION,
height DOUBLE PRECISION NOT NULL DEFAULT 112 CHECK (height > 0),
z_index INTEGER NOT NULL DEFAULT 0,
visible BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (board_id,view_type_id),
CHECK (
(placement_mode = 'docked' AND dock_edge IS NOT NULL)
OR (placement_mode IN ('canvas','window') AND xpos IS NOT NULL AND ypos IS NOT NULL AND width IS NOT NULL)
)
);
CREATE INDEX board_views_board_idx ON osint.board_views (board_id,z_index,created_at);
CREATE TABLE osint.timeline_views (
view_id UUID PRIMARY KEY REFERENCES osint.board_views(id) ON DELETE CASCADE,
range_mode TEXT NOT NULL DEFAULT 'auto' CHECK (range_mode IN ('auto','fixed')),
range_start DATE,
range_end DATE,
CHECK (
(range_mode = 'auto' AND range_start IS NULL AND range_end IS NULL)
OR (range_mode = 'fixed' AND range_start IS NOT NULL AND range_end IS NOT NULL AND range_end > range_start)
)
);
WITH created_views AS (
INSERT INTO osint.board_views (id,board_id,view_type_id,placement_mode,dock_edge,height)
SELECT gen_random_uuid(),b.id,'timeline','docked','bottom',112
FROM osint.boards b
RETURNING id,board_id
)
INSERT INTO osint.timeline_views (view_id,range_mode,range_start,range_end)
SELECT v.id,
CASE WHEN settings.board_id IS NULL THEN 'auto' ELSE 'fixed' END,
settings.range_start,
settings.range_end
FROM created_views v
LEFT JOIN osint.board_timeline_settings settings ON settings.board_id=v.board_id;
DROP TABLE osint.board_timeline_settings;
COMMENT ON TABLE osint.board_views IS 'Persistent frontend projections and workspace apparatus. Views are not investigation-domain exhibits.';
COMMENT ON TABLE osint.timeline_views IS 'Timeline projection settings; temporal markers remain derived from exhibits and are never duplicated here.';
+16
View File
@@ -0,0 +1,16 @@
ALTER TABLE osint.assets
ALTER COLUMN content DROP NOT NULL,
ADD COLUMN storage_provider TEXT NOT NULL DEFAULT 'postgres' CHECK (storage_provider IN ('postgres','s3')),
ADD COLUMN storage_bucket TEXT,
ADD COLUMN object_key TEXT,
ADD COLUMN etag TEXT;
ALTER TABLE osint.assets
ADD CONSTRAINT assets_storage_location_check CHECK (
(storage_provider = 'postgres' AND content IS NOT NULL AND storage_bucket IS NULL AND object_key IS NULL)
OR (storage_provider = 's3' AND content IS NULL AND storage_bucket IS NOT NULL AND object_key IS NOT NULL)
),
ADD CONSTRAINT assets_object_location_unique UNIQUE (storage_bucket,object_key);
COMMENT ON COLUMN osint.assets.content IS 'Compatibility storage for assets created before object storage. New uploads use the private S3/MinIO bucket.';
COMMENT ON COLUMN osint.assets.object_key IS 'Private object key; clients retrieve bytes through the authenticated application asset endpoint.';
+95
View File
@@ -0,0 +1,95 @@
-- Narrative layer, first slice: a mystery (campaign) wrapping ordered level
-- template versions, an authored NPC cast with named poses, scripted cutscenes,
-- and a per-user playthrough that owns game state. Dialogue content is fixed and
-- owned by the mystery; progress is a reference recorded in seen_dialogue.
CREATE TABLE osint.mysteries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE osint.mystery_chapters (
mystery_id UUID NOT NULL REFERENCES osint.mysteries(id) ON DELETE CASCADE,
chapter_index INTEGER NOT NULL CHECK (chapter_index >= 1),
level_template_version_id UUID NOT NULL REFERENCES osint.level_template_versions(id),
PRIMARY KEY (mystery_id, chapter_index)
);
CREATE TABLE osint.npcs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mystery_id UUID NOT NULL REFERENCES osint.mysteries(id) ON DELETE CASCADE,
npc_key TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT '',
default_pose_key TEXT,
UNIQUE (mystery_id, npc_key)
);
-- A pose is a named portrait variant backed by an immutable shared asset. A
-- missing pose is never an error; the client falls back to the NPC default pose
-- and then to no artwork.
CREATE TABLE osint.npc_poses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
npc_id UUID NOT NULL REFERENCES osint.npcs(id) ON DELETE CASCADE,
pose_key TEXT NOT NULL,
asset_id UUID REFERENCES osint.assets(id),
UNIQUE (npc_id, pose_key)
);
CREATE TABLE osint.cutscenes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mystery_id UUID NOT NULL REFERENCES osint.mysteries(id) ON DELETE CASCADE,
chapter_index INTEGER,
slot TEXT NOT NULL CHECK (slot IN ('mystery_intro','level_intro','level_debrief','mystery_resolution')),
title TEXT NOT NULL DEFAULT '',
-- Chapter-scoped slots carry a chapter; mystery-scoped slots do not.
CHECK (
(slot IN ('mystery_intro','mystery_resolution') AND chapter_index IS NULL)
OR (slot IN ('level_intro','level_debrief') AND chapter_index IS NOT NULL)
)
);
CREATE INDEX cutscenes_mystery_idx ON osint.cutscenes (mystery_id);
-- One utterance. `kind` reserves the LLM path; `body_text` is required for
-- scripted steps and null for generated ones. `advances_to` reserves branching;
-- null means "next by sort_order".
CREATE TABLE osint.dialogue_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cutscene_id UUID NOT NULL REFERENCES osint.cutscenes(id) ON DELETE CASCADE,
step_key TEXT NOT NULL,
sort_order INTEGER NOT NULL,
npc_id UUID NOT NULL REFERENCES osint.npcs(id),
pose_key TEXT,
kind TEXT NOT NULL DEFAULT 'scripted' CHECK (kind IN ('scripted','generated')),
body_text TEXT,
advances_to TEXT,
UNIQUE (cutscene_id, sort_order),
UNIQUE (cutscene_id, step_key),
CHECK ((kind = 'scripted' AND body_text IS NOT NULL) OR kind = 'generated')
);
-- The single object that owns a player's game state, bound to the JWT identity.
CREATE TABLE osint.playthroughs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
mystery_id UUID NOT NULL REFERENCES osint.mysteries(id) ON DELETE CASCADE,
current_chapter_index INTEGER NOT NULL DEFAULT 1 CHECK (current_chapter_index >= 1),
current_level_id UUID REFERENCES osint.levels(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','finished')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX playthroughs_user_idx ON osint.playthroughs (user_id, updated_at DESC);
-- Progress as a reference to fixed authored dialogue, never a copy of its text.
CREATE TABLE osint.seen_dialogue (
playthrough_id UUID NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
cutscene_id UUID NOT NULL REFERENCES osint.cutscenes(id) ON DELETE CASCADE,
seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (playthrough_id, cutscene_id)
);
COMMENT ON TABLE osint.playthroughs IS 'Per-user instance of a mystery; owns current chapter and live level. Bound to the JWT sub (or the development test user).';
COMMENT ON TABLE osint.dialogue_steps IS 'Fixed authored utterances owned by the immutable mystery. seen_dialogue references these ids; they are never cloned per playthrough.';
@@ -0,0 +1,7 @@
-- A dialogue step is meaningless without its speaker. Cascade deletes from npcs
-- so that removing an NPC (or dropping a whole mystery, which cascades to its
-- cast) also removes the steps that reference it, rather than failing on the
-- non-cascading foreign key.
ALTER TABLE osint.dialogue_steps DROP CONSTRAINT dialogue_steps_npc_id_fkey;
ALTER TABLE osint.dialogue_steps ADD CONSTRAINT dialogue_steps_npc_id_fkey
FOREIGN KEY (npc_id) REFERENCES osint.npcs(id) ON DELETE CASCADE;
+10
View File
@@ -0,0 +1,10 @@
-- NPCs become a reusable, global template library rather than per-mystery copies.
-- A NULL mystery_id marks a global template; mysteries reference templates by key
-- when authored, so editing an NPC in the admin panel survives re-imports.
ALTER TABLE osint.npcs ALTER COLUMN mystery_id DROP NOT NULL;
-- Enforce one global template per key (the existing UNIQUE(mystery_id, npc_key)
-- does not constrain rows where mystery_id IS NULL).
CREATE UNIQUE INDEX npcs_global_key_idx ON osint.npcs (npc_key) WHERE mystery_id IS NULL;
COMMENT ON TABLE osint.npcs IS 'Reusable NPC templates. mystery_id IS NULL for a global template; mysteries reference templates by npc_key when authored.';
+42
View File
@@ -0,0 +1,42 @@
-- Story flow graph (Phase 1: authoring only). A mystery is a directed graph of
-- nodes; each node has N output terminals, and a terminal carries its own single
-- outgoing wire (to_node_id) — there is no separate edges table. The runtime is
-- untouched in this phase; it still plays via the slot model.
CREATE TABLE osint.story_nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mystery_id UUID NOT NULL REFERENCES osint.mysteries(id) ON DELETE CASCADE,
node_type TEXT NOT NULL CHECK (node_type IN ('cutscene','dialogue','level','det_gate','llm_gate')),
label TEXT NOT NULL DEFAULT '',
has_utterances BOOLEAN NOT NULL DEFAULT FALSE,
xpos DOUBLE PRECISION NOT NULL,
ypos DOUBLE PRECISION NOT NULL,
-- Folded, type-specific scalars. Nullable during authoring (an author drops a
-- node, then configures it); "required for its type" is a publish-time check.
-- These exclusion CHECKs only stop a column being set on the wrong node_type.
level_template_version_id UUID REFERENCES osint.level_template_versions(id),
component_key TEXT, -- cutscene: frontend component; gate: backend gate function
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (level_template_version_id IS NULL OR node_type = 'level'),
CHECK (component_key IS NULL OR node_type IN ('cutscene','det_gate','llm_gate'))
);
CREATE INDEX story_nodes_mystery_idx ON osint.story_nodes (mystery_id);
CREATE TABLE osint.story_node_terminals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_node_id UUID NOT NULL REFERENCES osint.story_nodes(id) ON DELETE CASCADE,
terminal_key TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
-- The single wire out of this port. NULL = unwired (authoring, or deliberate
-- end). SET NULL keeps the port when its target node is deleted.
to_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE (parent_node_id, terminal_key)
);
CREATE INDEX story_terminals_node_idx ON osint.story_node_terminals (parent_node_id);
-- One entrypoint per mystery. Nullable so nodes can be inserted before it is set
-- (avoids a chicken-and-egg with story_nodes.mystery_id).
ALTER TABLE osint.mysteries ADD COLUMN entry_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL;
COMMENT ON TABLE osint.story_nodes IS 'Nodes of a mystery story flow graph. Same-mystery integrity for terminal wiring is enforced in the repository (Phase 1).';
+25
View File
@@ -0,0 +1,25 @@
-- Utterances: the content of a dialogue node (and utterance-bearing cutscenes).
-- One table for both NPC lines and player choices, distinguished by `utterer`.
-- The intra-node conversation graph uses two self-references; `terminal_id` is the
-- exit that leaves the node via one of its output terminals. Runtime is Phase 2.
CREATE TABLE osint.utterances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
node_id UUID NOT NULL REFERENCES osint.story_nodes(id) ON DELETE CASCADE,
utterer TEXT NOT NULL DEFAULT 'npc' CHECK (utterer IN ('npc','player')),
npc_id UUID REFERENCES osint.npcs(id), -- speaker for NPC lines; NULL for player choices
pose_key TEXT, -- resolved with the usual pose fallback
text TEXT NOT NULL DEFAULT '',
-- Intra-node conversation graph:
parent_utterance_id UUID REFERENCES osint.utterances(id) ON DELETE CASCADE, -- player options hang under the NPC prompt they answer
advances_to_utterance_id UUID REFERENCES osint.utterances(id) ON DELETE SET NULL, -- next line; may loop back to the same one
-- Inter-node exit:
terminal_id UUID REFERENCES osint.story_node_terminals(id) ON DELETE SET NULL,
effect TEXT, -- optional authored side-effect hook (vocabulary TBD)
xpos DOUBLE PRECISION NOT NULL DEFAULT 0, -- position in the node's utterance sub-canvas
ypos DOUBLE PRECISION NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX utterances_node_idx ON osint.utterances (node_id);
COMMENT ON TABLE osint.utterances IS 'Dialogue content per story node. Same-node integrity for parent/advances_to/terminal links is enforced in the repository (Phase 1).';
@@ -0,0 +1,4 @@
-- Phase 2 runtime: a playthrough walks the story graph. current_node_id is where
-- the player is; current_level_id (existing) is set while on a level node. The
-- slot-based fields remain for mysteries without a graph (fallback).
ALTER TABLE osint.playthroughs ADD COLUMN current_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL;
+9
View File
@@ -0,0 +1,9 @@
-- Clean cutover to the graph runtime: retire the slot/chapter model. Progression
-- is now a walk of the story graph (playthroughs.current_node_id); cutscene and
-- dialogue content lives in story_nodes/utterances. No users exist yet, so this
-- drops the superseded tables outright rather than migrating their data.
DROP TABLE IF EXISTS osint.seen_dialogue;
DROP TABLE IF EXISTS osint.dialogue_steps;
DROP TABLE IF EXISTS osint.cutscenes;
DROP TABLE IF EXISTS osint.mystery_chapters;
ALTER TABLE osint.playthroughs DROP COLUMN IF EXISTS current_chapter_index;
@@ -0,0 +1,5 @@
-- The utterance model settled on parent-only successors (child count decides linear
-- vs. options), so advances_to_utterance_id was never used; the effect side-effect
-- hook was reserved but unbuilt. Drop both.
ALTER TABLE osint.utterances DROP COLUMN IF EXISTS advances_to_utterance_id;
ALTER TABLE osint.utterances DROP COLUMN IF EXISTS effect;
+17
View File
@@ -0,0 +1,17 @@
# Mystery content packages
Mysteries live outside the React application as authoring manifests and immutable source assets. Importing a manifest exercises the same public operations as the level editor: it creates a mutable authoring level, uploads assets, saves normalized exhibits, freezes an immutable template version, and instantiates a separate playable level.
With the local Docker stack running and editing enabled:
```bash
npm run mystery:import -- mysteries/glass-harbor/mystery.json
```
The command prints the template version, authoring-level ID, playable-level ID, and player URL. Importing the same manifest again creates a new immutable version; it does not rewrite an earlier version.
## The Glass Harbor Diversion
This compact first mystery contains eight dated source documents in three folders, six unresolved Party concepts, and one fictional archival photograph. It intentionally starts without Party or Event exhibits: classification, dossier association, event reconstruction, working notes, and red-thread theory are the players work.
The source image was generated with the built-in image-generation tool for this fictional case. Its final production prompt asked for a degraded 1987 harbor-security photograph of truck `H&F 14` unloading a `CALDER OPTICAL / FRAGILE` crate at Warehouse 3, with period-correct details and no real people or brands.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

+193
View File
@@ -0,0 +1,193 @@
{
"slug": "glass-harbor",
"name": "The Glass Harbor Diversion",
"title": "The Glass Harbor Diversion",
"subtitle": "Greyhaven file 87-10 · missing lighthouse optics",
"timelineRange": { "start": "1987-10-01", "end": "1987-10-31" },
"brief": {
"body": "A replacement Fresnel lens purchased for North Quay Lighthouse vanished between dispatch and installation. Determine who arranged the diversion, which organization stood to benefit, and when the shipment changed course. Classify every named concept, associate each party with the evidence that supports its dossier, reconstruct the decisive events, and make your conclusion visible with red thread. The terminal will not announce a winner: a solved board is a defensible account of what happened.",
"concepts": [
{ "label": "Mara Voss", "context": "Named in restoration correspondence and harbor records.", "expectedPartyKind": "person" },
{ "label": "Elias Vale", "context": "Named on the carrier dispatch manifest.", "expectedPartyKind": "person" },
{ "label": "Greyhaven Preservation Society", "context": "Commissioned the North Quay restoration.", "expectedPartyKind": "organization" },
{ "label": "Calder Optical Works", "context": "Supplied the missing Fresnel assembly.", "expectedPartyKind": "organization" },
{ "label": "Harbor & Fell Logistics", "context": "Transported the consignment from Calder.", "expectedPartyKind": "organization" },
{ "label": "Voss Antiquities Ltd", "context": "Appears in company and auction records.", "expectedPartyKind": "organization" }
]
},
"documents": [
{
"key": "company-register",
"title": "Company Register Extract · Voss Antiquities Ltd",
"fileType": "filing",
"publishedAt": "1987-01-08T10:00:00.000Z",
"body": [
"GREYHAVEN COMPANIES REGISTRY — CERTIFIED EXTRACT 87/0118",
"VOSS ANTIQUITIES LTD · dealer in architectural and maritime salvage.",
"Sole director and beneficial owner: Mara Elise Voss.",
"Registered trading premises: Warehouse 3, Old Glass Harbor, Greyhaven.",
"Company status: active. Last annual return filed 8 January 1987."
],
"metadata": { "registry_number": "GH-44109", "certified_by": "Greyhaven Companies Registry" }
},
{
"key": "contract-award",
"title": "North Quay Restoration · Contract Award",
"fileType": "text",
"publishedAt": "1987-10-02T14:00:00.000Z",
"body": [
"GREYHAVEN PRESERVATION SOCIETY — CONTRACT 31/NQ",
"Calder Optical Works shall supply one restored third-order Fresnel assembly, serial CO-771, for permanent installation at North Quay Lighthouse.",
"Harbor & Fell Logistics is the approved carrier. Delivery is to the North Quay works compound no later than 20 October 1987.",
"The Society's project officer, Mara Voss, may acknowledge delivery but may not alter the delivery address without a countersignature from the Society treasurer.",
"Insured replacement value: 48,000 kroner."
],
"metadata": { "contract": "31/NQ", "serial": "CO-771" }
},
{
"key": "dispatch-manifest",
"title": "Carrier Dispatch Manifest · H&F 14",
"fileType": "text",
"publishedAt": "1987-10-16T16:40:00.000Z",
"body": [
"HARBOR & FELL LOGISTICS — OUTBOUND MANIFEST 16-440",
"Vehicle: H&F 14. Driver: Elias Vale. Cargo: one sealed Calder Optical crate, serial CO-771.",
"Collected from Calder Optical Works at 16:40 on 16 October 1987.",
"Consignee and destination: Greyhaven Preservation Society, North Quay Lighthouse works compound.",
"Special instructions: hold sealed; no alternate delivery authority lodged at time of dispatch."
],
"metadata": { "vehicle": "H&F 14", "driver": "Elias Vale", "serial": "CO-771" }
},
{
"key": "gate-ledger",
"title": "Old Glass Harbor Gate Ledger · Page 214",
"fileType": "text",
"publishedAt": "1987-10-17T22:08:00.000Z",
"body": [
"OLD GLASS HARBOR — NIGHT GATE LEDGER · 17 OCTOBER 1987",
"22:08 — Vehicle H&F 14 admitted. Driver signed E. Vale.",
"Declared load: Calder Optical crate CO-771. Destination inside harbor: Warehouse 3.",
"Temporary access sponsor: M. Voss. Telephone authorization logged 21:54.",
"22:31 — H&F 14 departed. Vehicle recorded empty."
],
"metadata": { "ledger_page": "214", "gate_officer": "T. Soren", "load_serial": "CO-771" }
},
{
"key": "warehouse-photo",
"title": "Warehouse 3 Security Photograph",
"fileType": "image",
"publishedAt": "1987-10-17T22:12:00.000Z",
"asset": "assets/warehouse-3-security.png",
"metadata": { "camera": "Old Glass Harbor C-3", "frame_time": "1987-10-17 22:12", "negative": "C3-871017-44" }
},
{
"key": "redirect-memo",
"title": "Delivery Redirection Memorandum",
"fileType": "email",
"publishedAt": "1987-10-18T09:15:00.000Z",
"body": [
"TO: Harbor & Fell Logistics / Calder Optical Works",
"FROM: Mara Voss, North Quay project officer",
"Owing to overnight water ingress at the lighthouse works compound, consignment CO-771 is to be held temporarily at Warehouse 3, Old Glass Harbor.",
"The driver was advised of this change before departure from Calder Optical Works. Formal notice follows for your files.",
"Signed M. Voss · 18 October 1987 · 09:15. No Society treasurer countersignature appears on this copy."
],
"metadata": { "received_stamp": "18 OCT 1987 09:15", "countersignature": "absent" }
},
{
"key": "inventory-notice",
"title": "North Quay Missing Inventory Notice",
"fileType": "text",
"publishedAt": "1987-10-21T08:30:00.000Z",
"body": [
"GREYHAVEN PRESERVATION SOCIETY — EXCEPTION NOTICE",
"The Fresnel assembly CO-771 was not present when the North Quay installation team assembled on 20 October.",
"Calder Optical Works confirms collection by the approved carrier. Harbor & Fell confirms completion of the driver's assigned shift but holds no signed North Quay receipt.",
"Warehouse 3 was inspected at 08:00 on 21 October. No Calder crate or optical assembly was found.",
"Project officer Mara Voss reported that she believed delivery remained pending."
],
"metadata": { "case_reference": "GPS/NQ/EX-4", "serial": "CO-771" }
},
{
"key": "auction-catalogue",
"title": "Meridian Maritime Auction · Lot 117",
"fileType": "article",
"publishedAt": "1987-10-24T12:00:00.000Z",
"body": [
"MERIDIAN MARITIME AUCTION — ADVANCE CATALOGUE · 24 OCTOBER 1987",
"LOT 117 — Restored third-order Fresnel lighthouse assembly, late nineteenth century, complete in fitted transit frame.",
"Consignor: Voss Antiquities Ltd. Seller reference: MV-3/771.",
"Insurance estimate: 45,00052,000 kroner. Private preview by appointment before export sale.",
"Catalogue correction: lot withdrawn from public view on 26 October at consignor's request."
],
"metadata": { "lot": "117", "seller_reference": "MV-3/771", "estimate": "45,00052,000 NOK" }
}
],
"narrative": {
"cast": [
{ "key": "professor", "name": "Prof. Almira Vetch", "role": "Glitch University · Investigative Method", "defaultPose": "neutral" }
],
"graph": {
"entry": "intro",
"nodes": [
{
"key": "intro", "type": "cutscene", "label": "The Glass Harbour Diversion",
"componentKey": "glass-harbour-diversion", "x": 200, "y": 60,
"terminals": [{ "key": "continue", "to": "briefing" }]
},
{
"key": "briefing", "type": "dialogue", "label": "Briefing", "x": 200, "y": 220,
"terminals": [{ "key": "continue", "label": "Begin", "to": "investigate" }],
"utterances": [
{ "npc": "professor", "pose": "neutral", "text": "Principal Investigator. Good — you're early. Sit. The Society has handed us a mess: a restored Fresnel lens, bought and paid for, that never reached the lighthouse it was meant for." },
{ "npc": "professor", "pose": "concerned", "text": "Greyhaven file 87-10. Between dispatch and installation the shipment simply changed course. Someone arranged that, and someone stood to profit. Both facts are in the documents — nowhere else." },
{ "npc": "professor", "pose": "wry", "text": "I won't tell you who did it. That is the whole exercise. Classify every name, tie each party to the evidence, and reconstruct the order of events until the account defends itself." },
{ "npc": "professor", "pose": "neutral", "text": "The terminal will not congratulate you. A solved board is one where your conclusion is the only one the paper trail still allows. Go." }
]
},
{
"key": "investigate", "type": "level", "label": "Investigate the board",
"templateSlug": "glass-harbor", "x": 200, "y": 380,
"terminals": [{ "key": "report_back", "label": "Report back", "to": "debrief" }]
},
{
"key": "debrief", "type": "dialogue", "label": "Debrief", "x": 200, "y": 540,
"terminals": [{ "key": "continue", "label": "End", "to": null }],
"utterances": [
{ "npc": "professor", "pose": "wry", "text": "There it is. The lens never sailed for the lighthouse — it sailed for a saleroom, and your thread shows exactly whose hand turned it." },
{ "npc": "professor", "pose": "neutral", "text": "A defensible account, Principal Investigator. Greyhaven file 87-10 is closed. Get some sleep — there will be another." }
]
}
]
}
},
"folders": [
{
"key": "procurement-file",
"title": "PROCUREMENT & OWNERSHIP",
"content": "Authority, ownership, destination, and declared value.",
"x": 270,
"y": 250,
"width": 270,
"members": ["company-register", "contract-award"]
},
{
"key": "movement-file",
"title": "MOVEMENT RECORDS",
"content": "Dispatch, harbor access, image capture, and later explanation.",
"x": 680,
"y": 320,
"width": 280,
"members": ["dispatch-manifest", "gate-ledger", "warehouse-photo", "redirect-memo"]
},
{
"key": "aftermath-file",
"title": "AFTERMATH",
"content": "The missing inventory report and the first attempted resale.",
"x": 1090,
"y": 250,
"width": 270,
"members": ["inventory-notice", "auction-catalogue"]
}
]
}
+680 -104
View File
@@ -8,19 +8,25 @@
"name": "gupi-osint-board", "name": "gupi-osint-board",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1111.0",
"cookie-parser": "^1.4.7",
"cors": "2.8.5", "cors": "2.8.5",
"dotenv": "16.5.0", "dotenv": "16.5.0",
"express": "5.1.0", "express": "5.1.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "0.468.0", "lucide-react": "0.468.0",
"multer": "2.0.2", "multer": "^2.2.0",
"pg": "8.16.3", "pg": "8.16.3",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"tsx": "4.20.3" "tsx": "4.20.3"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.1",
"@types/cookie-parser": "^1.4.9",
"@types/cors": "2.8.18", "@types/cors": "2.8.18",
"@types/express": "5.0.3", "@types/express": "5.0.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "2.0.0", "@types/multer": "2.0.0",
"@types/node": "22.15.30", "@types/node": "22.15.30",
"@types/pg": "8.15.4", "@types/pg": "8.15.4",
@@ -29,11 +35,319 @@
"@vitejs/plugin-react": "4.5.2", "@vitejs/plugin-react": "4.5.2",
"concurrently": "9.1.2", "concurrently": "9.1.2",
"typescript": "5.8.3", "typescript": "5.8.3",
"vite": "6.3.5", "vite": "^6.4.3",
"vitest": "3.2.3" "vitest": "^3.2.7"
}, },
"engines": { "engines": {
"node": ">=20" "node": "^20.0.0 || >=22.0.0"
}
},
"node_modules/@aws-sdk/checksums": {
"version": "3.1000.28",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.28.tgz",
"integrity": "sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1111.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1111.0.tgz",
"integrity": "sha512-VnLT6aSTN8tWl/NsXUysXNZor7wQBp9CRwufo7kt8cwGXvHLZ0S/cV1K9WFcREGboVYSo3NGQ3ZvU7LRidh2aQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1000.28",
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/credential-provider-node": "^3.972.80",
"@aws-sdk/middleware-sdk-s3": "^3.972.74",
"@aws-sdk/signature-v4-multi-region": "^3.996.45",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/fetch-http-handler": "^5.6.13",
"@smithy/node-http-handler": "^4.9.13",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.977.8",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.8.tgz",
"integrity": "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.4",
"@aws-sdk/xml-builder": "^3.972.39",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.31.1",
"@smithy/signature-v4": "^5.6.12",
"@smithy/types": "^4.16.1",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.69",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.69.tgz",
"integrity": "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.71",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.71.tgz",
"integrity": "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/fetch-http-handler": "^5.6.13",
"@smithy/node-http-handler": "^4.9.13",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.14",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.14.tgz",
"integrity": "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/credential-provider-env": "^3.972.69",
"@aws-sdk/credential-provider-http": "^3.972.71",
"@aws-sdk/credential-provider-login": "^3.972.76",
"@aws-sdk/credential-provider-process": "^3.972.69",
"@aws-sdk/credential-provider-sso": "^3.973.13",
"@aws-sdk/credential-provider-web-identity": "^3.972.75",
"@aws-sdk/nested-clients": "^3.997.43",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/credential-provider-imds": "^4.4.16",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.76",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.76.tgz",
"integrity": "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/nested-clients": "^3.997.43",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.80",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.80.tgz",
"integrity": "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.69",
"@aws-sdk/credential-provider-http": "^3.972.71",
"@aws-sdk/credential-provider-ini": "^3.973.14",
"@aws-sdk/credential-provider-process": "^3.972.69",
"@aws-sdk/credential-provider-sso": "^3.973.13",
"@aws-sdk/credential-provider-web-identity": "^3.972.75",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/credential-provider-imds": "^4.4.16",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.69",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.69.tgz",
"integrity": "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.13",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.13.tgz",
"integrity": "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/nested-clients": "^3.997.43",
"@aws-sdk/token-providers": "3.1111.0",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.75",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.75.tgz",
"integrity": "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/nested-clients": "^3.997.43",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.74",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.74.tgz",
"integrity": "sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/signature-v4-multi-region": "^3.996.45",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.43",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.43.tgz",
"integrity": "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/signature-v4-multi-region": "^3.996.45",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/fetch-http-handler": "^5.6.13",
"@smithy/node-http-handler": "^4.9.13",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.45",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.45.tgz",
"integrity": "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.4",
"@smithy/signature-v4": "^5.6.12",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1111.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1111.0.tgz",
"integrity": "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/nested-clients": "^3.997.43",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.974.4",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.4.tgz",
"integrity": "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.39",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.39.tgz",
"integrity": "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -801,6 +1115,22 @@
"node": "^22.20 || ^24.12 || >=25" "node": "^22.20 || ^24.12 || >=25"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.11", "version": "1.0.0-beta.11",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz",
@@ -1158,6 +1488,87 @@
"win32" "win32"
] ]
}, },
"node_modules/@smithy/core": {
"version": "3.33.0",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.0.tgz",
"integrity": "sha512-uKbkxgqLyepQDZoq8aRSdUqD1ID//rOqG96ixBhp++O7vBtmwYM6fwldGhr9HJP0iYrdc7GP/AlgzPWEZIrNRg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.17.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.0.tgz",
"integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.32.0",
"@smithy/types": "^4.17.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.0.tgz",
"integrity": "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.32.0",
"@smithy/types": "^4.17.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.11.0",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.0.tgz",
"integrity": "sha512-ssHIZsadPUA3lGdnoByxfnjtb9xPYQLvdfJRLKIwxOoa6tO1suG4sLFSsgd7D/CsvYd8QbBIuKTImuJha5l6aQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.0",
"@smithy/types": "^4.17.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.0.tgz",
"integrity": "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.32.0",
"@smithy/types": "^4.17.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.17.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.0.tgz",
"integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1235,6 +1646,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/cookie-parser": {
"version": "1.4.9",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz",
"integrity": "sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cors": { "node_modules/@types/cors": {
"version": "2.8.18", "version": "2.8.18",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.18.tgz", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.18.tgz",
@@ -1291,6 +1712,24 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": { "node_modules/@types/multer": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
@@ -1400,15 +1839,15 @@
} }
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "3.2.3", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.3.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
"integrity": "sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ==", "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/spy": "3.2.3", "@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.3", "@vitest/utils": "3.2.7",
"chai": "^5.2.0", "chai": "^5.2.0",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
@@ -1417,13 +1856,13 @@
} }
}, },
"node_modules/@vitest/mocker": { "node_modules/@vitest/mocker": {
"version": "3.2.3", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.3.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
"integrity": "sha512-cP6fIun+Zx8he4rbWvi+Oya6goKQDZK+Yq4hhlggwQBbrlOQ4qtZ+G4nxB6ZnzI9lyIb+JnvyiJnPC2AGbKSPA==", "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "3.2.3", "@vitest/spy": "3.2.7",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"magic-string": "^0.30.17" "magic-string": "^0.30.17"
}, },
@@ -1457,13 +1896,13 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "3.2.3", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.3.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
"integrity": "sha512-83HWYisT3IpMaU9LN+VN+/nLHVBCSIUKJzGxC5RWUOsK1h3USg7ojL+UXQR3b4o4UBIWCYdD2fxuzM7PQQ1u8w==", "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "3.2.3", "@vitest/utils": "3.2.7",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"strip-literal": "^3.0.0" "strip-literal": "^3.0.0"
}, },
@@ -1472,13 +1911,13 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "3.2.3", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.3.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
"integrity": "sha512-9gIVWx2+tysDqUmmM1L0hwadyumqssOL1r8KJipwLx5JVYyxvVRfxvMq7DaWbZZsCqZnu/dZedaZQh4iYTtneA==", "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "3.2.3", "@vitest/pretty-format": "3.2.7",
"magic-string": "^0.30.17", "magic-string": "^0.30.17",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
@@ -1486,23 +1925,10 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.3.tgz",
"integrity": "sha512-yFglXGkr9hW/yEXngO+IKMhP0jxyFw2/qys/CK4fFUZnSltD+MU7dVYGrH8rvPcK/O6feXQA+EU33gjaBBbAng==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "3.2.3", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.3.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
"integrity": "sha512-JHu9Wl+7bf6FEejTCREy+DmgWe+rQKbK+y32C/k5f4TBIAlijhJbRBIRIOCEpVevgRsCQR2iHRUH2/qKVM/plw==", "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1513,27 +1939,14 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "3.2.3", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.3.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
"integrity": "sha512-4zFBCU5Pf+4Z6v+rwnZ1HU1yzOKKvDkMXZrymE2PBlbjKJRlrOxbvpfPSvJTGRIwGoahaOGvp+kbCoxifhzJ1Q==", "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.3",
"loupe": "^3.1.3",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils/node_modules/@vitest/pretty-format": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.3.tgz",
"integrity": "sha512-yFglXGkr9hW/yEXngO+IKMhP0jxyFw2/qys/CK4fFUZnSltD+MU7dVYGrH8rvPcK/O6feXQA+EU33gjaBBbAng==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "3.2.7",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
"funding": { "funding": {
@@ -1645,6 +2058,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.8", "version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -1679,6 +2098,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -1936,6 +2361,25 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@@ -2027,6 +2471,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -2516,6 +2969,61 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
"integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.2",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -2523,6 +3031,48 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/loupe": { "node_modules/loupe": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -2618,27 +3168,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2646,21 +3175,22 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": { "node_modules/multer": {
"version": "2.0.2", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"append-field": "^1.0.0", "append-field": "^1.0.0",
"busboy": "^1.6.0", "busboy": "^1.6.0",
"concat-stream": "^2.0.0", "concat-stream": "^2.0.0",
"mkdirp": "^0.5.6", "type-is": "^1.6.18"
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
}, },
"engines": { "engines": {
"node": ">= 10.16.0" "node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/multer/node_modules/media-typer": { "node_modules/multer/node_modules/media-typer": {
@@ -2931,6 +3461,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.26", "version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
@@ -3584,7 +4161,6 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsx": { "node_modules/tsx": {
@@ -3720,9 +4296,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.3.5", "version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3795,9 +4371,9 @@
} }
}, },
"node_modules/vite-node": { "node_modules/vite-node": {
"version": "3.2.3", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.3.tgz", "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
"integrity": "sha512-gc8aAifGuDIpZHrPjuHyP4dpQmYXqWw7D1GmDnWeNWP654UEXzVfQ5IHPSK5HaHkwB/+p1atpYpSdw/2kOv8iQ==", "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3818,20 +4394,20 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "3.2.3", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.3.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
"integrity": "sha512-E6U2ZFXe3N/t4f5BwUaVCKRLHqUpk1CBWeMh78UT4VaTPH/2dyvH6ALl29JTovEPu9dVKr/K/J4PkXgrMbw4Ww==", "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/expect": "3.2.3", "@vitest/expect": "3.2.7",
"@vitest/mocker": "3.2.3", "@vitest/mocker": "3.2.7",
"@vitest/pretty-format": "^3.2.3", "@vitest/pretty-format": "^3.2.7",
"@vitest/runner": "3.2.3", "@vitest/runner": "3.2.7",
"@vitest/snapshot": "3.2.3", "@vitest/snapshot": "3.2.7",
"@vitest/spy": "3.2.3", "@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.3", "@vitest/utils": "3.2.7",
"chai": "^5.2.0", "chai": "^5.2.0",
"debug": "^4.4.1", "debug": "^4.4.1",
"expect-type": "^1.2.1", "expect-type": "^1.2.1",
@@ -3842,10 +4418,10 @@
"tinybench": "^2.9.0", "tinybench": "^2.9.0",
"tinyexec": "^0.3.2", "tinyexec": "^0.3.2",
"tinyglobby": "^0.2.14", "tinyglobby": "^0.2.14",
"tinypool": "^1.1.0", "tinypool": "^1.1.1",
"tinyrainbow": "^2.0.0", "tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
"vite-node": "3.2.3", "vite-node": "3.2.4",
"why-is-node-running": "^2.3.0" "why-is-node-running": "^2.3.0"
}, },
"bin": { "bin": {
@@ -3861,8 +4437,8 @@
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.3", "@vitest/browser": "3.2.7",
"@vitest/ui": "3.2.3", "@vitest/ui": "3.2.7",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*" "jsdom": "*"
}, },
+16 -6
View File
@@ -8,36 +8,46 @@
"dev:web": "vite", "dev:web": "vite",
"dev:server": "tsx watch server/index.ts", "dev:server": "tsx watch server/index.ts",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"e2e:serve": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgres://osint:osint_secret@localhost:5433/osint_dev} tsx server/e2eHarness.ts",
"start": "NODE_ENV=production tsx server/index.ts", "start": "NODE_ENV=production tsx server/index.ts",
"migrate:up": "tsx server/migrate.ts", "migrate:up": "tsx server/migrate.ts",
"test": "vitest run" "mystery:import": "tsx scripts/importMysteryTemplate.ts",
"test": "vitest run --exclude '**/*.integration.test.ts' --exclude 'e2e/**'",
"test:integration": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgres://osint:osint_secret@localhost:5433/osint_dev} vitest run server/*.integration.test.ts",
"test:e2e": "npm run build && playwright test"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1111.0",
"cookie-parser": "^1.4.7",
"cors": "2.8.5", "cors": "2.8.5",
"dotenv": "16.5.0", "dotenv": "16.5.0",
"express": "5.1.0", "express": "5.1.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "0.468.0", "lucide-react": "0.468.0",
"multer": "2.0.2", "multer": "^2.2.0",
"pg": "8.16.3", "pg": "8.16.3",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"tsx": "4.20.3" "tsx": "4.20.3"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.1",
"@types/cookie-parser": "^1.4.9",
"@types/cors": "2.8.18", "@types/cors": "2.8.18",
"@types/express": "5.0.3", "@types/express": "5.0.3",
"@types/node": "22.15.30", "@types/jsonwebtoken": "^9.0.10",
"@types/multer": "2.0.0", "@types/multer": "2.0.0",
"@types/node": "22.15.30",
"@types/pg": "8.15.4", "@types/pg": "8.15.4",
"@types/react": "19.1.8", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"@vitejs/plugin-react": "4.5.2", "@vitejs/plugin-react": "4.5.2",
"concurrently": "9.1.2", "concurrently": "9.1.2",
"typescript": "5.8.3", "typescript": "5.8.3",
"vite": "6.3.5", "vite": "^6.4.3",
"vitest": "3.2.3" "vitest": "^3.2.7"
}, },
"engines": { "engines": {
"node": ">=20" "node": "^20.0.0 || >=22.0.0"
} }
} }
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from '@playwright/test'
import jwt from 'jsonwebtoken'
const port = 18788
const adminToken = jwt.sign({ sub: 'e2e-admin', role: 'admin' }, 'osint-e2e-jwt-secret')
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
workers: 1,
retries: 0,
timeout: 30_000,
use: {
baseURL: `http://127.0.0.1:${port}`,
headless: true,
viewport: { width: 1280, height: 720 },
extraHTTPHeaders: { Cookie: `auth_token=${adminToken}` },
},
webServer: {
command: 'npm run e2e:serve',
url: `http://127.0.0.1:${port}/api/health`,
reuseExistingServer: false,
timeout: 120_000,
gracefulShutdown: { signal: 'SIGTERM', timeout: 10_000 },
},
})
+147
View File
@@ -0,0 +1,147 @@
import { randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { CaseDocument, CaseState, PartyKind, SourceFileType } from '../src/types.js'
type MysteryDocument = {
key: string
title: string
fileType: SourceFileType
publishedAt: string
body?: string[]
metadata?: Record<string, string>
asset?: string
}
type MysteryGraph = {
entry: string
nodes: { key: string; type: 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'; label?: string; x: number; y: number
componentKey?: string; templateSlug?: string; version?: number
terminals?: { key: string; label?: string; to?: string | null }[]
utterances?: { npc?: string; pose?: string; text: string; utterer?: 'npc' | 'player' }[] }[]
}
type MysteryNarrative = {
cast: { key: string; name: string; role?: string; defaultPose?: string; poses?: { poseKey: string; assetId: string }[] }[]
graph?: MysteryGraph
}
type MysteryManifest = {
slug: string
name: string
title: string
subtitle: string
timelineRange?: { start: string; end: string }
brief: { body: string; concepts: { label: string; context: string; expectedPartyKind: PartyKind }[] }
documents: MysteryDocument[]
folders: { key: string; title: string; content: string; x: number; y: number; width: number; members: string[] }[]
narrative?: MysteryNarrative
}
function requireOk(response: Response, action: string) {
if (response.ok) return response
return response.text().then(body => { throw new Error(`${action} failed (${response.status}): ${body}`) })
}
async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string, document: MysteryDocument, authorization?: string) {
if (!document.asset) return undefined
const assetPath = path.resolve(manifestDir, document.asset)
const form = new FormData()
form.append('file', new Blob([await readFile(assetPath)]), path.basename(assetPath))
const response = await requireOk(await fetch(`${baseUrl}/api/levels/${levelId}/documents?edit=1`, { method: 'POST', headers: authorization ? { authorization } : undefined, body: form }), `Upload ${document.asset}`)
return await response.json() as CaseDocument
}
export async function importMysteryTemplate(manifestPath: string, baseUrl = 'http://localhost:8787', adminJwt = process.env.OSINT_ADMIN_JWT) {
const absoluteManifest = path.resolve(manifestPath)
const manifest = JSON.parse(await readFile(absoluteManifest, 'utf8')) as MysteryManifest
const manifestDir = path.dirname(absoluteManifest)
const authoringId = `${manifest.slug}-authoring-${Date.now()}`
const authorization = adminJwt ? `Bearer ${adminJwt}` : undefined
const headers = { 'content-type': 'application/json', ...(authorization ? { authorization } : {}) }
const createdResponse = await requireOk(await fetch(`${baseUrl}/api/levels`, {
method: 'POST', headers,
body: JSON.stringify({ id: authoringId, title: manifest.title, subtitle: manifest.subtitle }),
}), 'Create authoring level')
const state = await createdResponse.json() as CaseState
const documentPositions = new Map<string, { x: number; y: number }>()
manifest.folders.forEach((folder, folderIndex) => folder.members.forEach((key, memberIndex) => documentPositions.set(key, { x: folder.x + 70 + memberIndex * 205, y: folder.y + 230 + folderIndex * 35 })))
const documents = new Map<string, CaseDocument>()
for (const source of manifest.documents) {
const uploaded = await uploadAsset(baseUrl, state.id, manifestDir, source, authorization)
documents.set(source.key, {
id: uploaded?.id || randomUUID(), type: 'document', title: source.title, publishedAt: source.publishedAt,
x: documentPositions.get(source.key)?.x || 100, y: documentPositions.get(source.key)?.y || 100,
width: uploaded?.width || 174, height: uploaded?.height || 145, rotation: 0, zIndex: uploaded?.zIndex || 1, hidden: false,
body: source.body || [], regions: [], assetId: uploaded?.assetId,
fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize,
fileType: source.fileType, metadata: source.metadata || {},
})
}
const folderIds = new Map(manifest.folders.map(folder => [folder.key, randomUUID()]))
state.brief = { body: manifest.brief.body, concepts: manifest.brief.concepts.map(concept => ({ id: randomUUID(), ...concept })) }
state.views = state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: manifest.timelineRange ? 'fixed' : 'auto', range: manifest.timelineRange } : view)
const folders = manifest.folders.map(folder => ({
id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content,
x: folder.x, y: folder.y, width: folder.width, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false,
} as const))
state.exhibits = [...documents.values(), ...folders]
state.relations = manifest.folders.flatMap(folder => folder.members.map((key, memberIndex) => {
const document = documents.get(key)
if (!document) throw new Error(`Folder ${folder.key} refers to unknown document ${key}`)
return {
id: `contains:${folderIds.get(folder.key)}:${document.id}`,
fromExhibitId: folderIds.get(folder.key)!, toExhibitId: document.id, type: 'contains' as const, sortOrder: memberIndex,
}
}))
state.connections = []
state.viewport = { x: 0, y: 28, zoom: 0.7 }
await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT', headers, body: JSON.stringify(state),
}), 'Save authored mystery')
const templateResponse = await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
method: 'POST', headers, body: JSON.stringify({ slug: manifest.slug, name: manifest.name }),
}), 'Freeze mystery template')
const template = await templateResponse.json() as { slug: string; currentVersion: number }
// Author the mystery and its NPC cast; the flow lives in the story graph, seeded below.
let mystery: { slug: string } | undefined
if (manifest.narrative) {
const mysteryResponse = await requireOk(await fetch(`${baseUrl}/api/mysteries?edit=1`, {
method: 'POST', headers, body: JSON.stringify({ slug: manifest.slug, title: manifest.title, cast: manifest.narrative.cast }),
}), 'Author narrative mystery')
mystery = await mysteryResponse.json() as { slug: string }
// Seed the story flow graph (default authored content that survives re-imports).
if (manifest.narrative.graph) {
const listResponse = await requireOk(await fetch(`${baseUrl}/api/admin/mysteries`, { headers: authorization ? { authorization } : undefined }), 'List mysteries')
const mysteries = await listResponse.json() as { id: string; slug: string }[]
const mysteryId = mysteries.find(m => m.slug === manifest.slug)?.id
if (mysteryId) await requireOk(await fetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, {
method: 'POST', headers, body: JSON.stringify(manifest.narrative.graph),
}), 'Seed story graph')
}
}
const playableId = `${manifest.slug}-case-${Date.now()}`
const playableResponse = await requireOk(await fetch(`${baseUrl}/api/templates/${manifest.slug}/levels?edit=1`, {
method: 'POST', headers, body: JSON.stringify({ id: playableId, title: manifest.title }),
}), 'Instantiate playable mystery')
const playable = await playableResponse.json() as CaseState
return { manifest, template, mystery, authoringLevelId: state.id, playableLevel: playable }
}
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ''
if (invokedPath === fileURLToPath(import.meta.url)) {
const manifestPath = process.argv[2]
if (!manifestPath) throw new Error('Usage: npm run mystery:import -- <manifest.json>')
const result = await importMysteryTemplate(manifestPath, process.env.OSINT_BOARD_URL)
console.log(JSON.stringify({
template: `${result.template.slug}@v${result.template.currentVersion}`,
mystery: result.mystery ? result.mystery.slug : undefined,
authoringLevelId: result.authoringLevelId,
playableLevelId: result.playableLevel.id,
playUrl: `${process.env.OSINT_BOARD_URL || 'http://localhost:8787'}/`,
}, null, 2))
}
+132
View File
@@ -0,0 +1,132 @@
import { createServer } from 'node:net'
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
import jwt from 'jsonwebtoken'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { CaseState, DocumentExhibit, EventExhibit, FolderExhibit, NoteExhibit, PartyExhibit, TimelineView } from '../src/types.js'
import { runMigrations } from './migrations.js'
const { Client } = pg
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
const suite = baseDatabaseUrl ? describe : describe.skip
const databaseName = `osint_api_test_${process.pid}_${Date.now()}`
let adminClient: InstanceType<typeof Client>
let appServer: Awaited<typeof import('./index.js')>['server']
let appPool: Awaited<typeof import('./index.js')>['pool']
let baseUrl = ''
let adminAuthorization = ''
function adminFetch(url: string, init: RequestInit = {}) {
const headers = new Headers(init.headers)
headers.set('authorization', adminAuthorization)
return fetch(url, { ...init, headers })
}
async function availablePort() {
return new Promise<number>((resolve, reject) => {
const probe = createServer()
probe.once('error', reject)
probe.listen(0, '127.0.0.1', () => {
const address = probe.address()
const port = typeof address === 'object' && address ? address.port : 0
probe.close(error => error ? reject(error) : resolve(port))
})
})
}
const placed = (x: number, y: number, width: number, height: number, zIndex = 1) => ({ x, y, width, height, rotation: 0, zIndex, hidden: false })
suite('normalized level persistence API', () => {
beforeAll(async () => {
const adminUrl = new URL(baseDatabaseUrl!)
adminUrl.pathname = '/postgres'
adminClient = new Client({ connectionString: adminUrl.toString() })
await adminClient.connect()
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
const testUrl = new URL(baseDatabaseUrl!)
testUrl.pathname = `/${databaseName}`
const databaseUrl = testUrl.toString()
await runMigrations(databaseUrl, path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations'), () => undefined)
const port = await availablePort()
process.env.DATABASE_URL = databaseUrl
process.env.LEVEL_EDITING_ENABLED = 'true'
process.env.JWT_SECRET = 'osint-integration-jwt-secret'
process.env.ASSET_STORAGE_DRIVER = 'memory'
process.env.PORT = String(port)
const serverModule = await import('./index.js')
appServer = serverModule.server
appPool = serverModule.pool
baseUrl = `http://127.0.0.1:${port}`
adminAuthorization = `Bearer ${jwt.sign({ sub: 'integration-admin', role: 'admin' }, process.env.JWT_SECRET)}`
})
afterAll(async () => {
if (appServer) await new Promise<void>((resolve, reject) => appServer.close(error => error ? reject(error) : resolve()))
if (appPool) await appPool.end()
if (!adminClient) return
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
await adminClient.end()
})
it('round-trips exhibits, relations, board views, private objects, and template clones', async () => {
expect(await (await fetch(`${baseUrl}/api/session`)).json()).toEqual({ authenticated: false, isAdmin: false })
const createResponse = await adminFetch(`${baseUrl}/api/levels`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }),
})
expect(createResponse.status).toBe(201)
const state = await createResponse.json() as CaseState
const timeline = state.views.find((view): view is TimelineView => view.type === 'timeline')!
timeline.rangeMode = 'fixed'
timeline.range = { start: '2021-04-01', end: '2021-04-30' }
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 folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) }
const note: NoteExhibit = { id: randomUUID(), type: 'note', title: 'Extract', content: 'Date matters', ...placed(420, 300, 108, 154) }
const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) }
const party: PartyExhibit = { id: randomUUID(), type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as correspondent.', aliases: ['A. A. L.'], ...placed(720, 250, 280, 190) }
state.exhibits = [document, folder, note, event, party]
state.relations = [
{ id: randomUUID(), fromExhibitId: folder.id, toExhibitId: document.id, type: 'contains', sortOrder: 0 },
{ id: randomUUID(), fromExhibitId: note.id, toExhibitId: document.id, type: 'source', sourceRegionId: 'stamp', sortOrder: 0 },
{ id: randomUUID(), fromExhibitId: event.id, toExhibitId: document.id, type: 'supports', sortOrder: 0 },
{ id: randomUUID(), fromExhibitId: event.id, toExhibitId: note.id, type: 'supports', sortOrder: 1 },
{ id: randomUUID(), fromExhibitId: party.id, toExhibitId: document.id, type: 'concerns', sortOrder: 0 },
]
state.connections = [{ id: randomUUID(), fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 }]
state.brief = { body: 'Identify Ada Lovelace.', concepts: [{ id: randomUUID(), label: 'Ada Lovelace', context: 'Named in evidence.', expectedPartyKind: 'person', resolvedPartyExhibitId: party.id }] }
const save = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(state) })
expect(await save.json()).toEqual({ ok: true, mode: 'author' })
const loaded = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
expect(loaded.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true })
expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })]))
expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' })
const upload = new FormData()
upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt')
const uploadResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/documents?edit=1`, { method: 'POST', body: upload })
expect(uploadResponse.status).toBe(201)
const uploaded = await uploadResponse.json() as DocumentExhibit
expect(uploaded).toMatchObject({ type: 'document', fileName: 'smoke-evidence.txt', fileType: 'text' })
expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence')
const assetRow = await appPool.query<{ storage_provider: string; content: Buffer | null; object_key: string | null }>('SELECT storage_provider,content,object_key FROM osint.assets WHERE id=$1', [uploaded.assetId])
expect(assetRow.rows[0]).toMatchObject({ storage_provider: 's3', content: null, object_key: expect.stringMatching(/^assets\//) })
const 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)
const cloneResponse = await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'smoke-template-copy', title: 'Playable copy' }) })
expect(cloneResponse.status).toBe(201)
const clone = await cloneResponse.json() as CaseState
expect(clone.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
expect(clone.exhibits.map(item => item.id)).not.toContain(folder.id)
expect(clone.exhibits.find(item => item.type === 'folder')).toMatchObject({ x: 685, y: 417 })
expect(clone.relations.filter(relation => relation.type === 'supports')).toHaveLength(2)
expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 })
expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id)
})
})
+51
View File
@@ -0,0 +1,51 @@
import type { NextFunction, Request, Response } from 'express'
import jwt, { type JwtPayload } from 'jsonwebtoken'
export type OsintClaims = JwtPayload & { role?: string; isAdmin?: boolean }
declare global {
namespace Express {
interface Request { authClaims?: OsintClaims }
}
}
export function authenticateJwt(req: Request, _res: Response, next: NextFunction) {
const authorization = req.headers.authorization
const token = req.cookies?.auth_token || (authorization?.startsWith('Bearer ') ? authorization.slice(7) : undefined)
const secret = process.env.JWT_SECRET
if (token && secret) {
try {
const decoded = jwt.verify(token, secret)
if (typeof decoded !== 'string') req.authClaims = decoded as OsintClaims
} catch { /* An absent, expired, or invalid cookie is an anonymous session. */ }
}
next()
}
export function hasAdminClaim(req: Request) {
return req.authClaims?.role === 'admin' || req.authClaims?.isAdmin === true
}
/**
* Identity for a player's game state. Real players arrive with a JWT issued by
* glitch.university (verified through the key-exchange handoff); until that lands,
* an absent token resolves to a single fixed development user so the game is
* playable locally with no identity provider. Only this fallback branch changes
* when the external handoff is wired the `user_id` column stays the same.
*/
export const DEVELOPMENT_TEST_USER_ID = 'osint-test-player'
export function resolveUserId(req: Request): string {
const sub = req.authClaims?.sub
return typeof sub === 'string' && sub.length > 0 ? sub : DEVELOPMENT_TEST_USER_ID
}
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
if (!hasAdminClaim(req)) return res.status(403).json({ error: 'Administrator claim required' })
next()
}
export function createDevelopmentAdminToken() {
if (process.env.NODE_ENV === 'production') throw new Error('Development sessions are disabled in production')
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required')
return jwt.sign({ sub: 'osint-local-admin', role: 'admin', isAdmin: true }, process.env.JWT_SECRET, { expiresIn: '7d' })
}
+192
View File
@@ -0,0 +1,192 @@
import { randomUUID } from 'node:crypto'
import type { PoolClient } from 'pg'
type IdMap = Map<string, string>
function mapped(ids: IdMap, sourceId: string, label: string) {
const id = ids.get(sourceId)
if (!id) throw new Error(`Could not map ${label} ${sourceId}`)
return id
}
export async function clearBoard(client: PoolClient, boardId: string) {
await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [boardId])
await client.query('UPDATE osint.boards SET revision=0,updated_at=NOW() WHERE id=$1', [boardId])
}
/** Clone a complete normalized board. The target board must be empty. */
export async function cloneBoard(client: PoolClient, sourceBoardId: string, targetBoardId: string) {
const exhibitIds: IdMap = new Map()
const regionIds: IdMap = new Map()
const fieldIds: IdMap = new Map()
const views = await client.query<{
id: string; view_type_id: string; placement_mode: string; dock_edge: string | null; xpos: number | null; ypos: number | null
width: number | null; height: number; z_index: number; visible: boolean; range_mode: string | null; range_start: string | null; range_end: string | null
}>(`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
LEFT JOIN osint.timeline_views t ON t.view_id=v.id WHERE v.board_id=$1 ORDER BY v.z_index,v.created_at`, [sourceBoardId])
for (const row of views.rows) {
const viewId = randomUUID()
await client.query(`INSERT INTO osint.board_views
(id,board_id,view_type_id,origin_view_id,placement_mode,dock_edge,xpos,ypos,width,height,z_index,visible)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, [viewId,targetBoardId,row.view_type_id,row.id,row.placement_mode,row.dock_edge,
row.xpos,row.ypos,row.width,row.height,row.z_index,row.visible])
if (row.view_type_id === 'timeline') await client.query(
'INSERT INTO osint.timeline_views (view_id,range_mode,range_start,range_end) VALUES ($1,$2,$3,$4)',
[viewId,row.range_mode || 'auto',row.range_start,row.range_end])
}
const exhibits = await client.query<{
id: string; exhibit_type_id: string; xpos: number; ypos: number; width: number; height: number
rotation: number; z_index: number; hidden: boolean
}>(`SELECT id,exhibit_type_id,xpos,ypos,width,height,rotation,z_index,hidden
FROM osint.exhibits WHERE board_id=$1 ORDER BY created_at,id`, [sourceBoardId])
for (const row of exhibits.rows) {
const id = randomUUID(); exhibitIds.set(row.id, id)
await client.query(`INSERT INTO osint.exhibits
(id,board_id,exhibit_type_id,origin_exhibit_id,xpos,ypos,width,height,rotation,z_index,hidden)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
[id, targetBoardId, row.exhibit_type_id, row.id, row.xpos, row.ypos, row.width, row.height, row.rotation, row.z_index, row.hidden])
}
const folders = await client.query<{ exhibit_id: string; title: string; label_text: string; is_open: boolean }>(
`SELECT f.* FROM osint.folder_exhibits f JOIN osint.exhibits e ON e.id=f.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
for (const row of folders.rows) await client.query(
'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)',
[mapped(exhibitIds, row.exhibit_id, 'folder'), row.title, row.label_text, row.is_open])
const documents = await client.query<{
exhibit_id: string; document_type_id: string; asset_id: string | null; title: string
published_at: Date | null; captured_at: Date | null; source_uri: string | null
}>(`SELECT d.* FROM osint.document_exhibits d JOIN osint.exhibits e ON e.id=d.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
for (const row of documents.rows) await client.query(`INSERT INTO osint.document_exhibits
(exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[mapped(exhibitIds, row.exhibit_id, 'document'), row.document_type_id, row.asset_id, row.title, row.published_at, row.captured_at, row.source_uri])
const images = await client.query<{ exhibit_id: string; pixel_width: number | null; pixel_height: number | null; alt_text: string }>(
`SELECT i.* FROM osint.image_documents i JOIN osint.exhibits e ON e.id=i.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
for (const row of images.rows) await client.query(
'INSERT INTO osint.image_documents (exhibit_id,pixel_width,pixel_height,alt_text) VALUES ($1,$2,$3,$4)',
[mapped(exhibitIds, row.exhibit_id, 'image'), row.pixel_width, row.pixel_height, row.alt_text])
const notes = await client.query<{ exhibit_id: string; title: string; note_text: string }>(
`SELECT n.* FROM osint.note_exhibits n JOIN osint.exhibits e ON e.id=n.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
for (const row of notes.rows) await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)',
[mapped(exhibitIds, row.exhibit_id, 'note'), row.title, row.note_text])
const events = await client.query<{ exhibit_id: string; title: string; narrative_text: string; occurred_at: Date | null }>(
`SELECT ev.* FROM osint.event_exhibits ev JOIN osint.exhibits e ON e.id=ev.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
for (const row of events.rows) await client.query(
'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)',
[mapped(exhibitIds, row.exhibit_id, 'event'), row.title, row.narrative_text, row.occurred_at])
const parties = await client.query<{ exhibit_id: string; party_kind: string; display_name: string; summary: string }>(
`SELECT p.* FROM osint.party_exhibits p JOIN osint.exhibits e ON e.id=p.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
for (const row of parties.rows) await client.query(
'INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)',
[mapped(exhibitIds, row.exhibit_id, 'party'), row.party_kind, row.display_name, row.summary])
const people = await client.query<{ exhibit_id: string; given_name: string | null; family_name: string | null }>(
`SELECT p.* FROM osint.person_parties p JOIN osint.exhibits e ON e.id=p.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
for (const row of people.rows) await client.query('INSERT INTO osint.person_parties (exhibit_id,given_name,family_name) VALUES ($1,$2,$3)',
[mapped(exhibitIds, row.exhibit_id, 'person'), row.given_name, row.family_name])
const organizations = await client.query<{ exhibit_id: string; organization_kind: string }>(
`SELECT o.* FROM osint.organization_parties o JOIN osint.exhibits e ON e.id=o.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
for (const row of organizations.rows) await client.query('INSERT INTO osint.organization_parties (exhibit_id,organization_kind) VALUES ($1,$2)',
[mapped(exhibitIds, row.exhibit_id, 'organization'), row.organization_kind])
const aliases = await client.query<{ party_exhibit_id: string; alias: string; sort_order: number }>(
`SELECT a.party_exhibit_id,a.alias,a.sort_order FROM osint.party_aliases a JOIN osint.exhibits e ON e.id=a.party_exhibit_id
WHERE e.board_id=$1 ORDER BY a.sort_order`, [sourceBoardId])
for (const row of aliases.rows) await client.query('INSERT INTO osint.party_aliases (id,party_exhibit_id,alias,sort_order) VALUES ($1,$2,$3,$4)',
[randomUUID(), mapped(exhibitIds, row.party_exhibit_id, 'party alias'), row.alias, row.sort_order])
const blocks = await client.query<{ document_exhibit_id: string; sort_order: number; content: string }>(
`SELECT b.document_exhibit_id,b.sort_order,b.content FROM osint.document_content_blocks b
JOIN osint.exhibits e ON e.id=b.document_exhibit_id WHERE e.board_id=$1 ORDER BY b.sort_order`, [sourceBoardId])
for (const row of blocks.rows) await client.query(
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,$3,$4)',
[randomUUID(), mapped(exhibitIds, row.document_exhibit_id, 'content document'), row.sort_order, row.content])
const regions = await client.query<{
id: string; document_exhibit_id: string; region_key: string; label: string; excerpt: string; occurred_at: Date | null; sort_order: number
}>(`SELECT r.* FROM osint.document_regions r JOIN osint.exhibits e ON e.id=r.document_exhibit_id
WHERE e.board_id=$1 ORDER BY r.sort_order`, [sourceBoardId])
for (const row of regions.rows) {
const id = randomUUID(); regionIds.set(row.id, id)
await client.query(`INSERT INTO osint.document_regions
(id,document_exhibit_id,region_key,label,excerpt,occurred_at,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[id, mapped(exhibitIds, row.document_exhibit_id, 'region document'), row.region_key, row.label, row.excerpt, row.occurred_at, row.sort_order])
}
const fields = await client.query<{ id: string; field_key: string; label: string; value_type: string }>(
'SELECT id,field_key,label,value_type FROM osint.metadata_fields WHERE board_id=$1 ORDER BY field_key', [sourceBoardId])
for (const row of fields.rows) {
const id = randomUUID(); fieldIds.set(row.id, id)
await client.query('INSERT INTO osint.metadata_fields (id,board_id,field_key,label,value_type) VALUES ($1,$2,$3,$4,$5)',
[id, targetBoardId, row.field_key, row.label, row.value_type])
}
for (const [table, cast] of [
['exhibit_metadata_text_values', 'text'], ['exhibit_metadata_timestamp_values', 'timestamptz'],
['exhibit_metadata_number_values', 'numeric'], ['exhibit_metadata_boolean_values', 'boolean'],
] as const) {
const values = await client.query<{ exhibit_id: string; field_id: string; value: unknown }>(
`SELECT v.exhibit_id,v.field_id,v.value::${cast} AS value FROM osint.${table} v
JOIN osint.exhibits e ON e.id=v.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
for (const row of values.rows) await client.query(`INSERT INTO osint.${table} (exhibit_id,field_id,value) VALUES ($1,$2,$3)`,
[mapped(exhibitIds, row.exhibit_id, 'metadata exhibit'), mapped(fieldIds, row.field_id, 'metadata field'), row.value])
}
const memberships = await client.query<{ folder_exhibit_id: string; child_exhibit_id: string; sort_order: number }>(
'SELECT folder_exhibit_id,child_exhibit_id,sort_order FROM osint.folder_memberships WHERE board_id=$1', [sourceBoardId])
for (const row of memberships.rows) await client.query(`INSERT INTO osint.folder_memberships
(board_id,folder_exhibit_id,child_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
[targetBoardId, mapped(exhibitIds, row.folder_exhibit_id, 'membership folder'), mapped(exhibitIds, row.child_exhibit_id, 'membership child'), row.sort_order])
const eventEvidence = await client.query<{ event_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
'SELECT event_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.event_evidence WHERE board_id=$1', [sourceBoardId])
for (const row of eventEvidence.rows) await client.query(`INSERT INTO osint.event_evidence
(board_id,event_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
[targetBoardId, mapped(exhibitIds, row.event_exhibit_id, 'event'), mapped(exhibitIds, row.evidence_exhibit_id, 'event evidence'), row.sort_order, row.note])
const partyEvidence = await client.query<{ party_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
'SELECT party_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.party_evidence WHERE board_id=$1', [sourceBoardId])
for (const row of partyEvidence.rows) await client.query(`INSERT INTO osint.party_evidence
(board_id,party_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
[targetBoardId, mapped(exhibitIds, row.party_exhibit_id, 'party'), mapped(exhibitIds, row.evidence_exhibit_id, 'party evidence'), row.sort_order, row.note])
const partyRelationships = await client.query<{ relationship_type_id: string; from_party_exhibit_id: string; to_party_exhibit_id: string; note: string | null }>(
'SELECT relationship_type_id,from_party_exhibit_id,to_party_exhibit_id,note FROM osint.party_relationships WHERE board_id=$1', [sourceBoardId])
for (const row of partyRelationships.rows) await client.query(`INSERT INTO osint.party_relationships
(id,board_id,relationship_type_id,from_party_exhibit_id,to_party_exhibit_id,note) VALUES ($1,$2,$3,$4,$5,$6)`,
[randomUUID(), targetBoardId, row.relationship_type_id, mapped(exhibitIds, row.from_party_exhibit_id, 'related party'),
mapped(exhibitIds, row.to_party_exhibit_id, 'related party'), row.note])
const connections = await client.query<{ connection_type_id: string; from_exhibit_id: string; to_exhibit_id: string; label: string | null; tightness: number; tag_style: string; tag_position_percent: number; tag_lateral_offset: number }>(
'SELECT connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style,tag_position_percent,tag_lateral_offset FROM osint.exhibit_connections WHERE board_id=$1', [sourceBoardId])
for (const row of connections.rows) await client.query(`INSERT INTO osint.exhibit_connections
(id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style,tag_position_percent,tag_lateral_offset) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[randomUUID(), targetBoardId, row.connection_type_id, mapped(exhibitIds, row.from_exhibit_id, 'connection source'), mapped(exhibitIds, row.to_exhibit_id, 'connection target'), row.label, row.tightness, row.tag_style, row.tag_position_percent, row.tag_lateral_offset])
const sources = await client.query<{ exhibit_id: string; source_document_exhibit_id: string; source_region_id: string | null }>(
`SELECT s.exhibit_id,s.source_document_exhibit_id,s.source_region_id FROM osint.exhibit_sources s
JOIN osint.exhibits e ON e.id=s.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
for (const row of sources.rows) await client.query(`INSERT INTO osint.exhibit_sources
(exhibit_id,source_document_exhibit_id,source_region_id) VALUES ($1,$2,$3)`,
[mapped(exhibitIds, row.exhibit_id, 'sourced exhibit'), mapped(exhibitIds, row.source_document_exhibit_id, 'source document'),
row.source_region_id ? mapped(regionIds, row.source_region_id, 'source region') : null])
const brief = await client.query<{ body: string }>('SELECT body FROM osint.level_briefs WHERE board_id=$1', [sourceBoardId])
if (brief.rows[0]) await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [targetBoardId, brief.rows[0].body])
const concepts = await client.query<{
id: string; label: string; context_text: string; sort_order: number; expected_party_kind: string | null; resolved_party_exhibit_id: string | null
}>('SELECT id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id FROM osint.brief_concepts WHERE board_id=$1 ORDER BY sort_order,id', [sourceBoardId])
for (const row of concepts.rows) await client.query(`INSERT INTO osint.brief_concepts
(id,board_id,origin_concept_id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
[randomUUID(), targetBoardId, row.id, row.label, row.context_text, row.sort_order, row.expected_party_kind,
row.resolved_party_exhibit_id ? mapped(exhibitIds, row.resolved_party_exhibit_id, 'resolved party') : null])
return exhibitIds
}
+89
View File
@@ -0,0 +1,89 @@
import { once } from 'node:events'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
import jwt from 'jsonwebtoken'
import type { CaseState } from '../src/types.js'
import { importMysteryTemplate } from '../scripts/importMysteryTemplate.js'
import { runMigrations } from './migrations.js'
const { Client } = pg
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
if (!baseDatabaseUrl) throw new Error('TEST_DATABASE_URL is required for the browser harness')
const databaseName = `osint_e2e_${process.pid}_${Date.now()}`
const adminUrl = new URL(baseDatabaseUrl)
adminUrl.pathname = '/postgres'
const adminClient = new Client({ connectionString: adminUrl.toString() })
await adminClient.connect()
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
const testUrl = new URL(baseDatabaseUrl)
testUrl.pathname = `/${databaseName}`
const databaseUrl = testUrl.toString()
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
await runMigrations(databaseUrl, migrationsDir, () => undefined)
const port = Number(process.env.E2E_PORT || 18788)
process.env.DATABASE_URL = databaseUrl
process.env.LEVEL_EDITING_ENABLED = 'true'
process.env.JWT_SECRET = 'osint-e2e-jwt-secret'
process.env.PORT = String(port)
process.env.OSINT_MANAGED_SERVER = 'true'
process.env.ASSET_STORAGE_DRIVER = 'memory'
const { server, pool } = await import('./index.js')
if (!server.listening) await once(server, 'listening')
const baseUrl = `http://127.0.0.1:${port}`
const adminToken = jwt.sign({ sub: 'e2e-admin', role: 'admin' }, process.env.JWT_SECRET)
const adminHeaders = { 'content-type': 'application/json', authorization: `Bearer ${adminToken}` }
const documentId = '22222222-2222-4222-8222-222222222222'
const folderId = '11111111-1111-4111-8111-111111111111'
const created = await fetch(`${baseUrl}/api/levels`, {
method: 'POST',
headers: adminHeaders,
body: JSON.stringify({ id: 'e2e-level', title: 'Browser Safety Test', subtitle: 'Disposable test level' }),
})
if (!created.ok) throw new Error(`Could not create browser test level: ${created.status}`)
const state = await created.json() as CaseState
state.brief = { body: 'Classify the named people and organizations in this investigation.', concepts: [
{ id: '44444444-4444-4444-8444-444444444444', label: 'Ada Lovelace', context: 'Named as the correspondent.', expectedPartyKind: 'person' },
{ id: '55555555-5555-4555-8555-555555555555', label: 'Difference Engine Bureau', context: 'Issued the archive notice.', expectedPartyKind: 'organization' },
] }
state.exhibits = [{
id: documentId, type: 'document', title: 'Dated source image', publishedAt: '2021-04-17T12:00:00.000Z',
body: [], regions: [], fileType: 'image', metadata: {}, x: 980, y: 360, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false,
}, {
id: folderId, type: 'folder', title: 'BROWSER TEST FOLDER', content: 'Disposable evidence',
x: 600, y: 360, width: 260, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false,
}]
state.relations = [{
id: `contains:${folderId}:${documentId}`, fromExhibitId: folderId, toExhibitId: documentId, type: 'contains', sortOrder: 0,
}]
state.connections = []
state.viewport = { x: 0, y: 28, zoom: 0.7 }
const saved = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT',
headers: adminHeaders,
body: JSON.stringify(state),
})
if (!saved.ok) throw new Error(`Could not seed browser test level: ${saved.status}`)
await importMysteryTemplate(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'mysteries', 'glass-harbor', 'mystery.json'), baseUrl, adminToken)
let shuttingDown = false
async function shutdown(exitCode: number) {
if (shuttingDown) return
shuttingDown = true
if (server.listening) await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
await pool.end()
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
await adminClient.end()
process.exit(exitCode)
}
process.on('SIGTERM', () => void shutdown(0))
process.on('SIGINT', () => void shutdown(0))
process.on('uncaughtException', error => { console.error(error); void shutdown(1) })
process.on('unhandledRejection', error => { console.error(error); void shutdown(1) })
console.log(`Browser safety harness ready on ${baseUrl}`)
+270 -224
View File
@@ -1,13 +1,18 @@
import 'dotenv/config' import 'dotenv/config'
import cors from 'cors' import cors from 'cors'
import cookieParser from 'cookie-parser'
import express from 'express' import express from 'express'
import fs from 'node:fs' import fs from 'node:fs'
import { createHash, randomUUID } from 'node:crypto'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import multer from 'multer' import multer from 'multer'
import pg, { type PoolClient } from 'pg' import pg from 'pg'
import type { CaseDocument, CaseState, Connection, Evidence, WidgetRelation } from '../src/types.js' import type { CaseState } from '../src/types.js'
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolveUserId } from './auth.js'
import { createLevelRepository } from './levelRepository.js'
import { createNarrativeRepository } from './narrativeRepository.js'
import { createStoryGraphRepository, type StoryNodeType } from './storyGraphRepository.js'
import { createObjectStorageFromEnv } from './objectStorage.js'
const { Pool } = pg const { Pool } = pg
const databaseUrl = process.env.DATABASE_URL const databaseUrl = process.env.DATABASE_URL
@@ -16,177 +21,27 @@ if (!databaseUrl) {
process.exit(1) process.exit(1)
} }
const pool = new Pool({ connectionString: databaseUrl }) 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()
type WidgetRow = { await objectStorage.initialize()
id: string; widget_type: 'document' | Evidence['type']; title: string; content: string const levels = createLevelRepository(pool, editingEnabled, objectStorage)
config: { kind?: string; date?: string; body?: string[]; fileType?: CaseDocument['fileType']; metadata?: Record<string, string>; [key: string]: unknown }; source_widget_id?: string; source_region_key?: string const narrative = createNarrativeRepository(pool, objectStorage)
event_date?: string; published_at?: string; x?: number; y?: number; width?: number; sort_order: number; asset_id?: string const storyGraph = createStoryGraphRepository(pool)
original_name?: string; mime_type?: string; byte_size?: number const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate']
}
function wantsEdit(req: express.Request) { function wantsEdit(req: express.Request) {
return editingEnabled && req.query.edit === '1' return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
}
function slug(value: unknown, fallback: string) {
return String(value || fallback).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
} }
async function assembleLevel(levelId: string, playthroughId = `default:${levelId}`, authorMode = false): Promise<CaseState | null> { export const app = express()
const levelResult = await pool.query<{ id: string; title: string; subtitle: string; status: string }>(
'SELECT id, title, subtitle, status FROM osint.levels WHERE id = $1', [levelId],
)
const level = levelResult.rows[0]
if (!level) return null
await pool.query(
`INSERT INTO osint.playthroughs (id, level_id) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`,
[playthroughId, levelId],
)
const [widgetsResult, regionsResult, authoredConnections, authoredRelations, playthroughResult, generatedResult, playerConnections, playerRelations, stateResult, relationStateResult] = await Promise.all([
pool.query<WidgetRow>(`SELECT w.id, w.widget_type, w.title, w.content, w.config, w.source_widget_id, w.source_region_key,
w.event_date::text, w.published_at::text, w.x, w.y, w.width, w.sort_order, w.asset_id, a.original_name, a.mime_type, a.byte_size
FROM osint.widgets w LEFT JOIN osint.assets a ON a.id = w.asset_id
WHERE w.level_id = $1 ORDER BY w.sort_order, w.id`, [levelId]),
pool.query<{ document_widget_id: string; region_key: string; label: string; excerpt: string; event_date?: string }>(
`SELECT r.document_widget_id, r.region_key, r.label, r.excerpt, r.event_date::text
FROM osint.widget_regions r JOIN osint.widgets w ON w.id = r.document_widget_id
WHERE w.level_id = $1 ORDER BY r.sort_order, r.id`, [levelId]),
pool.query<{ id: string; from_widget_id: string; to_widget_id: string }>(
'SELECT id, from_widget_id, to_widget_id FROM osint.level_connections WHERE level_id = $1', [levelId]),
pool.query<{ id: string; from_widget_id: string; to_widget_id: string; relation_type: string; sort_order: number; config: Record<string, unknown> }>(
`SELECT id, from_widget_id, to_widget_id, relation_type, sort_order, config FROM osint.widget_relations
WHERE level_id = $1 ORDER BY sort_order, id`, [levelId]),
pool.query<{ viewport: CaseState['viewport']; updated_at: Date }>(
'SELECT viewport, updated_at FROM osint.playthroughs WHERE id = $1', [playthroughId]),
pool.query<WidgetRow>(`SELECT id, widget_type, title, content, config, source_widget_id,
source_region_key, event_date::text, x, y, width, 0 AS sort_order
FROM osint.playthrough_widgets WHERE playthrough_id = $1 ORDER BY created_at, id`, [playthroughId]),
pool.query<{ id: string; from_widget_id: string; to_widget_id: string }>(
'SELECT id, from_widget_id, to_widget_id FROM osint.playthrough_connections WHERE playthrough_id = $1', [playthroughId]),
pool.query<{ id: string; from_widget_id: string; to_widget_id: string; relation_type: string; sort_order: number; config: Record<string, unknown> }>(
`SELECT id, from_widget_id, to_widget_id, relation_type, sort_order, config FROM osint.playthrough_widget_relations
WHERE playthrough_id = $1 ORDER BY sort_order, id`, [playthroughId]),
pool.query<{ widget_id: string; x: number; y: number; width: number; hidden: boolean; config: Record<string, unknown> }>(
'SELECT widget_id, x, y, width, hidden, config FROM osint.playthrough_widget_state WHERE playthrough_id = $1', [playthroughId]),
pool.query<{ relation_id: string; config: Record<string, unknown> }>(
'SELECT relation_id, config FROM osint.playthrough_widget_relation_state WHERE playthrough_id = $1', [playthroughId]),
])
const stateByWidget = new Map(authorMode ? [] : stateResult.rows.map(row => [row.widget_id, row]))
const documents: CaseDocument[] = widgetsResult.rows.filter(w => w.widget_type === 'document').map(w => { const override = stateByWidget.get(w.id)?.config || {}; const publishedAt = String(override.publishedAt || w.published_at || ''); return ({
id: w.id, title: String(override.title || w.title), kind: w.config.kind || 'DOCUMENT', date: publishedAt.slice(0, 10) || w.config.date || '', publishedAt: publishedAt || undefined,
body: w.config.body || [], fileType: (override.fileType || w.config.fileType || (w.mime_type?.startsWith('image/') ? 'image' : 'file')) as CaseDocument['fileType'], metadata: (override.metadata || w.config.metadata || {}) as Record<string, string>,
assetId: w.asset_id, fileName: w.original_name, mimeType: w.mime_type, fileSize: w.byte_size,
regions: regionsResult.rows.filter(r => r.document_widget_id === w.id).map(r => ({ id: r.region_key, label: r.label, excerpt: r.excerpt, date: r.event_date })),
}) })
const relationState = new Map(authorMode ? [] : relationStateResult.rows.map(row => [row.relation_id, row.config]))
const containedByFolder = new Map<string, string[]>()
for (const relation of [...authoredRelations.rows, ...(authorMode ? [] : playerRelations.rows)]) {
if (relation.relation_type !== 'contains') continue
containedByFolder.set(relation.from_widget_id, [...(containedByFolder.get(relation.from_widget_id) || []), relation.to_widget_id])
}
const toEvidence = (w: WidgetRow): Evidence => {
const override = stateByWidget.get(w.id)
const runtimeConfig = { ...w.config, ...(override?.config || {}) }
return { id: w.id, type: w.widget_type as Evidence['type'], title: String(override?.config?.title || w.title), content: String(override?.config?.content ?? w.content), config: runtimeConfig,
sourceDocumentId: w.source_widget_id, sourceRegionId: w.source_region_key, eventDate: w.widget_type === 'event' ? w.event_date : undefined,
containedDocumentIds: containedByFolder.get(w.id) || (w.source_widget_id ? [w.source_widget_id] : []),
x: override?.x ?? w.x ?? 100, y: override?.y ?? w.y ?? 100, width: override?.width ?? w.width ?? 240 }
}
const authoredEvidence = widgetsResult.rows.filter(w => w.widget_type !== 'document' && !stateByWidget.get(w.id)?.hidden).map(toEvidence)
const evidence = [...authoredEvidence, ...(authorMode ? [] : generatedResult.rows.map(toEvidence))]
const connections: Connection[] = [...authoredConnections.rows, ...(authorMode ? [] : playerConnections.rows)].map(c => ({
id: c.id, fromEvidenceId: c.from_widget_id, toEvidenceId: c.to_widget_id,
}))
const relations: WidgetRelation[] = [...authoredRelations.rows, ...(authorMode ? [] : playerRelations.rows)].map(relation => ({
id: relation.id, fromWidgetId: relation.from_widget_id, toWidgetId: relation.to_widget_id, type: relation.relation_type,
sortOrder: relation.sort_order, config: { ...relation.config, ...(relationState.get(relation.id) || {}) },
}))
const playthrough = playthroughResult.rows[0]
return { id: level.id, title: level.title, subtitle: level.subtitle, documents, evidence, relations, connections,
viewport: playthrough.viewport, updatedAt: playthrough.updated_at.toISOString(), levelStatus: level.status, editingAllowed: editingEnabled }
}
async function savePlaythrough(client: PoolClient, state: CaseState) {
const playthroughId = `default:${state.id}`
const authored = await client.query<{ id: string }>('SELECT id FROM osint.widgets WHERE level_id = $1', [state.id])
const authoredIds = new Set(authored.rows.map(row => row.id))
const authoredRelations = await client.query<{ id: string }>('SELECT id FROM osint.widget_relations WHERE level_id = $1', [state.id])
const authoredRelationIds = new Set(authoredRelations.rows.map(row => row.id))
await client.query('UPDATE osint.playthroughs SET viewport = $2::jsonb, updated_at = NOW() WHERE id = $1', [playthroughId, JSON.stringify(state.viewport)])
await client.query('DELETE FROM osint.playthrough_widget_state WHERE playthrough_id = $1', [playthroughId])
await client.query('DELETE FROM osint.playthrough_widget_relation_state WHERE playthrough_id = $1', [playthroughId])
await client.query('DELETE FROM osint.playthrough_widget_relations WHERE playthrough_id = $1', [playthroughId])
await client.query('DELETE FROM osint.playthrough_widgets WHERE playthrough_id = $1', [playthroughId])
for (const document of state.documents) {
await client.query(`INSERT INTO osint.playthrough_widget_state (playthrough_id, widget_id, x, y, width, config)
VALUES ($1,$2,0,0,0,$3::jsonb)`, [playthroughId, document.id, JSON.stringify({ title: document.title, publishedAt: document.publishedAt || null, fileType: document.fileType, metadata: document.metadata })])
}
for (const widget of state.evidence) {
if (authoredIds.has(widget.id)) {
await client.query(`INSERT INTO osint.playthrough_widget_state (playthrough_id, widget_id, x, y, width, config)
VALUES ($1, $2, $3, $4, $5, $6::jsonb)`, [playthroughId, widget.id, widget.x, widget.y, widget.width, JSON.stringify({ ...(widget.config || {}), title: widget.title, content: widget.content })])
} else {
await client.query(`INSERT INTO osint.playthrough_widgets
(id, playthrough_id, widget_type, title, content, config, source_widget_id, source_region_key, event_date, x, y, width)
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11,$12)`, [widget.id, playthroughId, widget.type, widget.title, widget.content, JSON.stringify(widget.config || {}),
widget.sourceDocumentId || null, widget.sourceRegionId || null, widget.eventDate || null, widget.x, widget.y, widget.width])
}
}
const fallbackRelations: WidgetRelation[] = state.evidence.flatMap(widget => (widget.containedDocumentIds || []).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index })))
for (const relation of (state.relations || fallbackRelations).filter(relation => authoredRelationIds.has(relation.id))) {
await client.query(`INSERT INTO osint.playthrough_widget_relation_state (playthrough_id, relation_id, config)
VALUES ($1,$2,$3::jsonb)`, [playthroughId, relation.id, JSON.stringify(relation.config || {})])
}
for (const relation of (state.relations || fallbackRelations).filter(relation => !authoredRelationIds.has(relation.id))) {
await client.query(`INSERT INTO osint.playthrough_widget_relations
(id, playthrough_id, from_widget_id, to_widget_id, relation_type, sort_order, config)
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)`,
[relation.id, playthroughId, relation.fromWidgetId, relation.toWidgetId, relation.type, relation.sortOrder || 0, JSON.stringify(relation.config || {})])
}
await client.query('DELETE FROM osint.playthrough_connections WHERE playthrough_id = $1', [playthroughId])
const authoredConnections = await client.query<{ id: string }>('SELECT id FROM osint.level_connections WHERE level_id = $1', [state.id])
const authoredConnectionIds = new Set(authoredConnections.rows.map(row => row.id))
for (const connection of state.connections.filter(c => !authoredConnectionIds.has(c.id))) {
await client.query(`INSERT INTO osint.playthrough_connections (id, playthrough_id, from_widget_id, to_widget_id)
VALUES ($1, $2, $3, $4)`, [connection.id, playthroughId, connection.fromEvidenceId, connection.toEvidenceId])
}
}
async function saveAuthoredLevel(client: PoolClient, state: CaseState) {
await client.query('UPDATE osint.levels SET title = $2, subtitle = $3, updated_at = NOW() WHERE id = $1', [state.id, state.title, state.subtitle])
await client.query('DELETE FROM osint.level_connections WHERE level_id = $1', [state.id])
await client.query('DELETE FROM osint.widget_relations WHERE level_id = $1', [state.id])
await client.query('DELETE FROM osint.widgets WHERE level_id = $1', [state.id])
for (const [index, doc] of state.documents.entries()) {
await client.query(`INSERT INTO osint.widgets (id, level_id, widget_type, title, config, published_at, asset_id, sort_order)
VALUES ($1,$2,'document',$3,$4::jsonb,$5,$6,$7)`, [doc.id, state.id, doc.title, JSON.stringify({ kind: doc.kind, body: doc.body, fileType: doc.fileType, metadata: doc.metadata }), doc.publishedAt || doc.date || null, doc.assetId || null, index])
for (const [regionIndex, region] of doc.regions.entries()) {
await client.query(`INSERT INTO osint.widget_regions (id, document_widget_id, region_key, label, excerpt, event_date, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [`${doc.id}:${region.id}`, doc.id, region.id, region.label, region.excerpt, region.date || null, regionIndex])
}
}
for (const [index, widget] of state.evidence.entries()) {
await client.query(`INSERT INTO osint.widgets
(id, level_id, widget_type, title, content, config, source_widget_id, source_region_key, event_date, x, y, width, sort_order)
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11,$12,$13)`, [widget.id, state.id, widget.type, widget.title, widget.content, JSON.stringify(widget.config || {}),
widget.sourceDocumentId || null, widget.sourceRegionId || null, widget.eventDate || null, widget.x, widget.y, widget.width, index])
}
const fallbackRelations: WidgetRelation[] = state.evidence.flatMap(widget => (widget.containedDocumentIds || []).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index })))
for (const relation of state.relations || fallbackRelations) {
await client.query(`INSERT INTO osint.widget_relations
(id, level_id, from_widget_id, to_widget_id, relation_type, sort_order, config)
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)`,
[relation.id, state.id, relation.fromWidgetId, relation.toWidgetId, relation.type, relation.sortOrder || 0, JSON.stringify(relation.config || {})])
}
for (const connection of state.connections) {
await client.query(`INSERT INTO osint.level_connections (id, level_id, from_widget_id, to_widget_id)
VALUES ($1,$2,$3,$4)`, [connection.id, state.id, connection.fromEvidenceId, connection.toEvidenceId])
}
}
const app = express()
app.disable('x-powered-by') app.disable('x-powered-by')
app.use(cors({ origin: process.env.CORS_ORIGIN || true })) app.use(cors({ origin: process.env.CORS_ORIGIN || true, credentials: true }))
app.use(cookieParser())
app.use(authenticateJwt)
app.use(express.json({ limit: '2mb' })) app.use(express.json({ limit: '2mb' }))
const upload = multer({ const upload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
@@ -194,86 +49,274 @@ 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', schema: 'osint', editingEnabled }) } try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', objectStorage: objectStorage.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) }))
if (process.env.NODE_ENV !== 'production') app.get('/api/dev/admin-session', (req, res) => {
const requestedReturn = String(req.query.returnTo || '/')
const returnTo = requestedReturn.startsWith('/') && !requestedReturn.startsWith('//') ? requestedReturn : '/'
res.cookie('auth_token', createDevelopmentAdminToken(), { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 7 * 24 * 60 * 60 * 1000 })
res.redirect(returnTo)
})
app.get('/api/levels', async (_req, res, next) => { app.get('/api/levels', async (_req, res, next) => {
try { const result = await pool.query('SELECT id, title, subtitle, status, updated_at AS "updatedAt" FROM osint.levels ORDER BY updated_at DESC'); res.json(result.rows) } try { res.json(await levels.listLevels()) }
catch (error) { next(error) } catch (error) { next(error) }
}) })
app.post('/api/levels', async (req, res, next) => { app.get('/api/templates', async (_req, res, next) => {
try { res.json(await levels.listTemplates()) }
catch (error) { next(error) }
})
app.post('/api/templates/:slug/levels', requireAdmin, async (req, res, next) => {
try {
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
const title = String(req.body?.title || '').trim() || undefined
const levelSlug = slug(req.body?.id, `${req.params.slug}-${Date.now()}`)
const level = await levels.instantiateTemplate(String(req.params.slug), { id: levelSlug, title, version: req.body?.version })
level ? res.status(201).json(level) : res.status(404).json({ error: 'Template version not found' })
} catch (error) { next(error) }
})
app.post('/api/levels', requireAdmin, async (req, res, next) => {
try { try {
if (!editingEnabled) return res.status(403).json({ error: 'Level editing is disabled' }) if (!editingEnabled) return res.status(403).json({ error: 'Level editing is disabled' })
const title = String(req.body?.title || 'Untitled Investigation').trim() const title = String(req.body?.title || 'Untitled Investigation').trim()
const id = String(req.body?.id || `level-${Date.now()}`).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-') const id = slug(req.body?.id, `level-${Date.now()}`)
await pool.query('INSERT INTO osint.levels (id, title, subtitle) VALUES ($1, $2, $3)', [id, title, String(req.body?.subtitle || '')]) res.status(201).json(await levels.createLevel({ id, title, subtitle: String(req.body?.subtitle || '') }))
const level = await assembleLevel(id, `default:${id}`, true) } catch (error) { next(error) }
res.status(201).json(level) })
app.post('/api/levels/:id/templates', requireAdmin, async (req, res, next) => {
try {
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
const name = String(req.body?.name || 'Untitled Template').trim()
const templateSlug = slug(req.body?.slug, name)
const template = await levels.saveLevelAsTemplate(String(req.params.id), { slug: templateSlug, name })
template ? res.status(201).json(template) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) } } catch (error) { next(error) }
}) })
app.get('/api/assets/:id', async (req, res, next) => { app.get('/api/assets/:id', async (req, res, next) => {
try { try {
const result = await pool.query<{ original_name: string; mime_type: string; byte_size: string; content: Buffer }>( const asset = await levels.getAsset(req.params.id)
'SELECT original_name, mime_type, byte_size, content FROM osint.assets WHERE id = $1', [req.params.id],
)
const asset = result.rows[0]
if (!asset) return res.status(404).json({ error: 'Asset not found' }) if (!asset) return res.status(404).json({ error: 'Asset not found' })
const inline = asset.mime_type === 'application/pdf' || asset.mime_type.startsWith('image/') || asset.mime_type.startsWith('text/') const inline = asset.mimeType === 'application/pdf' || asset.mimeType.startsWith('image/') || asset.mimeType.startsWith('text/')
res.setHeader('Content-Type', asset.mime_type || 'application/octet-stream') res.setHeader('Content-Type', asset.mimeType || 'application/octet-stream')
res.setHeader('Content-Length', asset.byte_size) res.setHeader('Content-Length', asset.byteSize)
res.setHeader('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(asset.original_name)}`) res.setHeader('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(asset.originalName)}`)
res.setHeader('X-Content-Type-Options', 'nosniff') res.setHeader('X-Content-Type-Options', 'nosniff')
res.send(asset.content) asset.stream.on('error', next)
asset.stream.pipe(res)
} catch (error) { next(error) } } catch (error) { next(error) }
}) })
app.post('/api/levels/:id/documents', upload.single('file'), async (req, res, next) => { app.post('/api/levels/:id/documents', requireAdmin, upload.single('file'), async (req, res, next) => {
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
if (!req.file) return res.status(400).json({ error: 'A file is required' })
const client = await pool.connect()
try { try {
const level = await client.query('SELECT id FROM osint.levels WHERE id = $1', [req.params.id]) if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
if (!level.rows[0]) return res.status(404).json({ error: 'Level not found' }) if (!req.file) return res.status(400).json({ error: 'A file is required' })
const assetId = `asset-${randomUUID()}` const document = await levels.uploadDocument(String(req.params.id), req.file)
const widgetId = `document-${randomUUID()}` document ? res.status(201).json(document) : res.status(404).json({ error: 'Level not found' })
const checksum = createHash('sha256').update(req.file.buffer).digest('hex') } catch (error) { next(error) }
const kind = req.file.mimetype === 'application/pdf' ? 'PDF' : req.file.mimetype.startsWith('image/') ? 'IMAGE' : 'FILE'
const fileType: CaseDocument['fileType'] = req.file.mimetype.startsWith('image/') ? 'image' : req.file.mimetype === 'application/pdf' ? 'pdf' : 'file'
await client.query('BEGIN')
await client.query(`INSERT INTO osint.assets (id, level_id, original_name, mime_type, byte_size, content, checksum_sha256)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [assetId, req.params.id, req.file.originalname, req.file.mimetype || 'application/octet-stream', req.file.size, req.file.buffer, checksum])
await client.query(`INSERT INTO osint.widgets (id, level_id, widget_type, title, config, asset_id, sort_order)
VALUES ($1,$2,'document',$3,$4::jsonb,$5,(SELECT COALESCE(MAX(sort_order),-1)+1 FROM osint.widgets WHERE level_id=$2 AND widget_type='document'))`,
[widgetId, req.params.id, req.file.originalname, JSON.stringify({ kind, body: [], fileType, metadata: {} }), assetId])
await client.query('UPDATE osint.levels SET updated_at = NOW() WHERE id = $1', [req.params.id])
await client.query('COMMIT')
res.status(201).json({ id: widgetId, title: req.file.originalname, kind, fileType, metadata: {}, date: '', body: [], regions: [], assetId,
fileName: req.file.originalname, mimeType: req.file.mimetype, fileSize: req.file.size })
} catch (error) { await client.query('ROLLBACK'); next(error) } finally { client.release() }
}) })
app.get('/api/levels/:id', async (req, res, next) => { app.get('/api/levels/:id', async (req, res, next) => {
try { const level = await assembleLevel(req.params.id, `default:${req.params.id}`, wantsEdit(req)); level ? res.json(level) : res.status(404).json({ error: 'Level not found' }) } try {
catch (error) { next(error) } const level = await levels.getLevel(req.params.id, wantsEdit(req))
level ? res.json(level) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
}) })
app.put('/api/levels/:id', async (req, res, next) => { app.put('/api/levels/:id', async (req, res, next) => {
const state = req.body as CaseState const state = req.body as CaseState
if (!state || state.id !== req.params.id || !Array.isArray(state.evidence) || !Array.isArray(state.connections)) return res.status(400).json({ error: 'Invalid level state' }) if (!state || state.id !== req.params.id || !Array.isArray(state.exhibits) || !Array.isArray(state.views) || !Array.isArray(state.connections)) return res.status(400).json({ error: 'Invalid level state' })
const client = await pool.connect()
try { try {
await client.query('BEGIN') const authorMode = wantsEdit(req)
if (wantsEdit(req)) await saveAuthoredLevel(client, state) await levels.saveLevel(state, authorMode)
else await savePlaythrough(client, state) res.json({ ok: true, mode: authorMode ? 'author' : 'play' })
await client.query('COMMIT'); res.json({ ok: true, mode: wantsEdit(req) ? 'author' : 'play' }) } catch (error) { next(error) }
} catch (error) { await client.query('ROLLBACK'); next(error) } finally { client.release() }
}) })
app.post('/api/levels/:id/reset', async (req, res, next) => { app.post('/api/levels/:id/reset', async (req, res, next) => {
const client = await pool.connect()
try { try {
const playthroughId = `default:${req.params.id}` const level = await levels.resetLevel(req.params.id)
await client.query('BEGIN') level ? res.json(level) : res.status(404).json({ error: 'Level not found' })
await client.query('DELETE FROM osint.playthroughs WHERE id = $1', [playthroughId]) } catch (error) { next(error) }
await client.query('COMMIT') })
const level = await assembleLevel(req.params.id); level ? res.json(level) : res.status(404).json({ error: 'Level not found' })
} catch (error) { await client.query('ROLLBACK'); next(error) } finally { client.release() } // Admin authoring panel: NPC template library and mystery listing. Reads require an
// admin claim; writes additionally require editing to be enabled on this deployment.
function requireEditing(res: express.Response) {
if (!editingEnabled) { res.status(403).json({ error: 'Level editing is disabled' }); return false }
return true
}
app.get('/api/admin/mysteries', requireAdmin, async (_req, res, next) => {
try { res.json(await narrative.listMysteries()) } catch (error) { next(error) }
})
app.get('/api/admin/npcs', requireAdmin, async (_req, res, next) => {
try { res.json(await narrative.listNpcs()) } catch (error) { next(error) }
})
app.post('/api/admin/npcs', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
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 }))
} catch (error) { next(error) }
})
app.patch('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => {
try {
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 })
npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' })
} catch (error) { next(error) }
})
app.delete('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const outcome = await narrative.deleteNpc(String(req.params.id))
if (outcome === 'deleted') return res.json({ ok: true })
res.status(outcome === 'in_use' ? 409 : 404).json({ error: outcome === 'in_use' ? 'NPC is used by a cutscene' : 'NPC not found' })
} catch (error) { next(error) }
})
app.post('/api/admin/npcs/:id/poses', requireAdmin, upload.single('file'), async (req, res, next) => {
try {
if (!requireEditing(res)) return
if (!req.file) return res.status(400).json({ error: 'An image file is required' })
if (!req.body?.poseKey) return res.status(400).json({ error: 'A pose key is required' })
const npc = await narrative.addPose(String(req.params.id), String(req.body.poseKey), req.file)
npc ? res.status(201).json(npc) : res.status(404).json({ error: 'NPC not found' })
} catch (error) { next(error) }
})
app.delete('/api/admin/npcs/:id/poses/:poseKey', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const npc = await narrative.deletePose(String(req.params.id), String(req.params.poseKey))
npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' })
} catch (error) { next(error) }
})
// Story flow graph authoring (Phase 1): nodes, terminals, wiring, entrypoint.
app.get('/api/admin/level-templates', requireAdmin, async (_req, res, next) => {
try { res.json(await storyGraph.listLevelTemplates()) } catch (error) { next(error) }
})
app.get('/api/admin/mysteries/:id/graph', requireAdmin, async (req, res, next) => {
try {
const graph = await storyGraph.getGraph(String(req.params.id))
graph ? res.json(graph) : res.status(404).json({ error: 'Mystery not found' })
} catch (error) { next(error) }
})
app.post('/api/admin/mysteries/:id/nodes', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const nodeType = String(req.body?.nodeType) as StoryNodeType
if (!STORY_NODE_TYPES.includes(nodeType)) return res.status(400).json({ error: 'Unknown node type' })
const node = await storyGraph.createNode(String(req.params.id), { nodeType, xpos: Number(req.body?.xpos) || 0, ypos: Number(req.body?.ypos) || 0, label: req.body?.label })
node ? res.status(201).json(node) : res.status(404).json({ error: 'Mystery not found' })
} catch (error) { next(error) }
})
app.patch('/api/admin/story-nodes/:id', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const node = await storyGraph.updateNode(String(req.params.id), req.body || {})
node ? res.json(node) : res.status(404).json({ error: 'Node not found' })
} catch (error) { next(error) }
})
app.delete('/api/admin/story-nodes/:id', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const ok = await storyGraph.deleteNode(String(req.params.id))
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Node not found' })
} catch (error) { next(error) }
})
app.post('/api/admin/story-nodes/:id/terminals', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
if (!req.body?.terminalKey) return res.status(400).json({ error: 'A terminal key is required' })
const node = await storyGraph.addTerminal(String(req.params.id), { terminalKey: String(req.body.terminalKey), label: req.body?.label })
node ? res.status(201).json(node) : res.status(404).json({ error: 'Node not found' })
} catch (error) { next(error) }
})
app.patch('/api/admin/story-terminals/:id', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const result = await storyGraph.updateTerminal(String(req.params.id), req.body || {})
result.ok ? res.json({ ok: true }) : res.status(result.error === 'Terminal not found' ? 404 : 400).json({ error: result.error })
} catch (error) { next(error) }
})
app.delete('/api/admin/story-terminals/:id', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const ok = await storyGraph.deleteTerminal(String(req.params.id))
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Terminal not found' })
} catch (error) { next(error) }
})
app.put('/api/admin/mysteries/:id/entry', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const result = await storyGraph.setEntryNode(String(req.params.id), req.body?.nodeId ?? null)
result.ok ? res.json({ ok: true }) : res.status(400).json({ error: result.error })
} catch (error) { next(error) }
})
app.post('/api/admin/mysteries/:id/graph', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
if (!req.body?.entry || !Array.isArray(req.body?.nodes)) return res.status(400).json({ error: 'A graph spec needs entry and nodes' })
const result = await storyGraph.authorGraph(String(req.params.id), req.body)
result ? res.status(201).json(result) : res.status(404).json({ error: 'Mystery not found' })
} catch (error) { next(error) }
})
// Utterance sub-graph (dialogue crafter).
app.get('/api/admin/story-nodes/:id/utterances', requireAdmin, async (req, res, next) => {
try { res.json(await storyGraph.listUtterances(String(req.params.id))) } catch (error) { next(error) }
})
app.post('/api/admin/story-nodes/:id/utterances', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const utterer = req.body?.utterer === 'player' ? 'player' : 'npc'
const utterance = await storyGraph.createUtterance(String(req.params.id), { utterer, xpos: Number(req.body?.xpos) || 0, ypos: Number(req.body?.ypos) || 0, text: req.body?.text })
utterance ? res.status(201).json(utterance) : res.status(404).json({ error: 'Node not found' })
} catch (error) { next(error) }
})
app.patch('/api/admin/utterances/:id', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const result = await storyGraph.updateUtterance(String(req.params.id), req.body || {})
result.ok ? res.json({ ok: true }) : res.status(result.error === 'Utterance not found' ? 404 : 400).json({ error: result.error })
} catch (error) { next(error) }
})
app.delete('/api/admin/utterances/:id', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const ok = await storyGraph.deleteUtterance(String(req.params.id))
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Utterance not found' })
} catch (error) { next(error) }
})
// Narrative authoring: create a mystery and its NPC cast. The flow (cutscenes,
// dialogue, levels) lives in the story graph, seeded separately.
app.post('/api/mysteries', requireAdmin, async (req, res, next) => {
try {
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
const body = req.body || {}
if (!body.slug || !body.title) return res.status(400).json({ error: 'A mystery requires slug and title' })
const created = await narrative.authorMystery({ slug: slug(body.slug, body.slug), title: String(body.title), cast: Array.isArray(body.cast) ? body.cast : [] })
res.status(201).json(created)
} catch (error) { next(error) }
})
// New Game creates a playthrough bound to the caller's identity.
app.post('/api/playthroughs', async (req, res, next) => {
try {
const result = await narrative.createPlaythrough(resolveUserId(req), req.body?.mystery ? slug(req.body.mystery, req.body.mystery) : undefined)
result ? res.status(201).json(result) : res.status(404).json({ error: 'No mystery available' })
} catch (error) { next(error) }
})
app.get('/api/playthroughs/current', async (req, res, next) => {
try {
const result = await narrative.getCurrentPlaythrough(resolveUserId(req))
result ? res.json(result) : res.status(204).end()
} catch (error) { next(error) }
})
// Advance the playthrough through the story graph (follows a terminal; auto-skips
// gates; instantiates the board when entering a level node).
app.post('/api/playthroughs/:id/advance', async (req, res, next) => {
try {
const result = await narrative.advancePlaythrough(resolveUserId(req), String(req.params.id), req.body?.terminalKey)
result.ok ? res.json(result.state ?? null) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
} catch (error) { next(error) }
}) })
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
@@ -285,6 +328,9 @@ app.use((error: unknown, _req: express.Request, res: express.Response, _next: ex
const here = path.dirname(fileURLToPath(import.meta.url)); const dist = path.resolve(here, '..', 'dist') const here = path.dirname(fileURLToPath(import.meta.url)); const dist = path.resolve(here, '..', 'dist')
if (fs.existsSync(dist)) { app.use(express.static(dist)); app.get('*splat', (_req, res) => res.sendFile(path.join(dist, 'index.html'))) } if (fs.existsSync(dist)) { app.use(express.static(dist)); app.get('*splat', (_req, res) => res.sendFile(path.join(dist, 'index.html'))) }
const port = Number(process.env.PORT || 8787) const port = Number(process.env.PORT || 8787)
const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`)) export const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`))
async function shutdown() { server.close(); await pool.end(); process.exit(0) } async function shutdown() { server.close(); await pool.end(); process.exit(0) }
process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown) if (!process.env.VITEST && process.env.OSINT_MANAGED_SERVER !== 'true') {
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
}
+490
View File
@@ -0,0 +1,490 @@
import { createHash, randomUUID } from 'node:crypto'
import { Readable } from 'node:stream'
import type { Pool, PoolClient } from 'pg'
import type { BoardView, BriefConcept, CaseDocument, CaseState, Evidence, Exhibit, ExhibitRelation, OrganizationKind, PartyKind, SourceFileType } from '../src/types.js'
import { isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js'
import { clearBoard, cloneBoard } from './boardClone.js'
import type { ObjectStorage } from './objectStorage.js'
export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number }
export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer | null; storage_provider: 'postgres' | 's3'; object_key: string | null }
export type AssetResponse = { originalName: string; mimeType: string; byteSize: number; stream: NodeJS.ReadableStream }
export type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string }
export interface LevelRepository {
listLevels(): Promise<unknown[]>
createLevel(input: { id: string; title: string; subtitle: string }): Promise<CaseState>
listTemplates(): Promise<TemplateSummary[]>
instantiateTemplate(templateSlug: string, input: { id: string; title?: string; version?: number }): Promise<CaseState | null>
saveLevelAsTemplate(levelId: string, input: { slug: string; name: string }): Promise<TemplateSummary | null>
getLevel(levelId: string, authorMode?: boolean): Promise<CaseState | null>
saveLevel(state: CaseState, authorMode: boolean): Promise<void>
resetLevel(levelId: string): Promise<CaseState | null>
getAsset(assetId: string): Promise<AssetResponse | null>
uploadDocument(levelId: string, file: UploadedDocument): Promise<CaseDocument | null>
}
type LevelRow = {
id: string; slug: string; board_id: string; title: string; subtitle: string; status: string
viewport_x: number; viewport_y: number; viewport_zoom: number; updated_at: Date; revision: string
source_template_version_id: string | null
}
type ExhibitRow = {
id: string; exhibit_type_id: Exhibit['type']; xpos: number; ypos: number; width: number; height: number; rotation: number; z_index: number; hidden: boolean
title: string; content: string; is_open: boolean | null; document_type_id: SourceFileType | null
asset_id: string | null; published_at: Date | null; occurred_at: Date | null
original_name: string | null; mime_type: string | null; byte_size: string | null
source_document_id: string | null; source_region_key: string | null
party_kind: PartyKind | null; organization_kind: OrganizationKind | null
}
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
function requireUuid(value: string, label: string) {
if (!uuidPattern.test(value)) throw new Error(`${label} must be a UUID`)
return value
}
function timestamp(value: string | undefined) {
if (!value) return null
const date = new Date(value)
return Number.isFinite(date.getTime()) ? date.toISOString() : null
}
function documentType(document: CaseDocument): SourceFileType {
const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
return allowed.includes(document.fileType) ? document.fileType : 'file'
}
export function createLevelRepository(pool: Pool, editingEnabled: boolean, objectStorage: ObjectStorage): LevelRepository {
async function findLevel(client: Pool | PoolClient, slug: string, lock = false) {
const result = await client.query<LevelRow>(`SELECT l.id,l.slug,l.board_id,l.title,l.subtitle,l.status,
l.viewport_x,l.viewport_y,l.viewport_zoom,l.updated_at,l.source_template_version_id,b.revision::text
FROM osint.levels l JOIN osint.boards b ON b.id=l.board_id WHERE l.slug = $1${lock ? ' FOR UPDATE OF l,b' : ''}`, [slug])
return result.rows[0] || null
}
async function createDefaultBoardViews(client: PoolClient, boardId: string) {
const viewId = randomUUID()
await client.query(`INSERT INTO osint.board_views (id,board_id,view_type_id,placement_mode,dock_edge,height)
VALUES ($1,$2,'timeline','docked','bottom',112)`, [viewId, boardId])
await client.query("INSERT INTO osint.timeline_views (view_id,range_mode) VALUES ($1,'auto')", [viewId])
}
async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> {
const level = await findLevel(pool, slug)
if (!level) return null
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult] = await Promise.all([
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.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
f.is_open, d.document_type_id, d.asset_id, d.published_at, ev.occurred_at,
p.party_kind, op.organization_kind,
a.original_name, a.mime_type, a.byte_size,
s.source_document_exhibit_id AS source_document_id, sr.region_key AS source_region_key
FROM osint.exhibits e
LEFT JOIN osint.folder_exhibits f ON f.exhibit_id = e.id
LEFT JOIN osint.document_exhibits d ON d.exhibit_id = e.id
LEFT JOIN osint.note_exhibits n ON n.exhibit_id = e.id
LEFT JOIN osint.event_exhibits ev ON ev.exhibit_id = e.id
LEFT JOIN osint.party_exhibits p ON p.exhibit_id = e.id
LEFT JOIN osint.organization_parties op ON op.exhibit_id = e.id
LEFT JOIN osint.assets a ON a.id = d.asset_id
LEFT JOIN osint.exhibit_sources s ON s.exhibit_id = e.id
LEFT JOIN osint.document_regions sr ON sr.id = s.source_region_id
WHERE e.board_id = $1 ORDER BY e.z_index, e.created_at, e.id`, [level.board_id]),
pool.query<{ document_exhibit_id: string; content: string }>(
`SELECT b.document_exhibit_id, b.content FROM osint.document_content_blocks b
JOIN osint.exhibits e ON e.id = b.document_exhibit_id WHERE e.board_id = $1 ORDER BY b.document_exhibit_id, b.sort_order`, [level.board_id]),
pool.query<{ document_exhibit_id: string; region_key: string; label: string; excerpt: string; occurred_at: Date | null }>(
`SELECT r.document_exhibit_id, r.region_key, r.label, r.excerpt, r.occurred_at FROM osint.document_regions r
JOIN osint.exhibits e ON e.id = r.document_exhibit_id WHERE e.board_id = $1 ORDER BY r.document_exhibit_id, r.sort_order`, [level.board_id]),
pool.query<{ folder_exhibit_id: string; child_exhibit_id: string; sort_order: number; xpos: number; ypos: number }>(
`SELECT m.folder_exhibit_id, m.child_exhibit_id, m.sort_order, child.xpos, child.ypos
FROM osint.folder_memberships m JOIN osint.exhibits child ON child.id = m.child_exhibit_id
WHERE m.board_id = $1 ORDER BY m.sort_order, m.child_exhibit_id`, [level.board_id]),
pool.query<{ id: string; from_exhibit_id: string; to_exhibit_id: string; label: string | null; tightness: number; tag_style: 'luggage' | 'compact'; tag_position_percent: number; tag_lateral_offset: number }>(
`SELECT id, from_exhibit_id, to_exhibit_id, label, tightness, tag_style, tag_position_percent, tag_lateral_offset FROM osint.exhibit_connections WHERE board_id = $1 ORDER BY created_at, id`, [level.board_id]),
pool.query<{ exhibit_id: string; field_key: string; value: string }>(
`SELECT v.exhibit_id, f.field_key, v.value FROM osint.exhibit_metadata_text_values v
JOIN osint.metadata_fields f ON f.id = v.field_id WHERE f.board_id = $1 ORDER BY f.field_key`, [level.board_id]),
pool.query<{ event_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
`SELECT event_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.event_evidence WHERE board_id=$1
ORDER BY event_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
pool.query<{ party_exhibit_id: string; alias: string }>(
`SELECT a.party_exhibit_id,a.alias FROM osint.party_aliases a JOIN osint.exhibits e ON e.id=a.party_exhibit_id
WHERE e.board_id=$1 ORDER BY a.party_exhibit_id,a.sort_order,a.id`, [level.board_id]),
pool.query<{ party_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
`SELECT party_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.party_evidence WHERE board_id=$1
ORDER BY party_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
pool.query<{ body: string }>('SELECT body FROM osint.level_briefs WHERE board_id=$1', [level.board_id]),
pool.query<{ id: string; label: string; context_text: string; expected_party_kind: PartyKind | null; resolved_party_exhibit_id: string | null }>(
`SELECT id,label,context_text,expected_party_kind,resolved_party_exhibit_id FROM osint.brief_concepts
WHERE board_id=$1 ORDER BY sort_order,id`, [level.board_id]),
pool.query<{ id: string; view_type_id: 'timeline'; placement_mode: 'docked' | 'canvas' | 'window'; dock_edge: 'top' | 'right' | 'bottom' | 'left' | null; xpos: number | null; ypos: number | null; width: number | null; height: number; z_index: number; visible: boolean; range_mode: 'auto' | 'fixed'; range_start: string | null; range_end: string | null }>(
`SELECT v.id,v.view_type_id,v.placement_mode,v.dock_edge,v.xpos,v.ypos,v.width,v.height,v.z_index,v.visible,
t.range_mode,t.range_start::text,t.range_end::text FROM osint.board_views v
JOIN osint.timeline_views t ON t.view_id=v.id WHERE v.board_id=$1 ORDER BY v.z_index,v.created_at`, [level.board_id]),
])
const blocks = new Map<string, string[]>()
for (const row of blocksResult.rows) blocks.set(row.document_exhibit_id, [...(blocks.get(row.document_exhibit_id) || []), row.content])
const regions = new Map<string, CaseDocument['regions']>()
for (const row of regionsResult.rows) regions.set(row.document_exhibit_id, [...(regions.get(row.document_exhibit_id) || []), {
id: row.region_key, label: row.label, excerpt: row.excerpt, date: row.occurred_at?.toISOString(),
}])
const metadata = new Map<string, Record<string, string>>()
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[]>()
for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias])
const relations: ExhibitRelation[] = [
...membershipsResult.rows.map(row => ({ id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromExhibitId: row.folder_exhibit_id,
toExhibitId: row.child_exhibit_id, type: 'contains' as const, sortOrder: row.sort_order })),
...eventEvidenceResult.rows.map(row => ({ id: `supports:${row.event_exhibit_id}:${row.evidence_exhibit_id}`, fromExhibitId: row.event_exhibit_id,
toExhibitId: row.evidence_exhibit_id, type: 'supports' as const, sortOrder: row.sort_order, note: row.note || undefined })),
...partyEvidenceResult.rows.map(row => ({ id: `concerns:${row.party_exhibit_id}:${row.evidence_exhibit_id}`, fromExhibitId: row.party_exhibit_id,
toExhibitId: row.evidence_exhibit_id, type: 'concerns' as const, sortOrder: row.sort_order, note: row.note || undefined })),
...exhibitsResult.rows.flatMap(row => row.source_document_id ? [{ id: `source:${row.id}`, fromExhibitId: row.id, toExhibitId: row.source_document_id,
type: 'source' as const, sourceRegionId: row.source_region_key || undefined, sortOrder: 0 }] : []),
]
const base = (row: ExhibitRow) => ({ id: row.id, title: row.title, x: row.xpos, y: row.ypos, width: row.width, height: row.height,
rotation: row.rotation, zIndex: row.z_index, hidden: row.hidden })
const documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => {
const type = row.document_type_id || 'file'
const publishedAt = row.published_at?.toISOString()
return { ...base(row), type: 'document', publishedAt,
body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined,
fileName: row.original_name || undefined, mimeType: row.mime_type || undefined,
fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, metadata: metadata.get(row.id) || {} }
})
const evidence: Evidence[] = []
for (const row of exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document' && !row.hidden)) {
const common = { ...base(row), title: row.title, content: row.content }
if (row.exhibit_type_id === 'folder') evidence.push({ ...common, type:'folder', isOpen:Boolean(row.is_open) })
else if (row.exhibit_type_id === 'event') evidence.push({ ...common, type:'event', eventDate:row.occurred_at?.toISOString() })
else if (row.exhibit_type_id === 'party') evidence.push({ ...common, type:'party', partyKind:row.party_kind || 'person', organizationKind:row.organization_kind || undefined, aliases:aliases.get(row.id) || [] })
else if (row.exhibit_type_id === 'note') evidence.push({ ...common, type:'note' })
else throw new Error(`Unsupported exhibit type ${row.exhibit_type_id}`)
}
const views: BoardView[] = viewsResult.rows.map(row => ({ id: row.id, type: 'timeline', visible: row.visible, zIndex: row.z_index,
placement: row.placement_mode === 'docked'
? { mode: 'docked', dockEdge: row.dock_edge || 'bottom', size: row.height }
: { mode: row.placement_mode, x: row.xpos || 0, y: row.ypos || 0, width: row.width || 900, height: row.height },
rangeMode: row.range_mode, range: row.range_start && row.range_end ? { start: row.range_start, end: row.range_end } : undefined }))
const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text,
...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined }))
return { id: level.slug, title: level.title, subtitle: level.subtitle, exhibits: [...documents, ...evidence], relations,
connections: connectionsResult.rows.map(row => ({ id: row.id, fromExhibitId: row.from_exhibit_id, toExhibitId: row.to_exhibit_id,
label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style,
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
views, revision: Number(level.revision),
brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
sourceTemplateVersionId: level.source_template_version_id || undefined }
}
async function templateSummary(slug: string): Promise<TemplateSummary | null> {
const result = await pool.query<{
id: string; slug: string; name: string; current_version: number; version_count: number; updated_at: Date
}>(`SELECT t.id,t.slug,t.name,current.version AS current_version,COUNT(v.id)::int AS version_count,t.updated_at
FROM osint.level_templates t
JOIN osint.level_template_versions current ON current.id=t.current_version_id
JOIN osint.level_template_versions v ON v.template_id=t.id
WHERE t.slug=$1 GROUP BY t.id,current.version`, [slug])
const row = result.rows[0]
return row ? { id: row.id, slug: row.slug, name: row.name, currentVersion: row.current_version,
versionCount: row.version_count, updatedAt: row.updated_at.toISOString() } : null
}
async function replaceBoard(client: PoolClient, level: LevelRow, state: CaseState) {
if (!Array.isArray(state.exhibits) || !Array.isArray(state.views)) throw new Error('Level state must contain exhibits and views')
const documents = state.exhibits.filter(isDocumentExhibit)
const evidence = state.exhibits.filter((exhibit): exhibit is Evidence => !isDocumentExhibit(exhibit))
const documentIds = new Set(documents.map(document => requireUuid(document.id, 'Document id')))
const evidenceIds = new Set(evidence.map(exhibit => requireUuid(exhibit.id, 'Exhibit id')))
const allIds = state.exhibits.map(exhibit => exhibit.id)
if (new Set(allIds).size !== allIds.length) throw new Error('Exhibit ids must be unique within a board')
const expectedConceptKinds = new Map((await client.query<{ id: string; expected_party_kind: PartyKind | null }>(
'SELECT id,expected_party_kind FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])).rows.map(row => [row.id, row.expected_party_kind]))
await client.query(`UPDATE osint.levels SET title=$2, subtitle=$3, viewport_x=$4, viewport_y=$5, viewport_zoom=$6,
updated_at=NOW() WHERE id=$1`, [level.id, state.title, state.subtitle, state.viewport.x, state.viewport.y, state.viewport.zoom])
await client.query('UPDATE osint.boards SET revision=revision+1, updated_at=NOW() WHERE id=$1', [level.board_id])
await client.query('DELETE FROM osint.exhibit_connections WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.folder_memberships WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.event_evidence WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.party_evidence WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.party_relationships WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.exhibit_sources WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)', [level.board_id])
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [level.board_id])
for (const table of ['folder_exhibits', 'image_documents', 'note_exhibits', 'event_exhibits', 'person_parties', 'organization_parties', 'party_exhibits', 'document_exhibits']) {
await client.query(`DELETE FROM osint.${table} WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)`, [level.board_id])
}
if (allIds.length) await client.query('DELETE FROM osint.exhibits WHERE board_id=$1 AND NOT (id = ANY($2::uuid[]))', [level.board_id, allIds])
else await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [level.board_id])
for (const exhibit of state.exhibits) {
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,rotation,z_index,hidden)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=$7,rotation=$8,z_index=$9,hidden=$10,updated_at=NOW()`,
[exhibit.id, level.board_id, exhibit.type, exhibit.x, exhibit.y, exhibit.width, exhibit.height, exhibit.rotation, exhibit.zIndex, exhibit.hidden])
}
for (const document of documents) {
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [document.id, documentType(document), document.assetId || null, document.title,
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null])
if (document.fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [document.id])
for (const [sortOrder, content] of document.body.entries()) await client.query(
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,$3,$4)', [randomUUID(), document.id, sortOrder, content])
for (const [sortOrder, region] of document.regions.entries()) await client.query(
`INSERT INTO osint.document_regions (id,document_exhibit_id,region_key,label,excerpt,occurred_at,sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [randomUUID(), document.id, region.id, region.label, region.excerpt, timestamp(region.date), sortOrder])
}
for (const exhibit of evidence) {
if (isFolderExhibit(exhibit)) await client.query(
'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)',
[exhibit.id, exhibit.title, exhibit.content, exhibit.isOpen])
if (exhibit.type === 'note') await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)', [exhibit.id, exhibit.title, exhibit.content])
if (isEventExhibit(exhibit)) await client.query(
'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)',
[exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate)])
if (isPartyExhibit(exhibit)) {
const partyKind = exhibit.partyKind
await client.query('INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)',
[exhibit.id, partyKind, exhibit.title, exhibit.content])
if (partyKind === 'person') await client.query('INSERT INTO osint.person_parties (exhibit_id) VALUES ($1)', [exhibit.id])
else await client.query('INSERT INTO osint.organization_parties (exhibit_id,organization_kind) VALUES ($1,$2)',
[exhibit.id, exhibit.organizationKind || 'business'])
for (const [sortOrder, alias] of (exhibit.aliases || []).filter(Boolean).entries()) await client.query(
'INSERT INTO osint.party_aliases (id,party_exhibit_id,alias,sort_order) VALUES ($1,$2,$3,$4)', [randomUUID(), exhibit.id, alias, sortOrder])
}
}
for (const relation of state.relations) {
if (!allIds.includes(relation.fromExhibitId) || !allIds.includes(relation.toExhibitId) || relation.fromExhibitId === relation.toExhibitId) throw new Error('Relation references an unknown or identical exhibit')
if (relation.type === 'contains') await client.query(`INSERT INTO osint.folder_memberships
(board_id,folder_exhibit_id,child_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
[level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder])
if (relation.type === 'supports') await client.query(`INSERT INTO osint.event_evidence
(board_id,event_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
[level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder, relation.note || null])
if (relation.type === 'concerns') await client.query(`INSERT INTO osint.party_evidence
(board_id,party_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
[level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder, relation.note || null])
if (relation.type === 'source') {
if (!documentIds.has(relation.toExhibitId)) throw new Error('Exhibit source must reference a document')
let regionId: string | null = null
if (relation.sourceRegionId) {
const region = await client.query<{ id: string }>(
'SELECT id FROM osint.document_regions WHERE document_exhibit_id=$1 AND region_key=$2', [relation.toExhibitId, relation.sourceRegionId])
regionId = region.rows[0]?.id || null
}
await client.query('INSERT INTO osint.exhibit_sources (exhibit_id,source_document_exhibit_id,source_region_id) VALUES ($1,$2,$3)',
[relation.fromExhibitId, relation.toExhibitId, regionId])
}
}
for (const connection of state.connections) {
requireUuid(connection.id, 'Connection id')
if (!allIds.includes(connection.fromExhibitId) || !allIds.includes(connection.toExhibitId) || connection.fromExhibitId === connection.toExhibitId) throw new Error('Connection references an unknown or identical exhibit')
const tightness = Math.max(0, Math.min(100, Math.round(Number(connection.tightness ?? 65))))
const tagStyle = connection.tagStyle === 'compact' ? 'compact' : 'luggage'
const tagPosition = Math.max(0, Math.min(100, Math.round(Number(connection.tagPosition ?? 50))))
const lateralLimit = Math.round(10 + (100 - tightness) * .6)
const tagOffset = Math.max(-lateralLimit, Math.min(lateralLimit, Math.round(Number(connection.tagOffset ?? 0))))
await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style,tag_position_percent,tag_lateral_offset)
VALUES ($1,$2,'thread',$3,$4,$5,$6,$7,$8,$9)`, [connection.id, level.board_id, connection.fromExhibitId, connection.toExhibitId, connection.label?.trim() || null, tightness, tagStyle, tagPosition, tagOffset])
}
for (const view of state.views) {
requireUuid(view.id, 'Board view id')
const placement = view.placement
const docked = placement.mode === 'docked'
await client.query(`INSERT INTO osint.board_views
(id,board_id,view_type_id,placement_mode,dock_edge,xpos,ypos,width,height,z_index,visible)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, [view.id, level.board_id, view.type, placement.mode,
placement.mode === 'docked' ? placement.dockEdge : null, placement.mode === 'docked' ? null : placement.x, placement.mode === 'docked' ? null : placement.y,
placement.mode === 'docked' ? null : placement.width, placement.mode === 'docked' ? placement.size : placement.height, view.zIndex, view.visible])
if (view.type === 'timeline') {
if (view.rangeMode === 'fixed' && (!view.range || !timestamp(view.range.start) || !timestamp(view.range.end) || Date.parse(view.range.end) <= Date.parse(view.range.start))) throw new Error('Timeline end must be after timeline start')
await client.query(`INSERT INTO osint.timeline_views (view_id,range_mode,range_start,range_end) VALUES ($1,$2,$3,$4)`,
[view.id, view.rangeMode, view.rangeMode === 'fixed' ? view.range!.start : null, view.rangeMode === 'fixed' ? view.range!.end : null])
}
}
const fields = new Map<string, string>()
for (const document of documents) for (const key of Object.keys(document.metadata || {})) {
if (!fields.has(key)) {
const fieldId = randomUUID(); fields.set(key, fieldId)
await client.query(`INSERT INTO osint.metadata_fields (id,board_id,field_key,label,value_type) VALUES ($1,$2,$3,$3,'text')`, [fieldId, level.board_id, key])
}
await client.query('INSERT INTO osint.exhibit_metadata_text_values (exhibit_id,field_id,value) VALUES ($1,$2,$3)',
[document.id, fields.get(key), document.metadata[key]])
}
const brief = state.brief || { body: '', concepts: [] }
await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [level.board_id, brief.body || ''])
for (const [sortOrder, concept] of brief.concepts.entries()) {
requireUuid(concept.id, 'Brief concept id')
if (concept.resolvedPartyExhibitId && !evidenceIds.has(concept.resolvedPartyExhibitId)) throw new Error('Concept resolution references an unknown party')
const expected = concept.expectedPartyKind || expectedConceptKinds.get(concept.id) || null
await client.query(`INSERT INTO osint.brief_concepts
(id,board_id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[concept.id, level.board_id, concept.label, concept.context, sortOrder, expected, concept.resolvedPartyExhibitId || null])
}
}
return {
async listLevels() {
const result = await pool.query(`SELECT slug AS id, title, subtitle, status, updated_at AS "updatedAt"
FROM osint.levels ORDER BY updated_at DESC`)
return result.rows
},
async createLevel(input) {
const client = await pool.connect()
try {
await client.query('BEGIN')
const boardId = randomUUID(); const levelId = randomUUID()
await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'level')`, [boardId])
await client.query(`INSERT INTO osint.levels (id,slug,board_id,title,subtitle) VALUES ($1,$2,$3,$4,$5)`,
[levelId, input.id, boardId, input.title, input.subtitle])
await createDefaultBoardViews(client, boardId)
await client.query('COMMIT')
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
return (await assembleLevel(input.id))!
},
async listTemplates() {
const result = await pool.query<{
id: string; slug: string; name: string; current_version: number; version_count: number; updated_at: Date
}>(`SELECT t.id,t.slug,t.name,current.version AS current_version,COUNT(v.id)::int AS version_count,t.updated_at
FROM osint.level_templates t
JOIN osint.level_template_versions current ON current.id=t.current_version_id
JOIN osint.level_template_versions v ON v.template_id=t.id
GROUP BY t.id,current.version ORDER BY t.updated_at DESC,t.slug`)
return result.rows.map(row => ({ id: row.id, slug: row.slug, name: row.name, currentVersion: row.current_version,
versionCount: row.version_count, updatedAt: row.updated_at.toISOString() }))
},
async instantiateTemplate(templateSlug, input) {
const client = await pool.connect()
try {
await client.query('BEGIN')
const version = await client.query<{ id: string; board_id: string; title: string; subtitle: string }>(
`SELECT v.id,v.board_id,v.title,v.subtitle FROM osint.level_templates t
JOIN osint.level_template_versions v ON v.template_id=t.id
WHERE t.slug=$1 AND (($2::int IS NULL AND v.id=t.current_version_id) OR v.version=$2)
FOR SHARE OF t,v`, [templateSlug, input.version ?? null])
const source = version.rows[0]
if (!source) { await client.query('ROLLBACK'); return null }
const boardId = randomUUID(); const levelId = randomUUID()
await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'level')`, [boardId])
await client.query(`INSERT INTO osint.levels
(id,slug,board_id,source_template_version_id,title,subtitle) VALUES ($1,$2,$3,$4,$5,$6)`,
[levelId, input.id, boardId, source.id, input.title || source.title, source.subtitle])
await cloneBoard(client, source.board_id, boardId)
await client.query('COMMIT')
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
return assembleLevel(input.id)
},
async saveLevelAsTemplate(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 }
await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [input.slug])
let template = await client.query<{ id: string }>('SELECT id FROM osint.level_templates WHERE slug=$1 FOR UPDATE', [input.slug])
let templateId = template.rows[0]?.id
if (!templateId) {
templateId = randomUUID()
await client.query('INSERT INTO osint.level_templates (id,slug,name) VALUES ($1,$2,$3)', [templateId, input.slug, input.name])
template = await client.query<{ id: string }>('SELECT id FROM osint.level_templates WHERE id=$1 FOR UPDATE', [templateId])
}
const versionResult = await client.query<{ version: number }>(
'SELECT COALESCE(MAX(version),0)::int+1 AS version FROM osint.level_template_versions WHERE template_id=$1', [templateId])
const version = versionResult.rows[0].version
const boardId = randomUUID(); const versionId = randomUUID()
await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'template_version')`, [boardId])
await client.query(`INSERT INTO osint.level_template_versions
(id,template_id,version,board_id,title,subtitle,created_from_level_id) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[versionId, templateId, version, boardId, level.title, level.subtitle, level.id])
await cloneBoard(client, level.board_id, boardId)
await client.query('UPDATE osint.level_templates SET name=$2,current_version_id=$3,updated_at=NOW() WHERE id=$1',
[templateId, input.name, versionId])
await client.query('COMMIT')
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
return templateSummary(input.slug)
},
getLevel(levelId, authorMode = false) { return assembleLevel(levelId, authorMode) },
async saveLevel(state) {
const client = await pool.connect()
try {
await client.query('BEGIN')
const level = await findLevel(client, state.id, true)
if (!level) throw new Error('Level not found')
await replaceBoard(client, level, state)
await client.query('COMMIT')
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
},
async resetLevel(levelId) {
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 }
if (level.source_template_version_id) {
const version = await client.query<{ board_id: string; title: string; subtitle: string }>(
'SELECT board_id,title,subtitle FROM osint.level_template_versions WHERE id=$1 FOR SHARE', [level.source_template_version_id])
const source = version.rows[0]
if (!source) throw new Error('Source template version not found')
await clearBoard(client, level.board_id)
await cloneBoard(client, source.board_id, level.board_id)
await client.query(`UPDATE osint.levels SET title=$2,subtitle=$3,viewport_x=0,viewport_y=28,viewport_zoom=0.7,updated_at=NOW()
WHERE id=$1`, [level.id, source.title, source.subtitle])
}
await client.query('COMMIT')
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
return assembleLevel(levelId)
},
async getAsset(assetId) {
if (!uuidPattern.test(assetId)) return null
const result = await pool.query<AssetRecord>('SELECT original_name,mime_type,byte_size,content,storage_provider,object_key FROM osint.assets WHERE id=$1', [assetId])
const asset = result.rows[0]
if (!asset) return null
if (asset.storage_provider === 'postgres') {
if (!asset.content) throw new Error(`PostgreSQL asset ${assetId} has no content`)
return { originalName: asset.original_name, mimeType: asset.mime_type, byteSize: Number(asset.byte_size), stream: Readable.from(asset.content) }
}
if (!asset.object_key) throw new Error(`Object asset ${assetId} has no 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
},
async uploadDocument(levelId, file) {
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 candidateAssetId = randomUUID(); const exhibitId = randomUUID()
const checksum = createHash('sha256').update(file.buffer).digest('hex')
let assetId = (await client.query<{ id: string }>('SELECT id FROM osint.assets WHERE checksum_sha256=$1 AND byte_size=$2 FOR SHARE', [checksum,file.size])).rows[0]?.id
if (!assetId) {
const objectKey = `assets/${checksum.slice(0,2)}/${checksum}`
const stored = await objectStorage.putObject(objectKey,file.buffer,file.mimetype || 'application/octet-stream')
const asset = await client.query<{ id: string }>(`INSERT INTO osint.assets
(id,original_name,mime_type,byte_size,content,checksum_sha256,storage_provider,storage_bucket,object_key,etag)
VALUES ($1,$2,$3,$4,NULL,$5,'s3',$6,$7,$8)
ON CONFLICT (checksum_sha256,byte_size) DO UPDATE SET checksum_sha256=EXCLUDED.checksum_sha256 RETURNING id`,
[candidateAssetId,file.originalname,file.mimetype || 'application/octet-stream',file.size,checksum,objectStorage.bucket,objectKey,stored.etag || null])
assetId = asset.rows[0].id
}
const fileType: SourceFileType = file.mimetype.startsWith('image/') ? 'image' : file.mimetype === 'application/pdf' ? 'pdf' : file.mimetype.startsWith('text/') ? 'text' : 'file'
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])
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)`,
[exhibitId, fileType, assetId, file.originalname])
if (fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [exhibitId])
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
await client.query('COMMIT')
return { id: exhibitId,type:'document',title:file.originalname,x:100,y:100,width:174,height:145,rotation:0,zIndex:0,hidden:false,
fileType,metadata:{},body:[],regions:[],assetId,fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size }
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
},
}
}
+2 -40
View File
@@ -1,11 +1,8 @@
import 'dotenv/config' import 'dotenv/config'
import { createHash } from 'node:crypto'
import fs from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import pg from 'pg' import { runMigrations } from './migrations.js'
const { Client } = pg
const databaseUrl = process.env.DATABASE_URL const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) { if (!databaseUrl) {
console.error('DATABASE_URL is required') console.error('DATABASE_URL is required')
@@ -13,39 +10,4 @@ if (!databaseUrl) {
} }
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations') const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
const client = new Client({ connectionString: databaseUrl }) await runMigrations(databaseUrl, migrationsDir)
await client.connect()
try {
await client.query('CREATE SCHEMA IF NOT EXISTS osint')
await client.query(`CREATE TABLE IF NOT EXISTS osint.schema_migrations (
name TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`)
const files = (await fs.readdir(migrationsDir)).filter(name => /^\d+.*\.sql$/.test(name)).sort()
for (const name of files) {
const sql = await fs.readFile(path.join(migrationsDir, name), 'utf8')
const checksum = createHash('sha256').update(sql).digest('hex')
const existing = await client.query<{ checksum: string }>('SELECT checksum FROM osint.schema_migrations WHERE name = $1', [name])
if (existing.rows[0]) {
if (existing.rows[0].checksum !== checksum) throw new Error(`Applied migration was modified: ${name}`)
console.log(`skip ${name}`)
continue
}
await client.query('BEGIN')
try {
await client.query(sql)
await client.query('INSERT INTO osint.schema_migrations (name, checksum) VALUES ($1, $2)', [name, checksum])
await client.query('COMMIT')
console.log(`apply ${name}`)
} catch (error) {
await client.query('ROLLBACK')
throw error
}
}
console.log('OSINT migrations complete.')
} finally {
await client.end()
}
+64
View File
@@ -0,0 +1,64 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { runMigrations } from './migrations.js'
const { Client } = pg
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
const suite = baseDatabaseUrl ? describe : describe.skip
const databaseName = `osint_test_${process.pid}_${Date.now()}`
let adminClient: InstanceType<typeof Client>
let testDatabaseUrl = ''
suite('PostgreSQL migrations', () => {
beforeAll(async () => {
const adminUrl = new URL(baseDatabaseUrl!)
adminUrl.pathname = '/postgres'
adminClient = new Client({ connectionString: adminUrl.toString() })
await adminClient.connect()
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
const testUrl = new URL(baseDatabaseUrl!)
testUrl.pathname = `/${databaseName}`
testDatabaseUrl = testUrl.toString()
})
afterAll(async () => {
if (!adminClient) return
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
await adminClient.end()
})
it('applies every migration transactionally and is idempotent', async () => {
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
const firstRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(22)
const client = new Client({ connectionString: testDatabaseUrl })
await client.connect()
const tables = await client.query<{ table_name: string }>(`SELECT table_name FROM information_schema.tables WHERE table_schema = 'osint'`)
const tableNames = tables.rows.map(row => row.table_name)
expect(tableNames).toEqual(expect.arrayContaining([
'boards', 'levels', 'level_templates', 'level_template_versions', 'exhibits', 'folder_exhibits',
'document_exhibits', 'folder_memberships', 'exhibit_connections', 'metadata_fields', 'assets', 'schema_migrations',
'party_exhibits', 'person_parties', 'organization_parties', 'brief_concepts', 'level_briefs',
'board_views', 'timeline_views',
'mysteries', 'npcs', 'npc_poses', 'playthroughs',
'story_nodes', 'story_node_terminals', 'utterances',
]))
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue']))
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
expect(ledger.rows[0].count).toBe('22')
const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`)
expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset']))
const eventOccurrence = await client.query<{ is_nullable: string }>(`SELECT is_nullable FROM information_schema.columns WHERE table_schema='osint' AND table_name='event_exhibits' AND column_name='occurred_at'`)
expect(eventOccurrence.rows[0].is_nullable).toBe('YES')
await client.end()
const secondRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(22)
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
})
})
+44
View File
@@ -0,0 +1,44 @@
import { createHash } from 'node:crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
import pg from 'pg'
const { Client } = pg
export async function runMigrations(databaseUrl: string, migrationsDir: string, log: (message: string) => void = console.log) {
const client = new Client({ connectionString: databaseUrl })
await client.connect()
try {
await client.query('CREATE SCHEMA IF NOT EXISTS osint')
await client.query(`CREATE TABLE IF NOT EXISTS osint.schema_migrations (
name TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`)
const files = (await fs.readdir(migrationsDir)).filter(name => /^\d+.*\.sql$/.test(name)).sort()
for (const name of files) {
const sql = await fs.readFile(path.join(migrationsDir, name), 'utf8')
const checksum = createHash('sha256').update(sql).digest('hex')
const existing = await client.query<{ checksum: string }>('SELECT checksum FROM osint.schema_migrations WHERE name = $1', [name])
if (existing.rows[0]) {
if (existing.rows[0].checksum !== checksum) throw new Error(`Applied migration was modified: ${name}`)
log(`skip ${name}`)
continue
}
await client.query('BEGIN')
try {
await client.query(sql)
await client.query('INSERT INTO osint.schema_migrations (name, checksum) VALUES ($1, $2)', [name, checksum])
await client.query('COMMIT')
log(`apply ${name}`)
} catch (error) {
await client.query('ROLLBACK')
throw error
}
}
log('OSINT migrations complete.')
} finally {
await client.end()
}
}
+128
View File
@@ -0,0 +1,128 @@
import { createServer } from 'node:net'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
import jwt from 'jsonwebtoken'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { PlaythroughState } from './narrativeRepository.js'
import { runMigrations } from './migrations.js'
const { Client } = pg
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
const suite = baseDatabaseUrl ? describe : describe.skip
const databaseName = `osint_narrative_test_${process.pid}_${Date.now()}`
let adminClient: InstanceType<typeof Client>
let appServer: Awaited<typeof import('./index.js')>['server']
let appPool: Awaited<typeof import('./index.js')>['pool']
let baseUrl = ''
let adminAuthorization = ''
function authFetch(url: string, authorization?: string, init: RequestInit = {}) {
const headers = new Headers(init.headers)
if (authorization) headers.set('authorization', authorization)
return fetch(url, { ...init, headers })
}
async function availablePort() {
return new Promise<number>((resolve, reject) => {
const probe = createServer()
probe.once('error', reject)
probe.listen(0, '127.0.0.1', () => {
const address = probe.address()
const port = typeof address === 'object' && address ? address.port : 0
probe.close(error => error ? reject(error) : resolve(port))
})
})
}
suite('narrative graph runtime', () => {
beforeAll(async () => {
const adminUrl = new URL(baseDatabaseUrl!)
adminUrl.pathname = '/postgres'
adminClient = new Client({ connectionString: adminUrl.toString() })
await adminClient.connect()
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
const testUrl = new URL(baseDatabaseUrl!)
testUrl.pathname = `/${databaseName}`
const databaseUrl = testUrl.toString()
await runMigrations(databaseUrl, path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations'), () => undefined)
const port = await availablePort()
process.env.DATABASE_URL = databaseUrl
process.env.LEVEL_EDITING_ENABLED = 'true'
process.env.JWT_SECRET = 'osint-narrative-jwt-secret'
process.env.ASSET_STORAGE_DRIVER = 'memory'
process.env.PORT = String(port)
const serverModule = await import('./index.js')
appServer = serverModule.server
appPool = serverModule.pool
baseUrl = `http://127.0.0.1:${port}`
adminAuthorization = `Bearer ${jwt.sign({ sub: 'integration-admin', role: 'admin' }, process.env.JWT_SECRET)}`
})
afterAll(async () => {
if (appServer) await new Promise<void>((resolve, reject) => appServer.close(error => error ? reject(error) : resolve()))
if (appPool) await appPool.end()
if (!adminClient) return
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
await adminClient.end()
})
it('New Game walks the seeded graph: cutscene → dialogue → level → finished', async () => {
const json = { 'content-type': 'application/json' }
// A frozen level template to back the level node.
await authFetch(`${baseUrl}/api/levels`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({ id: 'gr-src', title: 'Runtime Source' }) })
await authFetch(`${baseUrl}/api/levels/gr-src/templates?edit=1`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({ name: 'Runtime Chapter', slug: 'gr-mystery-chapter' }) })
// Mystery + cast, then seed a linear graph.
await authFetch(`${baseUrl}/api/mysteries?edit=1`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({ slug: 'gr-mystery', title: 'Runtime Mystery', cast: [{ key: 'prof', name: 'Prof. Test', role: 'GU' }] }) })
const mysteries = await (await authFetch(`${baseUrl}/api/admin/mysteries`, adminAuthorization)).json() as { id: string; slug: string }[]
const mysteryId = mysteries.find(m => m.slug === 'gr-mystery')!.id
const seed = await authFetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({
entry: 'intro',
nodes: [
{ key: 'intro', type: 'cutscene', label: 'Title', componentKey: 'runtime-title', x: 0, y: 0, terminals: [{ key: 'continue', to: 'brief' }] },
{ key: 'brief', type: 'dialogue', label: 'Briefing', x: 200, y: 0, terminals: [{ key: 'continue', to: 'level' }], utterances: [{ npc: 'prof', text: 'Welcome.' }, { npc: 'prof', text: 'Investigate.' }] },
{ key: 'level', type: 'level', label: 'Board', templateSlug: 'gr-mystery-chapter', x: 400, y: 0, terminals: [{ key: 'report_back', to: 'debrief' }] },
{ key: 'debrief', type: 'dialogue', label: 'Debrief', x: 600, y: 0, terminals: [{ key: 'continue', to: null }], utterances: [{ npc: 'prof', text: 'Case closed.' }] },
],
}) })
expect(seed.status).toBe(201)
// New Game lands on the entry cutscene.
const created = await authFetch(`${baseUrl}/api/playthroughs`, undefined, { method: 'POST', headers: json, body: '{}' })
expect(created.status).toBe(201)
const start = await created.json() as PlaythroughState
expect(start.node?.kind).toBe('cutscene')
expect(start.node?.componentKey).toBe('runtime-title')
const id = start.playthrough.id
// Advance into the briefing dialogue (NPC utterances become steps).
const brief = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState
expect(brief.node?.kind).toBe('dialogue')
expect(brief.node?.utterances).toHaveLength(2)
const root = brief.node?.utterances?.find(u => u.id === brief.node?.rootId)
expect(root).toMatchObject({ text: 'Welcome.', speaker: { name: 'Prof. Test' } })
expect(root?.childIds).toHaveLength(1) // linear parent-chain
// Advance into the level (a board is instantiated and loadable).
const level = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState
expect(level.node?.kind).toBe('level')
expect(level.node?.levelSlug).toMatch(/^gr-mystery-play-/)
expect((await fetch(`${baseUrl}/api/levels/${level.node!.levelSlug}`)).status).toBe(200)
// Report back → debrief dialogue.
const debrief = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState
expect(debrief.node?.kind).toBe('dialogue')
// Final advance → finished; current returns nothing active.
const done = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState
expect(done.playthrough.status).toBe('finished')
expect(done.node).toBeNull()
expect((await authFetch(`${baseUrl}/api/playthroughs/current`, undefined)).status).toBe(204)
// Identity scoping: another user has no playthrough and cannot advance this one.
const playerTwo = `Bearer ${jwt.sign({ sub: 'player-two' }, process.env.JWT_SECRET!)}`
expect((await authFetch(`${baseUrl}/api/playthroughs/current`, playerTwo)).status).toBe(204)
expect((await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, playerTwo, { method: 'POST', headers: json, body: '{}' })).status).toBe(404)
})
})
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { resolvePoseAssetId } from './narrativeRepository.js'
describe('resolvePoseAssetId', () => {
const poses = { neutral: 'asset-neutral', concerned: 'asset-concerned', missing: null }
it('returns the requested pose asset when present', () => {
expect(resolvePoseAssetId(poses, 'concerned', 'neutral')).toBe('asset-concerned')
})
it('falls back to the NPC default pose when the requested pose is absent', () => {
expect(resolvePoseAssetId(poses, 'pointing', 'neutral')).toBe('asset-neutral')
})
it('falls back to the default when the requested pose exists but has no artwork', () => {
expect(resolvePoseAssetId(poses, 'missing', 'neutral')).toBe('asset-neutral')
})
it('returns null (no artwork) when neither requested nor default resolves', () => {
expect(resolvePoseAssetId(poses, 'pointing', 'also-missing')).toBeNull()
expect(resolvePoseAssetId(poses, null, null)).toBeNull()
expect(resolvePoseAssetId({}, 'neutral', 'neutral')).toBeNull()
})
})
+311
View File
@@ -0,0 +1,311 @@
import { createHash, randomUUID } from 'node:crypto'
import type { Pool, PoolClient } from 'pg'
import { cloneBoard } from './boardClone.js'
import type { ObjectStorage } from './objectStorage.js'
export type UploadedFile = { buffer: Buffer; originalname: string; mimetype: string; size: number }
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 MysterySummary = { id: string; slug: string; title: string; nodes: number }
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: 'active' | 'finished' }
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
utterances?: RuntimeUtterance[]; rootId?: string | null
}
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
export type MysteryAuthoring = {
slug: string
title: string
cast: { key: string; name: string; role?: string; defaultPose?: string; poses?: { poseKey: string; assetId: string }[] }[]
}
/**
* Resolve a portrait with graceful fallback: the requested pose, else the NPC's
* default pose, else no artwork. Pure so it is unit-testable without a DB.
*/
export function resolvePoseAssetId(
poseAssets: Record<string, string | null | undefined>,
requestedPoseKey: string | null | undefined,
defaultPoseKey: string | null | undefined,
): string | null {
if (requestedPoseKey && poseAssets[requestedPoseKey]) return poseAssets[requestedPoseKey]!
if (defaultPoseKey && poseAssets[defaultPoseKey]) return poseAssets[defaultPoseKey]!
return null
}
export interface NarrativeRepository {
authorMystery(input: MysteryAuthoring): Promise<{ slug: string }>
createPlaythrough(userId: string, mysterySlug?: string): Promise<PlaythroughState | null>
getCurrentPlaythrough(userId: string): Promise<PlaythroughState | null>
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
listMysteries(): Promise<MysterySummary[]>
listNpcs(): Promise<NpcDto[]>
createNpc(input: { key: string; name: string; role?: string; defaultPose?: string | null }): Promise<NpcDto>
updateNpc(id: string, input: { name?: string; role?: string; defaultPose?: string | null }): Promise<NpcDto | null>
deleteNpc(id: string): Promise<'deleted' | 'in_use' | 'not_found'>
addPose(npcId: string, poseKey: string, file: UploadedFile): 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 }
export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStorage): NarrativeRepository {
// ---- Runtime: walking the story graph -------------------------------------
// Resolve a dialogue node's whole utterance tree for the client to walk: each
// utterance carries its ordered children and (if it exits the node) its terminal key.
async function resolveDialogueGraph(nodeId: string): Promise<{ utterances: RuntimeUtterance[]; rootId: string | null }> {
const [utterances, poses, terminals] = await Promise.all([
pool.query<{ id: string; utterer: 'npc' | 'player'; npc_id: string | null; pose_key: string | null; text: string; parent_utterance_id: string | null; terminal_id: string | null; name: string | null; role: string | null; default_pose_key: string | null }>(
`SELECT u.id,u.utterer,u.npc_id,u.pose_key,u.text,u.parent_utterance_id,u.terminal_id,n.name,n.role,n.default_pose_key
FROM osint.utterances u LEFT JOIN osint.npcs n ON n.id=u.npc_id WHERE u.node_id=$1 ORDER BY u.sort_order`, [nodeId]),
pool.query<{ npc_id: string; pose_key: string; asset_id: string | null }>(
`SELECT p.npc_id,p.pose_key,p.asset_id FROM osint.npc_poses p
WHERE p.npc_id IN (SELECT DISTINCT npc_id FROM osint.utterances WHERE node_id=$1 AND npc_id IS NOT NULL)`, [nodeId]),
pool.query<{ id: string; terminal_key: string }>('SELECT id,terminal_key FROM osint.story_node_terminals WHERE parent_node_id=$1', [nodeId]),
])
const poseAssets = new Map<string, Record<string, string | null>>()
for (const row of poses.rows) { const map = poseAssets.get(row.npc_id) || {}; map[row.pose_key] = row.asset_id; poseAssets.set(row.npc_id, map) }
const terminalKey = new Map(terminals.rows.map(row => [row.id, row.terminal_key]))
const children = new Map<string, string[]>()
for (const row of utterances.rows) if (row.parent_utterance_id) children.set(row.parent_utterance_id, [...(children.get(row.parent_utterance_id) || []), row.id])
const root = utterances.rows.find(row => !row.parent_utterance_id)
return {
rootId: root?.id ?? null,
utterances: utterances.rows.map(row => {
const assetId = row.npc_id ? resolvePoseAssetId(poseAssets.get(row.npc_id) || {}, row.pose_key, row.default_pose_key) : null
return {
id: row.id, utterer: row.utterer, speaker: { name: row.name || '', role: row.role || '' },
poseUrl: assetId ? `/api/assets/${assetId}` : null, text: row.text,
childIds: children.get(row.id) || [], terminalKey: row.terminal_id ? (terminalKey.get(row.terminal_id) ?? null) : 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 FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0]
if (!node) return null
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key }
if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug }
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, ...(await resolveDialogueGraph(node.id)) }
return null // gates are auto-resolved during advance and never surfaced
}
// Skip through gate nodes (deterministic gate is dumb: it follows its first terminal).
async function resolveThroughGates(client: PoolClient, nodeId: string | null): Promise<GraphNodeRow | null> {
let current = nodeId
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]
if (!node) return null
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])
current = next.rows[0]?.to_node_id ?? null
}
return null
}
async function instantiateLevel(client: PoolClient, versionId: string, mysterySlug: string): Promise<string> {
const source = (await client.query<{ board_id: string; title: string; subtitle: string }>(
'SELECT board_id,title,subtitle FROM osint.level_template_versions WHERE id=$1 FOR SHARE', [versionId])).rows[0]
if (!source) throw new Error('Level template version not found')
const boardId = randomUUID(); const levelId = randomUUID()
const levelSlug = `${mysterySlug}-play-${randomUUID().slice(0, 8)}`
await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'level')`, [boardId])
await client.query('INSERT INTO osint.levels (id,slug,board_id,source_template_version_id,title,subtitle) VALUES ($1,$2,$3,$4,$5,$6)',
[levelId, levelSlug, boardId, versionId, source.title, source.subtitle])
await cloneBoard(client, source.board_id, boardId)
return levelId
}
async function stateForPlaythrough(playthroughId: string): Promise<PlaythroughState | null> {
const row = (await pool.query<{ id: string; mystery_slug: string; current_node_id: string | null; level_slug: string | null; status: 'active' | 'finished' }>(
`SELECT p.id,m.slug AS mystery_slug,p.current_node_id,l.slug AS level_slug,p.status
FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id LEFT JOIN osint.levels l ON l.id=p.current_level_id
WHERE p.id=$1`, [playthroughId])).rows[0]
if (!row) return null
const node = row.current_node_id ? await resolveNodeForPlay(row.current_node_id, row.level_slug) : null
return { playthrough: { id: row.id, mysterySlug: row.mystery_slug, levelSlug: row.level_slug, status: row.status }, node }
}
// ---- Assets & NPC catalog -------------------------------------------------
async function storeAsset(file: UploadedFile): Promise<string> {
const checksum = createHash('sha256').update(file.buffer).digest('hex')
const existing = await pool.query<{ id: string }>('SELECT id FROM osint.assets WHERE checksum_sha256=$1 AND byte_size=$2', [checksum, file.size])
if (existing.rows[0]) return existing.rows[0].id
const objectKey = `assets/${checksum.slice(0, 2)}/${checksum}`
const stored = await objectStorage.putObject(objectKey, file.buffer, file.mimetype || 'application/octet-stream')
const asset = await pool.query<{ id: string }>(`INSERT INTO osint.assets
(id,original_name,mime_type,byte_size,content,checksum_sha256,storage_provider,storage_bucket,object_key,etag)
VALUES ($1,$2,$3,$4,NULL,$5,'s3',$6,$7,$8)
ON CONFLICT (checksum_sha256,byte_size) DO UPDATE SET checksum_sha256=EXCLUDED.checksum_sha256 RETURNING id`,
[randomUUID(), file.originalname, file.mimetype || 'application/octet-stream', file.size, checksum, objectStorage.bucket, objectKey, stored.etag || null])
return asset.rows[0].id
}
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 }>(
'SELECT id,npc_key,name,role,default_pose_key FROM osint.npcs WHERE id=$1 AND mystery_id IS NULL', [id])).rows[0]
if (!npc) return null
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<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.utterances WHERE npc_id=$1', [id]),
])
return {
id: npc.id, key: npc.npc_key, name: npc.name, role: npc.role, defaultPose: npc.default_pose_key,
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,
}
}
return {
async authorMystery(input) {
const client = await pool.connect()
try {
await client.query('BEGIN')
// Dev-friendly replace: re-authoring the same slug supersedes the previous
// mystery (cascades to its graph, cast links, and playthroughs).
await client.query('DELETE FROM osint.mysteries WHERE slug=$1', [input.slug])
const mysteryId = randomUUID()
await client.query('INSERT INTO osint.mysteries (id,slug,title) VALUES ($1,$2,$3)', [mysteryId, input.slug, input.title])
// NPCs are global templates referenced by key; create the first time a key is
// seen and never clobber an existing one (admin edits persist).
for (const npc of input.cast) {
const existing = await client.query('SELECT 1 FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [npc.key])
if (existing.rows[0]) continue
const npcId = randomUUID()
await client.query('INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key) VALUES ($1,NULL,$2,$3,$4,$5)',
[npcId, npc.key, npc.name, npc.role || '', npc.defaultPose || null])
for (const pose of npc.poses || []) await client.query(
'INSERT INTO osint.npc_poses (id,npc_id,pose_key,asset_id) VALUES ($1,$2,$3,$4)', [randomUUID(), npcId, pose.poseKey, pose.assetId])
}
await client.query('COMMIT')
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
return { slug: input.slug }
},
async createPlaythrough(userId, mysterySlug) {
const client = await pool.connect()
let playthroughId: string
try {
await client.query('BEGIN')
const mystery = (await client.query<{ id: string; slug: string; entry_node_id: string | null }>(
mysterySlug
? 'SELECT id,slug,entry_node_id FROM osint.mysteries WHERE slug=$1'
: 'SELECT id,slug,entry_node_id FROM osint.mysteries WHERE entry_node_id IS NOT NULL ORDER BY created_at DESC LIMIT 1',
mysterySlug ? [mysterySlug] : [])).rows[0]
if (!mystery?.entry_node_id) { await client.query('ROLLBACK'); return null }
const entry = await resolveThroughGates(client, mystery.entry_node_id)
if (!entry) { await client.query('ROLLBACK'); return null }
const levelId = entry.node_type === 'level' && entry.level_template_version_id
? await instantiateLevel(client, entry.level_template_version_id, mystery.slug) : null
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)',
[playthroughId, userId, mystery.id, entry.id, levelId])
await client.query('COMMIT')
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
return stateForPlaythrough(playthroughId)
},
async getCurrentPlaythrough(userId) {
const row = (await pool.query<{ id: string }>(
`SELECT id FROM osint.playthroughs WHERE user_id=$1 AND status='active' ORDER BY updated_at DESC LIMIT 1`, [userId])).rows[0]
return row ? stateForPlaythrough(row.id) : null
},
async advancePlaythrough(userId, playthroughId, terminalKey) {
const client = await pool.connect()
try {
await client.query('BEGIN')
const playthrough = (await client.query<{ current_node_id: string | null; mystery_slug: string }>(
`SELECT p.current_node_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 AND p.status='active' FOR UPDATE OF p`, [playthroughId, userId])).rows[0]
if (!playthrough) { await client.query('ROLLBACK'); return { ok: false, error: 'Playthrough not found' } }
if (!playthrough.current_node_id) { await client.query('ROLLBACK'); return { ok: false, error: 'Playthrough already finished' } }
const terminals = (await client.query<{ terminal_key: string; to_node_id: string | null }>(
'SELECT terminal_key,to_node_id FROM osint.story_node_terminals WHERE parent_node_id=$1 ORDER BY sort_order', [playthrough.current_node_id])).rows
const wired = terminals.filter(t => t.to_node_id)
const chosen = terminalKey ? terminals.find(t => t.terminal_key === terminalKey)
: wired.length === 1 ? wired[0] : terminals.length === 1 ? terminals[0] : undefined
if (!chosen) { await client.query('ROLLBACK'); return { ok: false, error: 'Ambiguous or unknown terminal — specify one' } }
const target = await resolveThroughGates(client, chosen.to_node_id)
if (!target) {
await client.query(`UPDATE osint.playthroughs SET status='finished',current_node_id=NULL,current_level_id=NULL,updated_at=NOW() WHERE id=$1`, [playthroughId])
} else {
const levelId = target.node_type === 'level' && target.level_template_version_id
? 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('COMMIT')
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
const state = await stateForPlaythrough(playthroughId)
return { ok: true, state: state ?? undefined }
},
async listMysteries() {
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
FROM osint.mysteries m LEFT JOIN osint.story_nodes n ON n.mystery_id=m.id
GROUP BY m.id ORDER BY m.created_at DESC`)
return result.rows.map(row => ({ id: row.id, slug: row.slug, title: row.title, nodes: Number(row.nodes) }))
},
async listNpcs() {
const npcs = await pool.query<{ id: string }>('SELECT id FROM osint.npcs WHERE mystery_id IS NULL ORDER BY name')
return (await Promise.all(npcs.rows.map(row => loadNpc(row.id)))).filter((npc): npc is NpcDto => npc !== null)
},
async createNpc(input) {
const key = input.key.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
if (!key) throw new Error('An NPC key is required')
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)',
[id, key, input.name.trim() || key, input.role?.trim() || '', input.defaultPose || null])
return (await loadNpc(id))!
},
async updateNpc(id, input) {
const existing = await loadNpc(id)
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', [
id, input.name?.trim() ?? existing.name, input.role?.trim() ?? existing.role,
input.defaultPose === undefined ? existing.defaultPose : (input.defaultPose || null),
])
return loadNpc(id)
},
async deleteNpc(id) {
const existing = await loadNpc(id)
if (!existing) return 'not_found'
if (existing.inUse) return 'in_use'
await pool.query('DELETE FROM osint.npcs WHERE id=$1 AND mystery_id IS NULL', [id])
return 'deleted'
},
async addPose(npcId, poseKey, file) {
const npc = await loadNpc(npcId)
if (!npc) return null
const key = poseKey.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'default'
const assetId = await storeAsset(file)
await pool.query(`INSERT INTO osint.npc_poses (id,npc_id,pose_key,asset_id) VALUES ($1,$2,$3,$4)
ON CONFLICT (npc_id,pose_key) DO UPDATE SET asset_id=EXCLUDED.asset_id`, [randomUUID(), npcId, key, assetId])
return loadNpc(npcId)
},
async deletePose(npcId, poseKey) {
const npc = await loadNpc(npcId)
if (!npc) return null
await pool.query('DELETE FROM osint.npc_poses WHERE npc_id=$1 AND pose_key=$2', [npcId, poseKey])
return loadNpc(npcId)
},
}
}
+88
View File
@@ -0,0 +1,88 @@
import { CreateBucketCommand, GetObjectCommand, HeadBucketCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { Readable } from 'node:stream'
export type ObjectBody = { stream: Readable; contentLength?: number }
export interface ObjectStorage {
readonly bucket: string
readonly provider: 's3' | 'memory'
initialize(): Promise<void>
putObject(key: string, body: Buffer, contentType: string): Promise<{ etag?: string }>
getObject(key: string): Promise<ObjectBody | null>
}
export class MemoryObjectStorage implements ObjectStorage {
readonly bucket: string
readonly provider = 'memory' as const
private readonly objects = new Map<string, Buffer>()
constructor(bucket = 'osint-test-assets') { this.bucket = bucket }
async initialize() { /* Nothing to initialize. */ }
async putObject(key: string, body: Buffer) { this.objects.set(key, Buffer.from(body)); return {} }
async getObject(key: string) {
const body = this.objects.get(key)
return body ? { stream: Readable.from(body), contentLength: body.byteLength } : null
}
}
export class S3ObjectStorage implements ObjectStorage {
readonly provider = 's3' as const
readonly bucket: string
private readonly client: S3Client
constructor(options: { endpoint: string; region: string; accessKey: string; secretKey: string; bucket: string; forcePathStyle: boolean }) {
this.bucket = options.bucket
this.client = new S3Client({
endpoint: options.endpoint,
region: options.region,
forcePathStyle: options.forcePathStyle,
credentials: { accessKeyId: options.accessKey, secretAccessKey: options.secretKey },
})
}
async initialize() {
try {
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }))
} catch (error) {
const status = (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode
if (status !== 404) throw error
try { await this.client.send(new CreateBucketCommand({ Bucket: this.bucket })) }
catch (createError) {
const createStatus = (createError as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode
if (createStatus !== 409) throw createError
}
}
}
async putObject(key: string, body: Buffer, contentType: string) {
const result = await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: body, ContentType: contentType }))
return { etag: result.ETag?.replaceAll('"', '') }
}
async getObject(key: string) {
try {
const result = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }))
if (!result.Body || typeof (result.Body as NodeJS.ReadableStream).pipe !== 'function') throw new Error(`Object ${key} did not return a Node stream`)
return { stream: result.Body as Readable, contentLength: result.ContentLength }
} catch (error) {
const status = (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode
if (status === 404) return null
throw error
}
}
}
export function createObjectStorageFromEnv() {
if (process.env.ASSET_STORAGE_DRIVER === 'memory' || process.env.NODE_ENV === 'test') return new MemoryObjectStorage(process.env.S3_BUCKET)
const accessKey = process.env.S3_ACCESS_KEY
const secretKey = process.env.S3_SECRET_KEY
if (!accessKey || !secretKey) throw new Error('S3_ACCESS_KEY and S3_SECRET_KEY are required for MinIO asset storage')
return new S3ObjectStorage({
endpoint: process.env.S3_ENDPOINT || 'http://127.0.0.1:9000',
region: process.env.S3_REGION || 'us-east-1',
accessKey,
secretKey,
bucket: process.env.S3_BUCKET || 'osint-evidence',
forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== 'false',
})
}
+142
View File
@@ -0,0 +1,142 @@
import { createServer } from 'node:net'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
import jwt from 'jsonwebtoken'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { StoryGraphDto, StoryNodeDto, LevelTemplateOption, UtteranceDto } from './storyGraphRepository.js'
import { runMigrations } from './migrations.js'
const { Client } = pg
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
const suite = baseDatabaseUrl ? describe : describe.skip
const databaseName = `osint_storygraph_test_${process.pid}_${Date.now()}`
let adminClient: InstanceType<typeof Client>
let appServer: Awaited<typeof import('./index.js')>['server']
let appPool: Awaited<typeof import('./index.js')>['pool']
let baseUrl = ''
let auth = ''
async function availablePort() {
return new Promise<number>((resolve, reject) => {
const probe = createServer()
probe.once('error', reject)
probe.listen(0, '127.0.0.1', () => {
const address = probe.address()
const port = typeof address === 'object' && address ? address.port : 0
probe.close(error => error ? reject(error) : resolve(port))
})
})
}
const json = { 'content-type': 'application/json' }
function admin(url: string, init: RequestInit = {}) {
const headers = new Headers(init.headers); headers.set('authorization', auth)
return fetch(url, { ...init, headers })
}
suite('story graph authoring API', () => {
beforeAll(async () => {
const adminUrl = new URL(baseDatabaseUrl!); adminUrl.pathname = '/postgres'
adminClient = new Client({ connectionString: adminUrl.toString() }); await adminClient.connect()
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
const testUrl = new URL(baseDatabaseUrl!); testUrl.pathname = `/${databaseName}`
const databaseUrl = testUrl.toString()
await runMigrations(databaseUrl, path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations'), () => undefined)
const port = await availablePort()
process.env.DATABASE_URL = databaseUrl
process.env.LEVEL_EDITING_ENABLED = 'true'
process.env.JWT_SECRET = 'osint-storygraph-jwt'
process.env.ASSET_STORAGE_DRIVER = 'memory'
process.env.PORT = String(port)
const serverModule = await import('./index.js')
appServer = serverModule.server; appPool = serverModule.pool
baseUrl = `http://127.0.0.1:${port}`
auth = `Bearer ${jwt.sign({ sub: 'sg-admin', role: 'admin' }, process.env.JWT_SECRET)}`
})
afterAll(async () => {
if (appServer) await new Promise<void>((resolve, reject) => appServer.close(error => error ? reject(error) : resolve()))
if (appPool) await appPool.end()
if (!adminClient) return
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
await adminClient.end()
})
async function makeMystery(slug: string) {
await admin(`${baseUrl}/api/levels`, { method: 'POST', headers: json, body: JSON.stringify({ id: `${slug}-src`, title: 'SG Source' }) })
await admin(`${baseUrl}/api/levels/${slug}-src/templates?edit=1`, { method: 'POST', headers: json, body: JSON.stringify({ name: `${slug} chapter` }) })
await admin(`${baseUrl}/api/mysteries?edit=1`, { method: 'POST', headers: json, body: JSON.stringify({ slug, title: slug, chapters: [{ templateSlug: `${slug}-chapter` }], cast: [], cutscenes: [] }) })
const list = await (await admin(`${baseUrl}/api/admin/mysteries`)).json() as { id: string; slug: string }[]
return list.find(m => m.slug === slug)!.id
}
it('builds a node graph: create, wire, configure, set entry', async () => {
const mysteryId = await makeMystery('sg-mystery')
const templates = await (await admin(`${baseUrl}/api/admin/level-templates`)).json() as LevelTemplateOption[]
const chapter = templates.find(t => t.slug === 'sg-mystery-chapter')!
const cutscene = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 40, ypos: 40 }) })).json() as StoryNodeDto
const level = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'level', xpos: 320, ypos: 40 }) })).json() as StoryNodeDto
expect(cutscene.terminals).toHaveLength(1)
expect(cutscene.terminals[0].terminalKey).toBe('continue')
expect(level.terminals[0].terminalKey).toBe('report_back')
// Wire cutscene → level; configure level template; set entrypoint.
expect((await admin(`${baseUrl}/api/admin/story-terminals/${cutscene.terminals[0].id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ toNodeId: level.id }) })).status).toBe(200)
await admin(`${baseUrl}/api/admin/story-nodes/${level.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ levelTemplateVersionId: chapter.versionId }) })
await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/entry`, { method: 'PUT', headers: json, body: JSON.stringify({ nodeId: cutscene.id }) })
const graph = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`)).json() as StoryGraphDto
expect(graph.nodes).toHaveLength(2)
expect(graph.entryNodeId).toBe(cutscene.id)
expect(graph.nodes.find(n => n.id === cutscene.id)!.terminals[0].toNodeId).toBe(level.id)
expect(graph.nodes.find(n => n.id === level.id)!.levelTemplateVersionId).toBe(chapter.versionId)
// Deleting the target node unwires (SET NULL) rather than deleting the source's port.
await admin(`${baseUrl}/api/admin/story-nodes/${level.id}`, { method: 'DELETE' })
const after = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`)).json() as StoryGraphDto
expect(after.nodes).toHaveLength(1)
expect(after.nodes[0].terminals[0].toNodeId).toBeNull()
})
it('rejects wiring a terminal across mysteries', async () => {
const mysteryA = await makeMystery('sg-a')
const mysteryB = await makeMystery('sg-b')
const nodeA = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryA}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 0, ypos: 0 }) })).json() as StoryNodeDto
const nodeB = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryB}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 0, ypos: 0 }) })).json() as StoryNodeDto
const cross = await admin(`${baseUrl}/api/admin/story-terminals/${nodeA.terminals[0].id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ toNodeId: nodeB.id }) })
expect(cross.status).toBe(400)
})
it('crafts utterances: NPC prompt, player option, wiring and same-node validation', async () => {
const mysteryId = await makeMystery('sg-utt')
const dialogue = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'dialogue', xpos: 0, ypos: 0 }) })).json() as StoryNodeDto
const terminalId = dialogue.terminals[0].id
const otherNode = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'level', xpos: 400, ypos: 0 }) })).json() as StoryNodeDto
const prompt = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`, { method: 'POST', headers: json, body: JSON.stringify({ utterer: 'npc', xpos: 40, ypos: 40, text: 'Are you ready?' }) })).json() as UtteranceDto
const yes = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`, { method: 'POST', headers: json, body: JSON.stringify({ utterer: 'player', xpos: 300, ypos: 40, text: 'Yes' }) })).json() as UtteranceDto
// 'Yes' hangs under the prompt (parent); and exits via the node's terminal.
expect((await admin(`${baseUrl}/api/admin/utterances/${yes.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ parentUtteranceId: prompt.id }) })).status).toBe(200)
expect((await admin(`${baseUrl}/api/admin/utterances/${yes.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ terminalId }) })).status).toBe(200)
const list = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`)).json() as UtteranceDto[]
const savedYes = list.find(u => u.id === yes.id)!
expect(savedYes.parentUtteranceId).toBe(prompt.id)
expect(savedYes.terminalId).toBe(terminalId)
// A terminal from another node cannot be used as this utterance's exit.
const foreign = await admin(`${baseUrl}/api/admin/utterances/${yes.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ terminalId: otherNode.terminals[0].id }) })
expect(foreign.status).toBe(400)
// Deleting the NPC prompt cascades its player options.
await admin(`${baseUrl}/api/admin/utterances/${prompt.id}`, { method: 'DELETE' })
const after = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`)).json() as UtteranceDto[]
expect(after).toHaveLength(0)
})
it('refuses graph writes without an admin claim', async () => {
const mysteryId = await makeMystery('sg-guard')
expect((await fetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 0, ypos: 0 }) })).status).toBe(403)
})
})
+314
View File
@@ -0,0 +1,314 @@
import { randomUUID } from 'node:crypto'
import type { Pool, PoolClient } from 'pg'
export type GraphSpecNode = {
key: string; type: StoryNodeType; label?: string; x: number; y: number
componentKey?: string; templateSlug?: string; version?: number
terminals?: { key: string; label?: string; to?: string | null }[]
utterances?: { npc?: string; pose?: string; text: string; utterer?: Utterer }[]
}
export type GraphSpec = { entry: string; nodes: GraphSpecNode[] }
export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
export type TerminalDto = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number }
export type StoryNodeDto = {
id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean
xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null
terminals: TerminalDto[]
}
export type StoryGraphDto = { mysteryId: string; entryNodeId: string | null; nodes: StoryNodeDto[] }
export type LevelTemplateOption = { versionId: string; slug: string; name: string; version: number }
export type Utterer = 'npc' | 'player'
export type UtteranceDto = {
id: string; nodeId: string; utterer: Utterer; npcId: string | null; poseKey: string | null; text: string
parentUtteranceId: string | null; terminalId: string | null
xpos: number; ypos: number; sortOrder: number
}
// A sensible starter terminal set so a freshly dropped node is immediately wireable.
const DEFAULT_TERMINALS: Record<StoryNodeType, { key: string; label: string }[]> = {
cutscene: [{ key: 'continue', label: 'Continue' }],
dialogue: [{ key: 'continue', label: 'Continue' }],
level: [{ key: 'report_back', label: 'Report back' }],
det_gate: [{ key: 'pass', label: 'Pass' }],
llm_gate: [{ key: 'pass', label: 'Pass' }],
}
export interface StoryGraphRepository {
getGraph(mysteryId: string): Promise<StoryGraphDto | 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 }>): Promise<StoryNodeDto | null>
deleteNode(nodeId: string): Promise<boolean>
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 }>
deleteTerminal(terminalId: string): Promise<boolean>
setEntryNode(mysteryId: string, nodeId: string | null): Promise<{ ok: boolean; error?: string }>
listLevelTemplates(): Promise<LevelTemplateOption[]>
listUtterances(nodeId: string): Promise<UtteranceDto[]>
createUtterance(nodeId: string, input: { utterer: Utterer; xpos: number; ypos: number; text?: string }): Promise<UtteranceDto | null>
updateUtterance(id: string, input: Partial<{ text: string; utterer: Utterer; npcId: string | null; poseKey: string | null; xpos: number; ypos: number; parentUtteranceId: string | null; terminalId: string | null }>): Promise<{ ok: boolean; error?: string }>
deleteUtterance(id: string): Promise<boolean>
authorGraph(mysteryId: string, spec: GraphSpec): Promise<{ nodes: number } | null>
}
export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
async function mysteryOfNode(nodeId: string): Promise<string | null> {
const result = await pool.query<{ mystery_id: string }>('SELECT mystery_id FROM osint.story_nodes WHERE id=$1', [nodeId])
return result.rows[0]?.mystery_id ?? null
}
async function loadGraph(mysteryId: string): Promise<StoryGraphDto | null> {
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
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 }>(
'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key 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 }>(
`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
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[]>()
for (const row of terminals.rows) {
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 })
byNode.set(row.parent_node_id, list)
}
return {
mysteryId, entryNodeId: mystery.rows[0].entry_node_id,
nodes: nodes.rows.map(row => ({
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,
terminals: byNode.get(row.id) || [],
})),
}
}
return {
getGraph: loadGraph,
async createNode(mysteryId, input) {
const client = await pool.connect()
try {
await client.query('BEGIN')
const mystery = await client.query('SELECT 1 FROM osint.mysteries WHERE id=$1', [mysteryId])
if (!mystery.rows[0]) { await client.query('ROLLBACK'); return null }
const nodeId = randomUUID()
const label = input.label?.trim() || input.nodeType
await client.query('INSERT INTO osint.story_nodes (id,mystery_id,node_type,label,xpos,ypos,has_utterances) VALUES ($1,$2,$3,$4,$5,$6,$7)',
[nodeId, mysteryId, input.nodeType, label, input.xpos, input.ypos, input.nodeType === 'dialogue' || input.nodeType === 'cutscene'])
for (const [index, terminal] of DEFAULT_TERMINALS[input.nodeType].entries())
await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5)',
[randomUUID(), nodeId, terminal.key, terminal.label, index])
await client.query('COMMIT')
const graph = await loadGraph(mysteryId)
return graph?.nodes.find(node => node.id === nodeId) ?? null
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
},
async updateNode(nodeId, input) {
const mysteryId = await mysteryOfNode(nodeId)
if (!mysteryId) return null
const sets: string[] = []
const values: unknown[] = [nodeId]
const set = (column: string, value: unknown) => { values.push(value); sets.push(`${column}=$${values.length}`) }
if (input.label !== undefined) set('label', input.label.trim())
if (input.xpos !== undefined) set('xpos', input.xpos)
if (input.ypos !== undefined) set('ypos', input.ypos)
if (input.hasUtterances !== undefined) set('has_utterances', input.hasUtterances)
if (input.componentKey !== undefined) set('component_key', input.componentKey || null)
if (input.levelTemplateVersionId !== undefined) set('level_template_version_id', input.levelTemplateVersionId || null)
if (sets.length) await pool.query(`UPDATE osint.story_nodes SET ${sets.join(',')} WHERE id=$1`, values)
const graph = await loadGraph(mysteryId)
return graph?.nodes.find(node => node.id === nodeId) ?? null
},
async deleteNode(nodeId) {
const result = await pool.query('DELETE FROM osint.story_nodes WHERE id=$1', [nodeId])
return (result.rowCount ?? 0) > 0
},
async addTerminal(nodeId, input) {
const mysteryId = await mysteryOfNode(nodeId)
if (!mysteryId) return null
const key = input.terminalKey.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'out'
const order = await pool.query<{ next: number }>('SELECT COALESCE(MAX(sort_order),-1)+1 AS next FROM osint.story_node_terminals WHERE parent_node_id=$1', [nodeId])
await pool.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (parent_node_id,terminal_key) DO NOTHING',
[randomUUID(), nodeId, key, input.label?.trim() || key, order.rows[0].next])
const graph = await loadGraph(mysteryId)
return graph?.nodes.find(node => node.id === nodeId) ?? null
},
async updateTerminal(terminalId, input) {
const owner = await pool.query<{ parent_node_id: string; mystery_id: string }>(
`SELECT t.parent_node_id, n.mystery_id FROM osint.story_node_terminals t JOIN osint.story_nodes n ON n.id=t.parent_node_id WHERE t.id=$1`, [terminalId])
if (!owner.rows[0]) return { ok: false, error: 'Terminal not found' }
if (input.toNodeId !== undefined && input.toNodeId !== null) {
const target = await mysteryOfNode(input.toNodeId)
if (target !== owner.rows[0].mystery_id) return { ok: false, error: 'A wire must stay within the same mystery' }
}
const sets: string[] = []
const values: unknown[] = [terminalId]
const set = (column: string, value: unknown) => { values.push(value); sets.push(`${column}=$${values.length}`) }
if (input.label !== undefined) set('label', input.label.trim())
if (input.sortOrder !== undefined) set('sort_order', input.sortOrder)
if (input.toNodeId !== undefined) set('to_node_id', input.toNodeId)
if (sets.length) await pool.query(`UPDATE osint.story_node_terminals SET ${sets.join(',')} WHERE id=$1`, values)
return { ok: true }
},
async deleteTerminal(terminalId) {
const result = await pool.query('DELETE FROM osint.story_node_terminals WHERE id=$1', [terminalId])
return (result.rowCount ?? 0) > 0
},
async setEntryNode(mysteryId, nodeId) {
if (nodeId !== null) {
const target = await mysteryOfNode(nodeId)
if (target !== mysteryId) return { ok: false, error: 'Entry node must belong to the mystery' }
}
const result = await pool.query('UPDATE osint.mysteries SET entry_node_id=$2 WHERE id=$1', [mysteryId, nodeId])
return (result.rowCount ?? 0) > 0 ? { ok: true } : { ok: false, error: 'Mystery not found' }
},
async listLevelTemplates() {
const result = await pool.query<{ version_id: string; slug: string; name: string; version: number }>(
`SELECT v.id AS version_id,t.slug,t.name,v.version FROM osint.level_templates t
JOIN osint.level_template_versions v ON v.id=t.current_version_id ORDER BY t.name`)
return result.rows.map(row => ({ versionId: row.version_id, slug: row.slug, name: row.name, version: row.version }))
},
async listUtterances(nodeId) {
const result = await pool.query<UtteranceRow>(
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,terminal_id,xpos,ypos,sort_order
FROM osint.utterances WHERE node_id=$1 ORDER BY sort_order,id`, [nodeId])
return result.rows.map(mapUtterance)
},
async createUtterance(nodeId, input) {
const node = await pool.query('SELECT 1 FROM osint.story_nodes WHERE id=$1', [nodeId])
if (!node.rows[0]) return null
const id = randomUUID()
const order = await pool.query<{ next: number }>('SELECT COALESCE(MAX(sort_order),-1)+1 AS next FROM osint.utterances WHERE node_id=$1', [nodeId])
await pool.query('INSERT INTO osint.utterances (id,node_id,utterer,text,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7)',
[id, nodeId, input.utterer, input.text || '', input.xpos, input.ypos, order.rows[0].next])
const created = await pool.query<UtteranceRow>(
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,terminal_id,xpos,ypos,sort_order FROM osint.utterances WHERE id=$1`, [id])
return mapUtterance(created.rows[0])
},
async updateUtterance(id, input) {
const owner = await pool.query<{ node_id: string }>('SELECT node_id FROM osint.utterances WHERE id=$1', [id])
if (!owner.rows[0]) return { ok: false, error: 'Utterance not found' }
const nodeId = owner.rows[0].node_id
// Same-node integrity for the three links.
for (const link of ['parentUtteranceId'] as const) {
const value = input[link]
if (value) {
const target = await pool.query<{ node_id: string }>('SELECT node_id FROM osint.utterances WHERE id=$1', [value])
if (target.rows[0]?.node_id !== nodeId) return { ok: false, error: 'Linked utterance must be in the same node' }
}
}
if (input.terminalId) {
const terminal = await pool.query<{ parent_node_id: string }>('SELECT parent_node_id FROM osint.story_node_terminals WHERE id=$1', [input.terminalId])
if (terminal.rows[0]?.parent_node_id !== nodeId) return { ok: false, error: 'Terminal must belong to this node' }
}
const columns: Record<string, string> = {
text: 'text', utterer: 'utterer', npcId: 'npc_id', poseKey: 'pose_key', xpos: 'xpos', ypos: 'ypos',
parentUtteranceId: 'parent_utterance_id', terminalId: 'terminal_id',
}
const sets: string[] = []
const values: unknown[] = [id]
for (const [key, column] of Object.entries(columns)) {
if ((input as Record<string, unknown>)[key] !== undefined) { values.push((input as Record<string, unknown>)[key]); sets.push(`${column}=$${values.length}`) }
}
if (sets.length) await pool.query(`UPDATE osint.utterances SET ${sets.join(',')} WHERE id=$1`, values)
return { ok: true }
},
async deleteUtterance(id) {
const result = await pool.query('DELETE FROM osint.utterances WHERE id=$1', [id])
return (result.rowCount ?? 0) > 0
},
// Seed/replace a mystery's whole graph from a spec (used by the manifest importer),
// so a default flow is authored content that survives re-imports.
async authorGraph(mysteryId, spec) {
const client: PoolClient = await pool.connect()
try {
await client.query('BEGIN')
const mystery = await client.query('SELECT 1 FROM osint.mysteries WHERE id=$1', [mysteryId])
if (!mystery.rows[0]) { await client.query('ROLLBACK'); return null }
await client.query('UPDATE osint.mysteries SET entry_node_id=NULL WHERE id=$1', [mysteryId])
await client.query('DELETE FROM osint.story_nodes WHERE mystery_id=$1', [mysteryId])
const nodeIds = new Map<string, string>()
const terminalIds = new Map<string, string>() // `${nodeKey}:${terminalKey}` -> id
for (const node of spec.nodes) {
const id = randomUUID(); nodeIds.set(node.key, id)
let versionId: string | null = null
if (node.type === 'level' && node.templateSlug) {
const version = await client.query<{ id: string }>(
`SELECT v.id FROM osint.level_templates t JOIN osint.level_template_versions v ON v.template_id=t.id
WHERE t.slug=$1 AND (($2::int IS NULL AND v.id=t.current_version_id) OR v.version=$2)`, [node.templateSlug, node.version ?? null])
versionId = version.rows[0]?.id ?? null
if (!versionId) throw new Error(`Graph node ${node.key}: unknown level template ${node.templateSlug}`)
}
await client.query('INSERT INTO osint.story_nodes (id,mystery_id,node_type,label,xpos,ypos,has_utterances,level_template_version_id,component_key) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
[id, mysteryId, node.type, node.label || node.type, node.x, node.y, Boolean(node.utterances?.length), versionId, node.componentKey || null])
for (const [index, terminal] of (node.terminals || []).entries()) {
const terminalId = randomUUID(); terminalIds.set(`${node.key}:${terminal.key}`, terminalId)
await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5)',
[terminalId, id, terminal.key, terminal.label || terminal.key, index])
}
}
// Wire terminals now that all nodes exist.
for (const node of spec.nodes) for (const terminal of node.terminals || []) {
if (!terminal.to) continue
const toId = nodeIds.get(terminal.to)
if (!toId) throw new Error(`Graph node ${node.key}: terminal ${terminal.key} points at unknown node ${terminal.to}`)
await client.query('UPDATE osint.story_node_terminals SET to_node_id=$2 WHERE id=$1', [terminalIds.get(`${node.key}:${terminal.key}`), toId])
}
// Utterances (linear seed): create, then chain them and exit the last one via
// the node's first terminal, so the crafter shows a connected flow.
for (const node of spec.nodes) {
const created: string[] = []
for (const [index, utterance] of (node.utterances || []).entries()) {
let npcId: string | null = null
if (utterance.npc) {
const npc = await client.query<{ id: string }>('SELECT id FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [utterance.npc])
npcId = npc.rows[0]?.id ?? null
}
const utteranceId = randomUUID(); created.push(utteranceId)
await client.query('INSERT INTO osint.utterances (id,node_id,utterer,npc_id,pose_key,text,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
[utteranceId, nodeIds.get(node.key), utterance.utterer || 'npc', npcId, utterance.pose || null, utterance.text, 60, 60 + index * 120, index])
}
// Chain via parent: each line follows the previous one (one child = linear).
for (let i = 1; i < created.length; i++)
await client.query('UPDATE osint.utterances SET parent_utterance_id=$2 WHERE id=$1', [created[i], created[i - 1]])
const firstTerminalKey = node.terminals?.[0]?.key
const exitTerminalId = firstTerminalKey ? terminalIds.get(`${node.key}:${firstTerminalKey}`) : undefined
if (created.length && exitTerminalId)
await client.query('UPDATE osint.utterances SET terminal_id=$2 WHERE id=$1', [created[created.length - 1], exitTerminalId])
}
const entryId = nodeIds.get(spec.entry)
if (!entryId) throw new Error(`Graph entry node ${spec.entry} not found`)
await client.query('UPDATE osint.mysteries SET entry_node_id=$2 WHERE id=$1', [mysteryId, entryId])
await client.query('COMMIT')
return { nodes: spec.nodes.length }
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
},
}
}
type UtteranceRow = {
id: string; node_id: string; utterer: Utterer; npc_id: string | null; pose_key: string | null; text: string
parent_utterance_id: string | null; terminal_id: string | null
xpos: number; ypos: number; sort_order: number
}
function mapUtterance(row: UtteranceRow): UtteranceDto {
return {
id: row.id, nodeId: row.node_id, utterer: row.utterer, npcId: row.npc_id, poseKey: row.pose_key, text: row.text,
parentUtteranceId: row.parent_utterance_id, terminalId: row.terminal_id,
xpos: row.xpos, ypos: row.ypos, sortOrder: row.sort_order,
}
}
+818 -161
View File
File diff suppressed because it is too large Load Diff
+164
View File
@@ -0,0 +1,164 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { MysteryGraphEditor } from './mysteryGraph'
type Pose = { poseKey: string; assetId: string; url: string }
type Npc = { id: string; key: string; name: string; role: string; defaultPose: string | null; poses: Pose[]; inUse: boolean }
type Mystery = { id: string; slug: string; title: string; nodes: number }
async function json<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init)
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || `Request failed (${response.status})`)
return response.json()
}
export function AdminPanel() {
const [isAdmin, setIsAdmin] = useState<boolean | null>(null)
const [tab, setTab] = useState<'npcs' | 'mysteries'>('npcs')
const [npcs, setNpcs] = useState<Npc[]>([])
const [mysteries, setMysteries] = useState<Mystery[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [editingMystery, setEditingMystery] = useState<{ id: string; title: string } | null>(null)
const [status, setStatus] = useState('')
useEffect(() => {
fetch('/api/session').then(r => r.json()).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
}, [])
const reloadNpcs = useCallback(async (selectKey?: string) => {
const list = await json<Npc[]>('/api/admin/npcs')
setNpcs(list)
setSelectedId(current => selectKey ? (list.find(npc => npc.key === selectKey)?.id ?? current) : (current && list.some(npc => npc.id === current) ? current : list[0]?.id ?? null))
}, [])
useEffect(() => {
if (!isAdmin) return
reloadNpcs().catch(error => setStatus(String(error.message || error)))
json<Mystery[]>('/api/admin/mysteries').then(setMysteries).catch(() => {})
}, [isAdmin, reloadNpcs])
if (isAdmin === null) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY · ADMIN</p><small>AUTHENTICATING</small></div>
if (!isAdmin) return <div className="boot"><div className="seal">GU</div><p>ADMINISTRATOR ACCESS REQUIRED</p><small><a className="admin-link" href="/"> RETURN TO TERMINAL</a></small></div>
const selected = npcs.find(npc => npc.id === selectedId) || null
return <div className="admin">
<header className="admin-head">
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ AUTHORING</em></span></div>
<nav className="admin-tabs">
<button className={tab === 'npcs' ? 'active' : ''} onClick={() => setTab('npcs')}>NPCS</button>
<button className={tab === 'mysteries' ? 'active' : ''} onClick={() => setTab('mysteries')}>MYSTERIES</button>
</nav>
<a className="admin-link" href="/"> TERMINAL</a>
</header>
{tab === 'npcs' && <div className="admin-body">
<aside className="npc-list">
<div className="npc-list-head"><span>NPC TEMPLATES</span><button onClick={() => createNpc(setStatus, reloadNpcs)}>+ NEW</button></div>
{npcs.length === 0 && <p className="admin-empty">No NPC templates yet.</p>}
{npcs.map(npc => <button key={npc.id} className={`npc-row${npc.id === selectedId ? ' selected' : ''}`} onClick={() => setSelectedId(npc.id)}>
<span className="npc-avatar">{npc.poses[0] ? <img src={npc.poses[0].url} alt="" /> : npc.name.slice(0, 1).toUpperCase()}</span>
<span className="npc-row-text"><strong>{npc.name || npc.key}</strong><small>{npc.role || npc.key}</small></span>
</button>)}
</aside>
{selected
? <NpcEditor key={selected.id} npc={selected} onChanged={reloadNpcs} setStatus={setStatus} />
: <div className="npc-editor empty">Select or create an NPC.</div>}
</div>}
{tab === 'mysteries' && (editingMystery
? <MysteryGraphEditor mysteryId={editingMystery.id} title={editingMystery.title} onClose={() => setEditingMystery(null)} setStatus={setStatus} />
: <div className="admin-body">
<div className="mystery-list">
{mysteries.length === 0 && <p className="admin-empty">No mysteries authored yet.</p>}
{mysteries.map(mystery => <button key={mystery.id} className="mystery-row" onClick={() => setEditingMystery({ id: mystery.id, title: mystery.title })}>
<strong>{mystery.title}</strong>
<span>{mystery.slug} · {mystery.nodes} node{mystery.nodes === 1 ? '' : 's'} · edit graph </span>
</button>)}
<p className="admin-note">Click a mystery to open its story-flow graph editor.</p>
</div>
</div>)}
<footer className="admin-foot">{status || 'READY'}</footer>
</div>
}
async function createNpc(setStatus: (message: string) => void, reload: (key?: string) => Promise<void>) {
const name = window.prompt('NPC display name (e.g. Prof. Almira Vetch)')?.trim()
if (!name) return
const key = window.prompt('Short key (e.g. professor)', name.toLowerCase().split(/\s+/).pop() || '')?.trim()
if (!key) return
try {
await json('/api/admin/npcs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key, name }) })
await reload(key.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, ''))
setStatus(`Created ${name}`)
} catch (error) { setStatus(String((error as Error).message || error)) }
}
function NpcEditor({ npc, onChanged, setStatus }: { npc: Npc; onChanged: (key?: string) => Promise<void>; setStatus: (message: string) => void }) {
const [name, setName] = useState(npc.name)
const [role, setRole] = useState(npc.role)
const [defaultPose, setDefaultPose] = useState(npc.defaultPose || '')
const [poseKey, setPoseKey] = useState('')
const [busy, setBusy] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const dirty = name !== npc.name || role !== npc.role || (defaultPose || null) !== npc.defaultPose
const save = async () => {
setBusy(true)
try {
await json(`/api/admin/npcs/${npc.id}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, role, defaultPose: defaultPose || null }) })
await onChanged(npc.key); setStatus(`Saved ${name}`)
} catch (error) { setStatus(String((error as Error).message || error)) } finally { setBusy(false) }
}
const uploadPose = async (file: File) => {
const key = (poseKey || file.name.replace(/\.[^.]+$/, '')).trim()
setBusy(true)
try {
const form = new FormData(); form.append('file', file); form.append('poseKey', key)
await json(`/api/admin/npcs/${npc.id}/poses`, { method: 'POST', body: form })
setPoseKey(''); if (fileRef.current) fileRef.current.value = ''
await onChanged(npc.key); setStatus(`Uploaded pose “${key}`)
} catch (error) { setStatus(String((error as Error).message || error)) } finally { setBusy(false) }
}
const removePose = async (key: string) => {
if (!window.confirm(`Remove pose “${key}”?`)) return
try { await json(`/api/admin/npcs/${npc.id}/poses/${encodeURIComponent(key)}`, { method: 'DELETE' }); await onChanged(npc.key) }
catch (error) { setStatus(String((error as Error).message || error)) }
}
const remove = async () => {
if (!window.confirm(`Delete NPC ${npc.name}? This cannot be undone.`)) return
try { await json(`/api/admin/npcs/${npc.id}`, { method: 'DELETE' }); await onChanged() }
catch (error) { setStatus(String((error as Error).message || error)) }
}
return <section className="npc-editor">
<div className="npc-editor-head">
<h2>{npc.name || npc.key}</h2>
<code>{npc.key}</code>
<button className="danger" disabled={npc.inUse} title={npc.inUse ? 'Used by a cutscene' : 'Delete NPC'} onClick={remove}>DELETE</button>
</div>
<div className="admin-field"><label>Display name</label><input value={name} onChange={event => setName(event.target.value)} /></div>
<div className="admin-field"><label>Role / affiliation</label><input value={role} onChange={event => setRole(event.target.value)} placeholder="Glitch University · Investigative Method" /></div>
<div className="admin-field"><label>Default pose</label>
<select value={defaultPose} onChange={event => setDefaultPose(event.target.value)}>
<option value=""> none </option>
{npc.poses.map(pose => <option key={pose.poseKey} value={pose.poseKey}>{pose.poseKey}</option>)}
</select>
</div>
<button className="admin-save" disabled={!dirty || busy} onClick={save}>{busy ? 'SAVING…' : dirty ? 'SAVE CHANGES' : 'SAVED'}</button>
<h3>Poses</h3>
<div className="pose-grid">
{npc.poses.map(pose => <figure key={pose.poseKey} className={`pose-card${pose.poseKey === defaultPose ? ' is-default' : ''}`}>
<img src={pose.url} alt={pose.poseKey} />
<figcaption>{pose.poseKey}</figcaption>
<button className="pose-remove" title="Remove pose" onClick={() => removePose(pose.poseKey)}>×</button>
</figure>)}
{npc.poses.length === 0 && <p className="admin-empty">No poses yet. Upload a portrait below.</p>}
</div>
<div className="pose-upload">
<input className="pose-key" value={poseKey} onChange={event => setPoseKey(event.target.value)} placeholder="pose key (e.g. neutral)" />
<input ref={fileRef} type="file" accept="image/*" onChange={event => { const file = event.target.files?.[0]; if (file) void uploadPose(file) }} />
</div>
<small className="admin-hint">Poses referenced in dialogue fall back to the default pose, then to no artwork. Upload big portraits they fill the screen in cutscenes.</small>
</section>
}
+217
View File
@@ -0,0 +1,217 @@
import { describe, expect, it } from 'vitest'
import type { CaseState, ExhibitRelation, FolderExhibit } from './types'
import {
MAX_BOARD_ZOOM,
MIN_BOARD_ZOOM,
clampBoardZoom,
containedIds,
discardExhibit,
folderIsOpen,
moveBoardPoint,
nextOpenBoardPosition,
normalizeCase,
panViewport,
relationPosition,
projectThreadTag,
threadCurve,
threadTagLateralLimit,
threadTagPlacement,
timelinePositionPercent,
timelineRange,
zoomFromWheel,
zoomFromPinch,
zoomViewportAt,
} from './boardDomain'
describe('red thread geometry', () => {
it('turns tightness into a taut or visibly sagging bezier curve', () => {
const taut = threadCurve({ x: 0, y: 0 }, { x: 120, y: 60 }, 100)
const slack = threadCurve({ x: 0, y: 0 }, { x: 120, y: 60 }, 0)
expect(taut.midpoint).toEqual({ x: 60, y: 30 })
expect(slack.midpoint.y).toBeGreaterThan(taut.midpoint.y)
expect(slack.path).toMatch(/^M 0 0 C /)
})
it('clamps tightness to its normalized percentage range', () => {
expect(threadCurve({ x: 0, y: 0 }, { x: 100, y: 0 }, 200)).toEqual(threadCurve({ x: 0, y: 0 }, { x: 100, y: 0 }, 100))
})
it('places and projects tags by percentage along the curve', () => {
const from = { x: 0, y: 0 }, to = { x: 200, y: 0 }
expect(threadTagPlacement(from, to, 100, 25, 0)).toMatchObject({ x: 50, y: 0, positionPercent: 25 })
expect(projectThreadTag(from, to, 100, { x: 150, y: 8 })).toMatchObject({ positionPercent: 75, lateralOffset: 8 })
})
it('reduces lateral tag travel as the thread becomes taut', () => {
expect(threadTagLateralLimit(0)).toBe(70)
expect(threadTagLateralLimit(100)).toBe(10)
expect(threadTagPlacement({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, 50, 100).lateralOffset).toBe(10)
})
})
const folder: FolderExhibit = {
id: 'folder-1',
type: 'folder',
title: 'Folder',
content: '',
x: 200,
y: 300,
width: 260,
height: 166,
rotation: 0,
zIndex: 1,
hidden: false,
isOpen: false,
}
const state: CaseState = {
brief: { body: '', concepts: [] },
id: 'test-level',
title: 'Test',
subtitle: '',
exhibits: [folder],
relations: [],
connections: [],
viewport: { x: 10, y: 20, zoom: 0.5 },
views: [],
revision: 0,
}
describe('board coordinate math', () => {
it('moves board objects by screen distance divided by zoom', () => {
expect(moveBoardPoint({ x: 100, y: 80 }, { x: 50, y: -25 }, 0.5)).toEqual({ x: 200, y: 30 })
expect(moveBoardPoint({ x: 100, y: 80 }, { x: 50, y: -25 }, 1.25)).toEqual({ x: 140, y: 60 })
})
it('rejects invalid zoom during coordinate conversion', () => {
expect(() => moveBoardPoint({ x: 0, y: 0 }, { x: 10, y: 10 }, 0)).toThrow(RangeError)
})
it('pans in screen coordinates without scaling the delta', () => {
expect(panViewport(state.viewport, { x: 25, y: -10 })).toEqual({ x: 35, y: 10, zoom: 0.5 })
})
it('clamps toolbar, wheel, and touch pinch zoom to the same limits', () => {
expect(clampBoardZoom(-10)).toBe(MIN_BOARD_ZOOM)
expect(clampBoardZoom(10)).toBe(MAX_BOARD_ZOOM)
expect(zoomFromWheel(1, 10_000)).toBe(MIN_BOARD_ZOOM)
expect(zoomFromWheel(1, -10_000)).toBe(MAX_BOARD_ZOOM)
expect(zoomFromWheel(1, 100)).toBeCloseTo(0.85)
expect(zoomFromPinch(1, 100, 125)).toBeCloseTo(1.25)
expect(zoomFromPinch(1, 100, 50)).toBeCloseTo(0.5)
expect(zoomFromPinch(1, 0, 120)).toBe(1)
})
it('keeps the board point beneath the cursor fixed while zooming', () => {
const anchor = { x: 110, y: 120 }
const zoomed = zoomViewportAt(state.viewport, 1, anchor)
expect(zoomed).toEqual({ x: -90, y: -80, zoom: 1 })
expect(zoomed.x + 200 * zoomed.zoom).toBe(anchor.x)
expect(zoomed.y + 200 * zoomed.zoom).toBe(anchor.y)
expect(zoomViewportAt(zoomed, .5, anchor)).toEqual(state.viewport)
})
it('places new exhibits in deterministic open slots', () => {
const preferred = { x: 500, y: 400 }
expect(nextOpenBoardPosition([], preferred, { width: 280 })).toEqual(preferred)
expect(nextOpenBoardPosition([{ x: 500, y: 400, width: 280, height: 160 }], preferred, { width: 280 })).toEqual({ x: 826, y: 400 })
expect(nextOpenBoardPosition([
{ x: 500, y: 400, width: 280, height: 160 },
{ x: 826, y: 400, width: 280, height: 160 },
], preferred, { width: 280 })).toEqual({ x: 174, y: 400 })
})
})
describe('timeline projection', () => {
it('creates at least a four-year range around a single year', () => {
const range = timelineRange(['2021-03-12'])
expect(range.endYear - range.startYear).toBe(4)
expect(range.startYear).toBeLessThanOrEqual(2021)
expect(range.endYear).toBeGreaterThanOrEqual(2021)
})
it('uses all supplied years when the evidence spans a wider range', () => {
expect(timelineRange(['2012-01-01', '2024-06-01'])).toMatchObject({ startYear: 2012, endYear: 2024 })
})
it('projects dates proportionally and clamps dates outside the range', () => {
const range = timelineRange(['2020-01-01', '2024-12-31'])
expect(timelinePositionPercent('2010-01-01', range)).toBe(0)
expect(timelinePositionPercent('2030-01-01', range)).toBe(100)
expect(timelinePositionPercent('2022-07-02', range)).toBeGreaterThan(49)
expect(timelinePositionPercent('2022-07-02', range)).toBeLessThan(51)
})
it('uses a valid configured date range without automatic year padding', () => {
const range = timelineRange(['1987-01-08', '1987-10-24'], 2026, { start: '1987-10-01', end: '1987-10-31' })
expect(new Date(range.start).toISOString()).toBe('1987-10-01T00:00:00.000Z')
expect(new Date(range.end).toISOString()).toBe('1987-10-31T23:59:59.999Z')
expect(timelinePositionPercent('1987-10-16', range)).toBeGreaterThan(48)
expect(timelinePositionPercent('1987-10-16', range)).toBeLessThan(52)
})
})
describe('folder domain behavior', () => {
const relations: ExhibitRelation[] = [
{ id: 'later', fromExhibitId: folder.id, toExhibitId: 'doc-2', type: 'contains', sortOrder: 2 },
{ id: 'other', fromExhibitId: 'folder-2', toExhibitId: 'doc-x', type: 'contains', sortOrder: 0 },
{ id: 'first', fromExhibitId: folder.id, toExhibitId: 'doc-1', type: 'contains', sortOrder: 0 },
]
it('orders and scopes contained documents by their normalized relations', () => {
expect(containedIds({ ...state, relations }, folder.id)).toEqual(['doc-1', 'doc-2'])
})
it('stores the open state explicitly on the folder exhibit', () => {
expect(folderIsOpen(folder)).toBe(false)
expect(folderIsOpen({ ...folder, isOpen: true })).toBe(true)
})
it('retains a configured expanded file position', () => {
const document = { id: 'doc-2', type: 'document' as const, title: 'Source', x: 720, y: 415, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, metadata: {} }
expect(relationPosition({ ...state, exhibits: [...state.exhibits, document], relations }, relations[0])).toEqual({ x: 720, y: 415 })
})
it('derives a deterministic position when a relation has not been moved', () => {
expect(relationPosition({ ...state, relations }, relations[2])).toEqual({ x: 550, y: 270 })
expect(relationPosition({ ...state, relations }, relations[0])).toEqual({ x: 960, y: 270 })
})
it('normalizes legacy containment without changing source ownership', () => {
const legacy = {
id: state.id, title: state.title, subtitle: state.subtitle, viewport: state.viewport, brief: state.brief,
documents: [{ id: 'doc-1', title: 'Image', kind: 'IMAGE', date: '', body: [], regions: [], mimeType: 'image/png' }],
evidence: [{ ...folder, type: 'evidence', sourceDocumentId: 'doc-1', containedDocumentIds: ['doc-1'], config: undefined }],
relations: undefined,
}
const normalized = normalizeCase(legacy)
expect(normalized.exhibits.find(exhibit => exhibit.type === 'folder')).toMatchObject({ type: 'folder', isOpen: false })
expect(normalized.exhibits.find(exhibit => exhibit.type === 'document')).toMatchObject({ fileType: 'image', metadata: {} })
expect(normalized.relations).toHaveLength(1)
})
})
describe('exhibit disposal', () => {
it('removes an exhibit and every graph reference while retaining unrelated exhibits', () => {
const note = { ...folder, id: 'note-1', type: 'note' as const, title: 'Working note' }
const event = { ...folder, id: 'event-1', type: 'event' as const, eventDate: undefined }
const party = { ...folder, id: 'party-1', type: 'party' as const, partyKind: 'person' as const, aliases: [] }
const document = { id: 'doc-1', type: 'document' as const, title: 'Source', x: 20, y: 20, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, metadata: {} }
const discarded = discardExhibit({
...state,
exhibits: [folder, document, note, event, party],
relations: [
{ id: 'supports', fromExhibitId: event.id, toExhibitId: note.id, type: 'supports', sortOrder: 0 },
{ id: 'concerns', fromExhibitId: party.id, toExhibitId: note.id, type: 'concerns', sortOrder: 0 },
],
connections: [{ id: 'thread', fromExhibitId: note.id, toExhibitId: party.id }],
brief: { body: '', concepts: [{ id: 'concept-1', label: 'Unknown', context: '', resolvedPartyExhibitId: note.id }] },
}, note.id)
expect(discarded.exhibits).toContainEqual(document)
expect(discarded.exhibits.map(exhibit => exhibit.id)).not.toContain(note.id)
expect(discarded.relations).toEqual([])
expect(discarded.connections).toEqual([])
expect(discarded.brief.concepts[0].resolvedPartyExhibitId).toBeUndefined()
})
})
+281
View File
@@ -0,0 +1,281 @@
import type { BoardView, CaseState, Connection, Exhibit, ExhibitRelation, FolderExhibit, OrganizationKind, SourceFileType, TimelineRange, Viewport } from './types'
export interface BoardPoint { x: number; y: number }
function threadControls(from: BoardPoint, to: BoardPoint, tightness = 65) {
const tautness = Math.max(0, Math.min(100, Number(tightness) || 0)) / 100
const distance = Math.hypot(to.x - from.x, to.y - from.y)
const sag = (1 - tautness) * Math.min(190, Math.max(45, distance * .28))
const c1 = { x: from.x + (to.x - from.x) / 3, y: from.y + (to.y - from.y) / 3 + sag }
const c2 = { x: from.x + (to.x - from.x) * 2 / 3, y: from.y + (to.y - from.y) * 2 / 3 + sag }
return { c1, c2 }
}
function cubicPoint(from: BoardPoint, c1: BoardPoint, c2: BoardPoint, to: BoardPoint, t: number) {
const inverse = 1 - t
return {
x: inverse ** 3 * from.x + 3 * inverse ** 2 * t * c1.x + 3 * inverse * t ** 2 * c2.x + t ** 3 * to.x,
y: inverse ** 3 * from.y + 3 * inverse ** 2 * t * c1.y + 3 * inverse * t ** 2 * c2.y + t ** 3 * to.y,
}
}
function cubicTangent(from: BoardPoint, c1: BoardPoint, c2: BoardPoint, to: BoardPoint, t: number) {
const inverse = 1 - t
return {
x: 3 * inverse ** 2 * (c1.x - from.x) + 6 * inverse * t * (c2.x - c1.x) + 3 * t ** 2 * (to.x - c2.x),
y: 3 * inverse ** 2 * (c1.y - from.y) + 6 * inverse * t * (c2.y - c1.y) + 3 * t ** 2 * (to.y - c2.y),
}
}
export function threadTagLateralLimit(tightness = 65) {
const normalized = Math.max(0, Math.min(100, Number(tightness) || 0))
return Math.round(10 + (100 - normalized) * .6)
}
export function threadTagPlacement(from: BoardPoint, to: BoardPoint, tightness = 65, positionPercent = 50, lateralOffset = 0) {
const { c1, c2 } = threadControls(from, to, tightness)
const position = Math.max(5, Math.min(95, Number(positionPercent) || 50))
const t = position / 100
const point = cubicPoint(from, c1, c2, to, t)
const tangent = cubicTangent(from, c1, c2, to, t)
const length = Math.hypot(tangent.x, tangent.y) || 1
const normal = { x: -tangent.y / length, y: tangent.x / length }
const maxLateralOffset = threadTagLateralLimit(tightness)
const offset = Math.max(-maxLateralOffset, Math.min(maxLateralOffset, Number(lateralOffset) || 0))
return { x: point.x + normal.x * offset, y: point.y + normal.y * offset, positionPercent: position, lateralOffset: offset, maxLateralOffset }
}
export function projectThreadTag(from: BoardPoint, to: BoardPoint, tightness: number, pointer: BoardPoint) {
const { c1, c2 } = threadControls(from, to, tightness)
let bestT = .5
let bestDistance = Number.POSITIVE_INFINITY
for (let index = 5; index <= 95; index += 1) {
const t = index / 100
const point = cubicPoint(from, c1, c2, to, t)
const distance = (pointer.x - point.x) ** 2 + (pointer.y - point.y) ** 2
if (distance < bestDistance) { bestDistance = distance; bestT = t }
}
const point = cubicPoint(from, c1, c2, to, bestT)
const tangent = cubicTangent(from, c1, c2, to, bestT)
const length = Math.hypot(tangent.x, tangent.y) || 1
const normal = { x: -tangent.y / length, y: tangent.x / length }
const maxLateralOffset = threadTagLateralLimit(tightness)
const lateralOffset = Math.max(-maxLateralOffset, Math.min(maxLateralOffset, (pointer.x - point.x) * normal.x + (pointer.y - point.y) * normal.y))
return { positionPercent: Math.round(bestT * 100), lateralOffset: Math.round(lateralOffset), maxLateralOffset }
}
export function threadCurve(from: BoardPoint, to: BoardPoint, tightness = 65) {
const { c1, c2 } = threadControls(from, to, tightness)
const midpoint = cubicPoint(from, c1, c2, to, .5)
return { path: `M ${from.x} ${from.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${to.x} ${to.y}`, midpoint }
}
export const MIN_BOARD_ZOOM = 0.45
export const MAX_BOARD_ZOOM = 1.5
export function clampBoardZoom(zoom: number) {
return Math.max(MIN_BOARD_ZOOM, Math.min(MAX_BOARD_ZOOM, zoom))
}
export function zoomFromWheel(currentZoom: number, deltaY: number) {
return clampBoardZoom(currentZoom - deltaY * 0.0015)
}
export function zoomFromPinch(currentZoom: number, previousDistance: number, nextDistance: number) {
if (!Number.isFinite(previousDistance) || !Number.isFinite(nextDistance) || previousDistance <= 0 || nextDistance <= 0) return currentZoom
return clampBoardZoom(currentZoom * nextDistance / previousDistance)
}
export function zoomViewportAt(viewport: Viewport, nextZoom: number, screenAnchor: BoardPoint): Viewport {
if (!Number.isFinite(viewport.zoom) || viewport.zoom <= 0) throw new RangeError('Board zoom must be positive')
const zoom = clampBoardZoom(nextZoom)
const boardPoint = { x: (screenAnchor.x - viewport.x) / viewport.zoom, y: (screenAnchor.y - viewport.y) / viewport.zoom }
return { x: screenAnchor.x - boardPoint.x * zoom, y: screenAnchor.y - boardPoint.y * zoom, zoom }
}
export function moveBoardPoint(origin: { x: number; y: number }, screenDelta: { x: number; y: number }, zoom: number) {
if (!Number.isFinite(zoom) || zoom <= 0) throw new RangeError('Board zoom must be positive')
return { x: origin.x + screenDelta.x / zoom, y: origin.y + screenDelta.y / zoom }
}
export function nextOpenBoardPosition(
evidence: Pick<Exhibit, 'x' | 'y' | 'width' | 'height'>[],
preferred: { x: number; y: number },
size: { width: number; height?: number },
bounds = { width: 2400, height: 1500 },
) {
const height = size.height || 160
const gap = 28
const overlaps = (x: number, y: number) => evidence.some(item =>
x < item.x + item.width + gap && x + size.width + gap > item.x &&
y < item.y + item.height + gap && y + height + gap > item.y)
const xStep = size.width + 46
const yStep = height + 46
for (let row = 0; row < 7; row += 1) {
for (const column of [0, 1, -1, 2, -2, 3, -3]) {
const x = Math.max(80, Math.min(bounds.width - size.width - 80, preferred.x + column * xStep))
const y = Math.max(100, Math.min(bounds.height - height - 80, preferred.y + row * yStep))
if (!overlaps(x, y)) return { x, y }
}
}
return { x: Math.max(80, Math.min(bounds.width - size.width - 80, preferred.x)), y: Math.min(bounds.height - height - 80, preferred.y + evidence.length * 24) }
}
export function panViewport(origin: Viewport, screenDelta: { x: number; y: number }): Viewport {
return { ...origin, x: origin.x + screenDelta.x, y: origin.y + screenDelta.y }
}
export function dateValue(date: string) {
const parsed = Date.parse(date)
return Number.isFinite(parsed) ? parsed : 0
}
export function timelineRange(dates: string[], fallbackYear = new Date().getFullYear(), configured?: TimelineRange) {
if (configured) {
const start = Date.parse(`${configured.start}T00:00:00.000Z`)
const end = Date.parse(`${configured.end}T23:59:59.999Z`)
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
return { startYear: Number(configured.start.slice(0, 4)), endYear: Number(configured.end.slice(0, 4)), start, end }
}
}
const years = dates
.map(date => Number(date.slice(0, 4)))
.filter(year => Number.isFinite(year) && year >= 1 && year <= 9999)
let startYear = years.length ? Math.min(...years) : fallbackYear - 2
let endYear = years.length ? Math.max(...years) : startYear + 4
if (endYear - startYear < 4) {
const missing = 4 - (endYear - startYear)
startYear -= Math.floor(missing / 2)
endYear += Math.ceil(missing / 2)
}
return { startYear, endYear, start: Date.UTC(startYear, 0, 1), end: Date.UTC(endYear, 11, 31) }
}
export function timelinePositionPercent(date: string, range: Pick<ReturnType<typeof timelineRange>, 'start' | 'end'>) {
if (range.end <= range.start) throw new RangeError('Timeline range must have positive duration')
return Math.max(0, Math.min(100, ((dateValue(date) - range.start) / (range.end - range.start)) * 100))
}
export function containedIds(state: CaseState, widgetId: string) {
return state.relations
.filter(relation => relation.type === 'contains' && relation.fromExhibitId === widgetId)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
.map(relation => relation.toExhibitId)
}
export function folderIsOpen(folder: FolderExhibit) {
return folder.isOpen
}
export function relationPosition(state: CaseState, relation: ExhibitRelation) {
const target = state.exhibits.find(exhibit => exhibit.id === relation.toExhibitId)
if (target) return { x: target.x, y: target.y }
const folder = state.exhibits.find(exhibit => exhibit.id === relation.fromExhibitId)
const order = relation.sortOrder || 0
return {
x: (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205,
y: (folder?.y || 100) - 30 + Math.floor(order / 3) * 185,
}
}
export function discardExhibit(state: CaseState, exhibitId: string): CaseState {
if (!state.exhibits.some(exhibit => exhibit.id === exhibitId)) return state
return {
...state,
exhibits: state.exhibits.filter(exhibit => exhibit.id !== exhibitId),
relations: state.relations.filter(relation => relation.fromExhibitId !== exhibitId && relation.toExhibitId !== exhibitId),
connections: state.connections.filter(connection => connection.fromExhibitId !== exhibitId && connection.toExhibitId !== exhibitId),
brief: {
...state.brief,
concepts: state.brief.concepts.map(concept => concept.resolvedPartyExhibitId === exhibitId
? { ...concept, resolvedPartyExhibitId: undefined }
: concept),
},
}
}
export function defaultTimelineView(range?: TimelineRange | null): BoardView {
return { id: crypto.randomUUID(), type: 'timeline', placement: { mode: 'docked', dockEdge: 'bottom', size: 112 }, visible: true, zIndex: 0,
rangeMode: range ? 'fixed' : 'auto', range: range || undefined }
}
type LegacyCaseState = {
id: string
title: string
subtitle: string
viewport: Viewport
brief?: CaseState['brief']
updatedAt?: string
levelStatus?: string
sourceTemplateVersionId?: string
editingAllowed?: boolean
revision?: number
exhibits?: Exhibit[]
views?: BoardView[]
documents?: Array<Record<string, unknown>>
evidence?: Array<Record<string, unknown>>
timelineRange?: TimelineRange | null
relations?: Array<Record<string, unknown>>
connections?: Array<Record<string, unknown>>
}
function placement(item: Record<string, unknown>, defaults: { width: number; height: number }, index: number) {
return { x: Number(item.x ?? 100), y: Number(item.y ?? 100), width: Number(item.width ?? defaults.width), height: Number(item.height ?? defaults.height),
rotation: Number(item.rotation ?? 0), zIndex: Number(item.zIndex ?? index), hidden: Boolean(item.hidden) }
}
function sourceFileType(value: unknown, mimeType: unknown): SourceFileType {
const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
if (allowed.includes(value as SourceFileType)) return value as SourceFileType
return String(mimeType || '').startsWith('image/') ? 'image' : mimeType === 'application/pdf' ? 'pdf' : 'file'
}
/** Normalizes current API state and upgrades disposable pre-registry browser caches. */
export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
const state = input as LegacyCaseState
if (Array.isArray(state.exhibits)) {
return { id: state.id, title: state.title, subtitle: state.subtitle, viewport: state.viewport,
relations: (state.relations || []) as unknown as ExhibitRelation[], connections: (state.connections || []) as unknown as Connection[],
revision: Number(state.revision || 0), brief: state.brief || { body: '', concepts: [] },
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) })),
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
}
}
const legacyEvidence = state.evidence || []
const legacyRelations = state.relations || []
const documentPositions = new Map(legacyRelations.filter(relation => relation.type === 'contains').map(relation => [String(relation.toWidgetId), {
x: Number((relation.config as Record<string, unknown> | undefined)?.x ?? 100), y: Number((relation.config as Record<string, unknown> | undefined)?.y ?? 100),
}]))
const documents: Exhibit[] = (state.documents || []).map((document, index) => ({
id: String(document.id), type: 'document', title: String(document.title || ''),
...placement({ ...document, ...(documentPositions.get(String(document.id)) || {}) }, { width: 174, height: 145 }, index),
publishedAt: String(document.publishedAt || document.date || '') || undefined, capturedAt: String(document.capturedAt || '') || undefined,
sourceUri: String(document.sourceUri || '') || undefined, body: Array.isArray(document.body) ? document.body.map(String) : [],
regions: Array.isArray(document.regions) ? document.regions as never[] : [], assetId: String(document.assetId || '') || undefined,
fileName: String(document.fileName || '') || undefined, mimeType: String(document.mimeType || '') || undefined,
fileSize: document.fileSize === undefined ? undefined : Number(document.fileSize),
fileType: sourceFileType(document.fileType, document.mimeType),
metadata: document.metadata && typeof document.metadata === 'object' ? document.metadata as Record<string, string> : {},
} as Exhibit))
const evidence: Exhibit[] = legacyEvidence.map((item, index) => {
const type = item.type === 'evidence' ? 'folder' : item.type
const common = { id: String(item.id), type, title: String(item.title || ''), content: String(item.content || ''), ...placement(item, { width: 240, height: 160 }, documents.length + index) }
if (type === 'folder') return { ...common, type: 'folder', isOpen: (item.config as Record<string, unknown> | undefined)?.open === true }
if (type === 'event') return { ...common, type: 'event', eventDate: String(item.eventDate || '') || undefined }
if (type === 'party') return { ...common, type: 'party', partyKind: item.partyKind === 'organization' ? 'organization' : 'person', organizationKind: item.organizationKind as OrganizationKind | undefined, aliases: Array.isArray(item.aliases) ? item.aliases.map(String) : [] }
return { ...common, type: 'note' }
})
const derivedRelations: ExhibitRelation[] = [
...legacyRelations.filter(relation => relation.type === 'contains').map(relation => ({ id: String(relation.id), type: 'contains' as const, fromExhibitId: String(relation.fromWidgetId), toExhibitId: String(relation.toWidgetId), sortOrder: Number(relation.sortOrder || 0) })),
...legacyEvidence.flatMap(item => Array.isArray(item.supportingEvidenceIds) ? item.supportingEvidenceIds.map((id, index) => ({ id: `supports:${item.id}:${id}`, type: 'supports' as const, fromExhibitId: String(item.id), toExhibitId: String(id), sortOrder: index })) : []),
...legacyEvidence.flatMap(item => Array.isArray(item.relatedEvidenceIds) ? item.relatedEvidenceIds.map((id, index) => ({ id: `concerns:${item.id}:${id}`, type: 'concerns' as const, fromExhibitId: String(item.id), toExhibitId: String(id), sortOrder: index })) : []),
...legacyEvidence.flatMap(item => item.sourceDocumentId ? [{ id: `source:${item.id}`, type: 'source' as const, fromExhibitId: String(item.id), toExhibitId: String(item.sourceDocumentId), sourceRegionId: String(item.sourceRegionId || '') || undefined, sortOrder: 0 }] : []),
]
const connections: Connection[] = (state.connections || []).map(connection => ({ ...connection, id: String(connection.id),
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,
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 }
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import type { SourceFileType } from './types'
import { documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetRegistry } from './exhibitRegistry'
describe('frontend exhibit registry', () => {
it('registers every normalized exhibit type', () => {
const types = ['folder', 'document', 'note', 'event', 'party'] as const
expect(Object.keys(exhibitWidgetRegistry).sort()).toEqual([...types].sort())
expect(exhibitWidget('event').heading({} as never, { exhibits: [], relations: [], dispatch: () => {} })).toContain('THIS HAPPENED')
})
it('registers every normalized document type with an explicit renderer', () => {
const types: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
expect(Object.keys(documentWidgetRegistry).sort()).toEqual(types.sort())
for (const type of types) {
expect(documentWidget(type).label).toBeTruthy()
expect(documentWidget(type).Preview).toBeTypeOf('function')
expect(documentWidget(type).Asset).toBeTypeOf('function')
}
})
})
+129
View File
@@ -0,0 +1,129 @@
import type { ComponentType } from 'react'
import { BookOpen, Building2, CalendarClock, FileText, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
import type { CaseDocument, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, SourceFileType, TemporalFact } from './types'
export type WidgetCommand =
| { type: 'open-document'; documentId: string }
| { type: 'edit-folder'; folderId: string }
| { type: 'edit-event'; eventId: string }
| { type: 'edit-party'; partyId: string }
| { type: 'edit-document'; documentId: string }
| { type: 'update-memory-cue'; documentId: string; cue: string }
export type ExhibitWidgetContext = {
exhibits: Exhibit[]
relations: ExhibitRelation[]
dispatch: (command: WidgetCommand) => void
}
export type ExhibitWidgetProps = { exhibit: Exhibit; context: ExhibitWidgetContext }
export type WidgetCapabilities = { movable: boolean; resizable: boolean; connectable: boolean; discardable: boolean; dockable: boolean }
export type ConnectionPort = { id: string; x: number; y: number }
export type ExhibitWidgetDefinition = {
modelKind: 'exhibit'
visualType: ExhibitType
shell: 'card' | 'document'
defaultSize: { width: number; height: number }
capabilities: WidgetCapabilities
heading: (exhibit: Exhibit, context: ExhibitWidgetContext) => string
connectionPorts: (exhibit: Exhibit) => ConnectionPort[]
temporalFacts: (exhibit: Exhibit) => TemporalFact[]
searchText: (exhibit: Exhibit) => string
Component: ComponentType<ExhibitWidgetProps>
}
const relationsFrom = (context: ExhibitWidgetContext, exhibitId: string, type: ExhibitRelation['type']) => context.relations
.filter(relation => relation.type === type && relation.fromExhibitId === exhibitId)
.sort((a, b) => a.sortOrder - b.sortOrder)
function FolderWidget({ exhibit, context }: ExhibitWidgetProps) {
if (exhibit.type !== 'folder') return null
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
<div className="folder-actions"><button onClick={() => context.dispatch({ type:'edit-folder',folderId:exhibit.id })}><Pencil size={15}/> EDIT</button></div>
</div>
}
function NoteWidget({ exhibit, context }: ExhibitWidgetProps) {
if (exhibit.type !== 'note') return null
const source = relationsFrom(context, exhibit.id, 'source')[0]
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
{source && <button onClick={() => context.dispatch({ type:'open-document',documentId:source.toExhibitId })}><BookOpen size={13}/> VIEW SOURCE</button>}
</div>
}
function EventWidget({ exhibit, context }: ExhibitWidgetProps) {
if (exhibit.type !== 'event') return null
const supportCount = relationsFrom(context,exhibit.id,'supports').length
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
<time>{exhibit.eventDate ? new Date(exhibit.eventDate).toLocaleString() : 'UNDATED'}</time>
<div className="event-actions"><span>{supportCount} SUPPORTING EXHIBIT{supportCount === 1 ? '' : 'S'}</span><button onClick={() => context.dispatch({ type:'edit-event',eventId:exhibit.id })}><CalendarClock size={12}/> EDIT EVENT</button></div>
</div>
}
function PartyWidget({ exhibit, context }: ExhibitWidgetProps) {
if (exhibit.type !== 'party') return null
const person = exhibit.partyKind === 'person'
const evidenceCount = relationsFrom(context,exhibit.id,'concerns').length
return <div className="card-content"><div className="party-identity">{person ? <UserRound size={28}/> : <Building2 size={28}/>}<div><h3>{exhibit.title}</h3><small>{person ? 'PERSON' : (exhibit.organizationKind || 'ORGANIZATION').replaceAll('_',' ').toUpperCase()}</small></div></div>
<p>{exhibit.content || 'No dossier summary yet.'}</p>
{exhibit.aliases.length > 0 && <div className="party-aliases">AKA · {exhibit.aliases.join(' · ')}</div>}
<div className="event-actions"><span>{evidenceCount} ASSOCIATED EXHIBITS</span><button onClick={() => context.dispatch({ type:'edit-party',partyId:exhibit.id })}><Pencil size={12}/> EDIT DOSSIER</button></div>
</div>
}
function DocumentWidget({ exhibit }: ExhibitWidgetProps) {
if (exhibit.type !== 'document') return null
return <strong>{exhibit.title}</strong>
}
const standardPorts = (exhibit: Exhibit) => [{ id:'centre',x:exhibit.x + exhibit.width / 2,y:exhibit.y + exhibit.height / 2 }]
const notePorts = (exhibit: Exhibit) => [{ id:'knot',x:exhibit.x + exhibit.width / 2,y:exhibit.y + 12 }]
const standardCapabilities: WidgetCapabilities = { movable:true,resizable:false,connectable:true,discardable:true,dockable:false }
const searchable = (exhibit: Exhibit) => exhibit.type === 'document'
? [exhibit.title,...exhibit.body,...Object.values(exhibit.metadata)].join('\n').toLocaleLowerCase()
: [exhibit.title,exhibit.content].join('\n').toLocaleLowerCase()
export const exhibitWidgetRegistry: Record<ExhibitType, ExhibitWidgetDefinition> = {
folder: { modelKind:'exhibit',visualType:'folder',shell:'card',defaultSize:{width:260,height:166},capabilities:standardCapabilities,
heading:(exhibit,context) => `EVIDENCE FOLDER / ${relationsFrom(context,exhibit.id,'contains').length}`,connectionPorts:standardPorts,temporalFacts:() => [],searchText:searchable,Component:FolderWidget },
document: { modelKind:'exhibit',visualType:'document',shell:'document',defaultSize:{width:174,height:145},capabilities:standardCapabilities,
heading:exhibit => exhibit.type === 'document' ? documentWidget(exhibit.fileType).label.toUpperCase() : 'DOCUMENT',connectionPorts:standardPorts,
temporalFacts:exhibit => exhibit.type === 'document' ? [
...(exhibit.publishedAt ? [{ id:`${exhibit.id}:published`,exhibitId:exhibit.id,kind:'published' as const,start:exhibit.publishedAt,label:exhibit.title }] : []),
...(exhibit.capturedAt ? [{ id:`${exhibit.id}:captured`,exhibitId:exhibit.id,kind:'captured' as const,start:exhibit.capturedAt,label:`${exhibit.title} captured` }] : []),
...exhibit.regions.flatMap(region => region.date ? [{ id:`${exhibit.id}:region:${region.id}`,exhibitId:exhibit.id,kind:'region_date' as const,start:region.date,label:region.label }] : []),
] : [],searchText:searchable,Component:DocumentWidget },
note: { modelKind:'exhibit',visualType:'note',shell:'card',defaultSize:{width:108,height:154},capabilities:standardCapabilities,
heading:() => 'INVESTIGATOR / NOTE',connectionPorts:notePorts,temporalFacts:() => [],searchText:searchable,Component:NoteWidget },
event: { modelKind:'exhibit',visualType:'event',shell:'card',defaultSize:{width:270,height:174},capabilities:standardCapabilities,
heading:() => 'EVENT / THIS HAPPENED',connectionPorts:standardPorts,temporalFacts:exhibit => exhibit.type === 'event' && exhibit.eventDate
? [{ id:`${exhibit.id}:occurred`,exhibitId:exhibit.id,kind:'occurred',start:exhibit.eventDate,label:exhibit.content }] : [],searchText:searchable,Component:EventWidget },
party: { modelKind:'exhibit',visualType:'party',shell:'card',defaultSize:{width:280,height:190},capabilities:standardCapabilities,
heading:exhibit => exhibit.type === 'party' && exhibit.partyKind === 'person' ? 'PARTY / PERSON DOSSIER' : 'PARTY / ORGANIZATION DOSSIER',connectionPorts:standardPorts,temporalFacts:() => [],searchText:searchable,Component:PartyWidget },
}
export function exhibitWidget(type: ExhibitType) { return exhibitWidgetRegistry[type] }
type DocumentWidgetProps = { document: CaseDocument; source: string; onMemoryCue?: (cue: string) => void }
export type DocumentWidgetDefinition = { label:string; Preview:ComponentType<DocumentWidgetProps>; Asset:ComponentType<DocumentWidgetProps> }
function ImagePreview({ document,source }: DocumentWidgetProps) { return document.assetId ? <img draggable={false} src={source} alt=""/> : <GenericPreview document={document} source={source}/> }
function GenericPreview({ document }: DocumentWidgetProps) { return <div><ImageIcon size={35}/><small>{document.fileType.toUpperCase()}</small></div> }
function TextPreview({ document,onMemoryCue }: DocumentWidgetProps) {
const excerpt = document.body.filter(Boolean).slice(0,2).join(' ')
return <div className="text-document-preview"><p className="text-source-excerpt">{excerpt || document.title}</p><textarea aria-label={`Memory cue for ${document.title}`} maxLength={48} placeholder="WRITE A MEMORY CUE…" value={document.metadata.memory_cue || ''} onPointerDown={event => event.stopPropagation()} onClick={event => event.stopPropagation()} onDoubleClick={event => event.stopPropagation()} onChange={event => onMemoryCue?.(event.target.value)}/></div>
}
function ImageAsset({ document,source }: DocumentWidgetProps) { return <img className="document-image" src={source} alt={document.fileName || document.title}/> }
function FrameAsset({ document,source }: DocumentWidgetProps) { return <iframe className="document-frame" src={source} title={document.fileName || document.title} sandbox="allow-same-origin"/> }
function GenericAsset({ document,source }: DocumentWidgetProps) { return <div className="unsupported-file"><FileText size={42}/><b>{document.fileName || document.title}</b><span>{document.mimeType || 'Unknown file type'} · {document.fileSize ? `${Math.ceil(document.fileSize / 1024)} KB` : ''}</span><a href={source} download={document.fileName}>DOWNLOAD ORIGINAL</a></div> }
const genericDocument = (label:string):DocumentWidgetDefinition => ({label,Preview:GenericPreview,Asset:GenericAsset})
export const documentWidgetRegistry:Record<SourceFileType,DocumentWidgetDefinition> = {
image:{label:'Image',Preview:ImagePreview,Asset:ImageAsset},pdf:{label:'PDF',Preview:GenericPreview,Asset:FrameAsset},text:{label:'Text document',Preview:TextPreview,Asset:FrameAsset},
web_capture:genericDocument('Web capture'),email:genericDocument('Email'),article:genericDocument('Article'),filing:genericDocument('Company filing'),price_list:genericDocument('Price list'),file:genericDocument('Generic file'),
}
export function documentWidget(type:SourceFileType) { return documentWidgetRegistry[type] || documentWidgetRegistry.file }
export function documentExhibits(exhibits:Exhibit[]):DocumentExhibit[] { return exhibits.filter((exhibit):exhibit is DocumentExhibit => exhibit.type === 'document') }
export function evidenceExhibits(exhibits:Exhibit[]):Evidence[] { return exhibits.filter((exhibit):exhibit is Evidence => exhibit.type !== 'document') }
+198
View File
@@ -0,0 +1,198 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { UtteranceCanvas } from './utteranceCanvas'
type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
type Terminal = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number }
type StoryNode = { id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean; xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; terminals: Terminal[] }
type Graph = { mysteryId: string; entryNodeId: string | null; nodes: StoryNode[] }
type LevelTemplate = { versionId: string; slug: string; name: string; version: number }
// Vertical layout: input on top, output terminals along the bottom; flow runs downward.
const NODE_W = 200, NODE_H = 88
const TYPES: { type: StoryNodeType; label: string }[] = [
{ type: 'cutscene', label: 'Cutscene' }, { type: 'dialogue', label: 'Dialogue' }, { type: 'level', label: 'Level' },
{ type: 'det_gate', label: 'Det gate' }, { type: 'llm_gate', label: 'LLM gate' },
]
const outPort = (node: StoryNode, index: number) => ({ x: node.xpos + NODE_W * (index + 0.5) / Math.max(1, node.terminals.length), y: node.ypos + NODE_H })
const inPort = (node: StoryNode) => ({ x: node.xpos + NODE_W / 2, y: node.ypos })
async function api<T>(url: string, method: string, body?: unknown): Promise<T> {
const response = await fetch(url, { method, headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined })
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || `Request failed (${response.status})`)
return response.json().catch(() => ({} as T))
}
export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { mysteryId: string; title: string; onClose: () => void; setStatus: (message: string) => void }) {
const [graph, setGraph] = useState<Graph | null>(null)
const [templates, setTemplates] = useState<LevelTemplate[]>([])
const [view, setView] = useState({ x: 60, y: 60, zoom: 1 })
const [selectedId, setSelectedId] = useState<string | null>(null)
const [wiringFrom, setWiringFrom] = useState<string | null>(null)
const [utterancesNode, setUtterancesNode] = useState<StoryNode | null>(null)
const canvasRef = useRef<HTMLDivElement>(null)
const drag = useRef<{ kind: 'pan' | 'node'; id?: string; startX: number; startY: number; origX: number; origY: number } | null>(null)
const reload = useCallback(async () => {
try { setGraph(await api<Graph>(`/api/admin/mysteries/${mysteryId}/graph`, 'GET')) }
catch (error) { setStatus(String((error as Error).message || error)) }
}, [mysteryId, setStatus])
useEffect(() => { void reload(); api<LevelTemplate[]>('/api/admin/level-templates', 'GET').then(setTemplates).catch(() => {}) }, [reload])
const centerInBoard = () => {
const rect = canvasRef.current?.getBoundingClientRect()
const cx = rect ? rect.width / 2 : 300, cy = rect ? rect.height / 2 : 200
return { x: (cx - view.x) / view.zoom, y: (cy - view.y) / view.zoom }
}
const addNode = async (nodeType: StoryNodeType) => {
const at = centerInBoard()
try { const node = await api<StoryNode>(`/api/admin/mysteries/${mysteryId}/nodes`, 'POST', { nodeType, xpos: Math.round(at.x), ypos: Math.round(at.y) }); await reload(); setSelectedId(node.id) }
catch (error) { setStatus(String((error as Error).message || error)) }
}
const patchNode = async (id: string, body: Record<string, unknown>) => { try { await api(`/api/admin/story-nodes/${id}`, 'PATCH', body); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }
const wire = async (terminalId: string, toNodeId: string | null) => { try { await api(`/api/admin/story-terminals/${terminalId}`, 'PATCH', { toNodeId }); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }
// Pointer: pan on background, drag on node header.
const onPointerDown = (event: React.PointerEvent) => {
if (wiringFrom) { setWiringFrom(null); return }
drag.current = { kind: 'pan', startX: event.clientX, startY: event.clientY, origX: view.x, origY: view.y }
setSelectedId(null)
}
const onNodePointerDown = (event: React.PointerEvent, node: StoryNode) => {
event.stopPropagation()
;(event.target as HTMLElement).setPointerCapture?.(event.pointerId)
drag.current = { kind: 'node', id: node.id, startX: event.clientX, startY: event.clientY, origX: node.xpos, origY: node.ypos }
setSelectedId(node.id)
}
const onPointerMove = (event: React.PointerEvent) => {
const state = drag.current
if (!state) return
const dx = event.clientX - state.startX, dy = event.clientY - state.startY
if (state.kind === 'pan') setView(v => ({ ...v, x: state.origX + dx, y: state.origY + dy }))
else setGraph(g => g && { ...g, nodes: g.nodes.map(n => n.id === state.id ? { ...n, xpos: state.origX + dx / view.zoom, ypos: state.origY + dy / view.zoom } : n) })
}
const onPointerUp = async () => {
const state = drag.current; drag.current = null
if (state?.kind === 'node' && state.id) {
const node = graph?.nodes.find(n => n.id === state.id)
if (node) { try { await api(`/api/admin/story-nodes/${state.id}`, 'PATCH', { xpos: Math.round(node.xpos), ypos: Math.round(node.ypos) }) } catch { /* position persists next reload */ } }
}
}
const onWheel = (event: React.WheelEvent) => {
const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return
const px = event.clientX - rect.left, py = event.clientY - rect.top
const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1
setView(v => { const zoom = Math.min(2, Math.max(0.35, v.zoom * factor)); return { zoom, x: px - (px - v.x) * (zoom / v.zoom), y: py - (py - v.y) * (zoom / v.zoom) } })
}
const onNodeClick = (event: React.MouseEvent, node: StoryNode) => {
event.stopPropagation()
if (wiringFrom) { void wire(wiringFrom, node.id); setWiringFrom(null); return }
setSelectedId(node.id)
}
if (!graph) return <div className="graph-loading">Loading graph</div>
const selected = graph.nodes.find(n => n.id === selectedId) || null
const nodeById = new Map(graph.nodes.map(n => [n.id, n]))
return <div className="graph-editor">
{utterancesNode && <UtteranceCanvas nodeId={utterancesNode.id} nodeLabel={utterancesNode.label} terminals={utterancesNode.terminals}
onClose={() => { setUtterancesNode(null); void reload() }} setStatus={setStatus} />}
<div className="graph-toolbar">
<button className="graph-back" onClick={onClose}> Mysteries</button>
<strong>{title}</strong>
<span className="graph-add-label">Add:</span>
{TYPES.map(t => <button key={t.type} className="graph-add" onClick={() => addNode(t.type)}>{t.label}</button>)}
{wiringFrom && <span className="graph-wiring">Click a target node to wire · click empty to cancel</span>}
<span className="graph-zoom">{Math.round(view.zoom * 100)}%</span>
</div>
<div className="graph-main">
<div ref={canvasRef} className={`graph-canvas${wiringFrom ? ' wiring' : ''}`} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onWheel={onWheel}>
<div className="graph-world" style={{ transform: `translate(${view.x}px,${view.y}px) scale(${view.zoom})` }}>
<svg className="graph-wires" width="6000" height="6000">
{graph.nodes.flatMap(node => node.terminals.filter(t => t.toNodeId && nodeById.has(t.toNodeId)).map(t => {
const target = nodeById.get(t.toNodeId!)!
const from = outPort(node, node.terminals.indexOf(t)), to = inPort(target)
const dy = Math.max(40, Math.abs(to.y - from.y) / 2)
const d = `M${from.x},${from.y} C${from.x},${from.y + dy} ${to.x},${to.y - dy} ${to.x},${to.y}`
return [
<path key={t.id + 'hit'} className="wire-hit" d={d} onClick={event => { event.stopPropagation(); void wire(t.id, null) }} />,
<path key={t.id} className="graph-wire" d={d} />,
]
}))}
</svg>
{graph.nodes.map(node => <div key={node.id} className={`gnode type-${node.nodeType}${node.id === selectedId ? ' selected' : ''}${graph.entryNodeId === node.id ? ' entry' : ''}`}
style={{ left: node.xpos, top: node.ypos, width: NODE_W, height: NODE_H }} onPointerDown={event => event.stopPropagation()} onClick={event => onNodeClick(event, node)}
onDoubleClick={event => { event.stopPropagation(); if (node.nodeType === 'dialogue' || node.hasUtterances) setUtterancesNode(node) }}>
<div className="ginput" />
<div className="gnode-head" onPointerDown={event => onNodePointerDown(event, node)}>
<span className="gnode-type">{node.nodeType}</span>
<span className="gnode-label">{node.label}</span>
{graph.entryNodeId === node.id && <span className="gnode-entry"></span>}
</div>
<div className="gnode-sub">{nodeSummary(node, templates)}</div>
<div className="gnode-outs">
{node.terminals.map(t => <div key={t.id} className="gout">
<span className="gout-label">{t.label || t.terminalKey}</span>
<button className={`gport${t.toNodeId ? ' wired' : ''}${wiringFrom === t.id ? ' active' : ''}`} title="Click to start a wire"
onClick={event => { event.stopPropagation(); setWiringFrom(from => from === t.id ? null : t.id) }} />
</div>)}
</div>
</div>)}
</div>
{graph.nodes.length === 0 && <div className="graph-empty">Empty graph add a node to begin.</div>}
</div>
{selected && <aside className="graph-inspector">
<NodeInspector key={selected.id} node={selected} graph={graph} templates={templates}
onEditUtterances={() => setUtterancesNode(selected)}
onPatch={body => patchNode(selected.id, body)}
onSetEntry={async () => { try { await api(`/api/admin/mysteries/${mysteryId}/entry`, 'PUT', { nodeId: selected.id }); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
onDelete={async () => { if (!window.confirm('Delete this node?')) return; try { await api(`/api/admin/story-nodes/${selected.id}`, 'DELETE'); setSelectedId(null); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
onAddTerminal={async () => { const key = window.prompt('Terminal key (e.g. proceed)'); if (!key) return; try { await api(`/api/admin/story-nodes/${selected.id}/terminals`, 'POST', { terminalKey: key, label: key }); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
onTerminalPatch={async (id, body) => { try { await api(`/api/admin/story-terminals/${id}`, 'PATCH', body); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
onTerminalDelete={async id => { try { await api(`/api/admin/story-terminals/${id}`, 'DELETE'); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }} />
</aside>}
</div>
</div>
}
function nodeSummary(node: StoryNode, templates: LevelTemplate[]) {
if (node.nodeType === 'level') return templates.find(t => t.versionId === node.levelTemplateVersionId)?.name || '⚠ no level chosen'
if (node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate') return node.componentKey || '⚠ no component'
if (node.nodeType === 'dialogue') return node.hasUtterances ? 'utterances' : 'no utterances'
return ''
}
function NodeInspector({ node, graph, templates, onPatch, onSetEntry, onDelete, onAddTerminal, onTerminalPatch, onTerminalDelete, onEditUtterances }: {
node: StoryNode; graph: Graph; templates: LevelTemplate[]
onPatch: (body: Record<string, unknown>) => void; onSetEntry: () => void; onDelete: () => void
onAddTerminal: () => void; onTerminalPatch: (id: string, body: Record<string, unknown>) => void; onTerminalDelete: (id: string) => void; onEditUtterances: () => void
}) {
const [label, setLabel] = useState(node.label)
const [componentKey, setComponentKey] = useState(node.componentKey || '')
const nodeName = (id: string | null) => id ? (graph.nodes.find(n => n.id === id)?.label || '—') : '— unwired —'
const usesComponent = node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate'
return <div className="inspector-body">
<div className="inspector-head"><span className="gnode-type">{node.nodeType}</span>{graph.entryNodeId !== node.id && <button className="ins-entry" onClick={onSetEntry}>Set entrypoint</button>}</div>
<label className="ins-field"><span>Label</span><input value={label} onChange={e => setLabel(e.target.value)} onBlur={() => label !== node.label && onPatch({ label })} /></label>
{node.nodeType === 'level' && <label className="ins-field"><span>Level template</span>
<select value={node.levelTemplateVersionId || ''} onChange={e => onPatch({ levelTemplateVersionId: e.target.value || null })}>
<option value=""> choose </option>
{templates.map(t => <option key={t.versionId} value={t.versionId}>{t.name} (v{t.version})</option>)}
</select></label>}
{usesComponent && <label className="ins-field"><span>Component key</span><input value={componentKey} placeholder={node.nodeType === 'cutscene' ? 'glass-harbour-diversion' : 'det_gate_lvl_1'} onChange={e => setComponentKey(e.target.value)} onBlur={() => componentKey !== (node.componentKey || '') && onPatch({ componentKey })} /></label>}
{(node.nodeType === 'dialogue' || node.nodeType === 'cutscene') && <label className="ins-check"><input type="checkbox" checked={node.hasUtterances} onChange={e => onPatch({ hasUtterances: e.target.checked })} /> Has utterances</label>}
{(node.nodeType === 'dialogue' || node.hasUtterances) && <button className="ins-utterances" onClick={onEditUtterances}>Edit utterances </button>}
<div className="ins-terminals-head"><span>Output terminals</span><button onClick={onAddTerminal}>+ Add</button></div>
{node.terminals.map(t => <div key={t.id} className="ins-terminal">
<input defaultValue={t.label} onBlur={e => e.target.value !== t.label && onTerminalPatch(t.id, { label: e.target.value })} />
<span className="ins-terminal-to"> {nodeName(t.toNodeId)}</span>
{t.toNodeId && <button className="ins-unwire" title="Unwire" onClick={() => onTerminalPatch(t.id, { toNodeId: null })}></button>}
<button className="ins-term-del" title="Delete terminal" onClick={() => onTerminalDelete(t.id)}>×</button>
</div>)}
<button className="ins-delete" onClick={onDelete}>Delete node</button>
</div>
}
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useMemo, useRef, useState, type FC } from 'react'
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; utterances?: RuntimeUtterance[]; rootId?: string | null }
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
function usePrefersReducedMotion() {
const [reduced, setReduced] = useState(() => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
useEffect(() => {
const query = window.matchMedia?.('(prefers-reduced-motion: reduce)')
if (!query) return
const listener = (event: MediaQueryListEvent) => setReduced(event.matches)
query.addEventListener('change', listener)
return () => query.removeEventListener('change', listener)
}, [])
return reduced
}
export function SplashScreen({ hasResume, busy, status, onNewGame, onResume }: {
hasResume: boolean; busy: boolean; status?: string; onNewGame: () => void; onResume: () => void
}) {
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</p>
<div className="splash-actions">
{hasResume && <button className="splash-button" disabled={busy} onClick={onResume}>RESUME</button>}
<button className="splash-button primary" disabled={busy} onClick={onNewGame}>NEW GAME</button>
</div>
<small className="splash-status">{busy ? 'OPENING CASE FILE…' : status || 'GLITCH UNIVERSITY NETWORK TERMINAL'}</small>
</div>
</div>
}
// Bespoke cutscene components, keyed by a node's component_key (mirrors the exhibit registry).
const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) => (
<div className="cutscene-card title-card" onClick={onComplete}>
<div className="title-card-inner">
<small>Greyhaven file 87-10</small>
<h1>The Glass Harbour Diversion</h1>
<button className="cutscene-begin" onClick={event => { event.stopPropagation(); onComplete() }}>Begin </button>
</div>
</div>
)
const CUTSCENE_REGISTRY: Record<string, FC<{ onComplete: () => void }>> = { 'glass-harbour-diversion': GlassHarbourDiversion }
export function CutsceneHost({ componentKey, label, onComplete }: { componentKey: string | null | undefined; label: string; onComplete: () => void }) {
const Component = componentKey ? CUTSCENE_REGISTRY[componentKey] : undefined
if (Component) return <Component onComplete={onComplete} />
return <div className="cutscene-card title-card" onClick={onComplete}>
<div className="title-card-inner">
<h1>{label}</h1>
<small className="cutscene-missing">{componentKey ? `component "${componentKey}" not registered` : 'no component set'}</small>
<button className="cutscene-begin" onClick={event => { event.stopPropagation(); onComplete() }}>Continue </button>
</div>
</div>
}
// Walk a dialogue node's utterance tree: play NPC lines, present player options at a
// branch, follow a chosen option to the next line or out through its exit terminal.
export function DialoguePlayer({ node, onExit }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void }) {
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
const [currentId, setCurrentId] = useState<string | null>(node.rootId)
const [charCount, setCharCount] = useState(0)
const reduced = usePrefersReducedMotion()
const current = currentId ? byId.get(currentId) ?? null : null
const fullText = current?.text ?? ''
const done = charCount >= fullText.length
const children = current ? current.childIds.map(id => byId.get(id)).filter((c): c is RuntimeUtterance => Boolean(c)) : []
const options = children.filter(c => c.utterer === 'player')
const showChoices = done && options.length > 0
useEffect(() => {
if (!current) { onExit(); return }
if (reduced) { setCharCount(fullText.length); return }
setCharCount(0)
const id = window.setInterval(() => setCharCount(count => (count >= fullText.length ? count : count + 1)), 18)
return () => window.clearInterval(id)
}, [currentId, fullText, reduced]) // eslint-disable-line react-hooks/exhaustive-deps
const pick = (choice: RuntimeUtterance) => {
if (choice.childIds.length > 0) setCurrentId(choice.childIds[0])
else onExit(choice.terminalKey ?? undefined)
}
const proceedRef = useRef(() => {})
proceedRef.current = () => {
if (!current) { onExit(); return }
if (!done) { setCharCount(fullText.length); return }
if (children.length === 0) { onExit(current.terminalKey ?? undefined); return }
if (options.length > 0) return // a branch — wait for a choice
setCurrentId(children[0].id) // linear next line
}
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (!showChoices && (event.key === ' ' || event.key === 'Enter' || event.key === 'ArrowRight')) { event.preventDefault(); proceedRef.current() }
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [showChoices])
if (!current) return null
return <div className="dialogue" role="dialog" aria-label="Dialogue" onClick={() => { if (!showChoices) proceedRef.current() }}>
<div className="dialogue-portrait">{current.poseUrl && <img src={current.poseUrl} alt={current.speaker.name} />}</div>
<div className="dialogue-scrim" aria-hidden />
<div className="dialogue-box">
<div className="dialogue-panel">
<div className="dialogue-speaker"><strong>{current.speaker.name}</strong>{current.speaker.role && <em>{current.speaker.role}</em>}</div>
<p className="dialogue-text">{fullText.slice(0, charCount)}<span className="dialogue-caret" aria-hidden>{done ? '' : '▍'}</span></p>
{showChoices
? <div className="dialogue-choices">{options.map(option => <button key={option.id} onClick={event => { event.stopPropagation(); pick(option) }}>{option.text || '(choice)'}</button>)}</div>
: <div className="dialogue-advance">{done ? 'CONTINUE ▸' : ''}</div>}
</div>
</div>
</div>
}
+391 -18
View File
@@ -12,7 +12,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.brand-mark { width: 35px; height: 35px; display: grid; place-items: center; border: 1px solid #d88938; color: #e99a44; font: 600 11px IBM Plex Mono; transform: rotate(-3deg); } .brand-mark { width: 35px; height: 35px; display: grid; place-items: center; border: 1px solid #d88938; color: #e99a44; font: 600 11px IBM Plex Mono; transform: rotate(-3deg); }
.menubar nav { display: flex; height: 100%; align-items: stretch; } .menubar nav { display: flex; height: 100%; align-items: stretch; }
.menubar nav button { background: none; border: 0; padding: 0 18px; font: 500 11px IBM Plex Mono; letter-spacing: .1em; color: #94aaa4; cursor: pointer; } .menubar nav button { background: none; border: 0; padding: 0 18px; font: 500 11px IBM Plex Mono; letter-spacing: .1em; color: #94aaa4; cursor: pointer; }
.menubar nav button:hover { background: #112f2a; color: white; } .menubar nav button:hover, .menubar nav button.active { background: #112f2a; color: white; }
.admin-menu { position: relative; display: flex; align-items: stretch; }.admin-menu > button { height: 100%; }.admin-menu-items { position: absolute; z-index: 30; top: calc(100% - 1px); right: 0; width: 235px; padding: 5px; display: grid; background: #c5c9c2; border: 2px outset #e5e8e2; box-shadow: 6px 8px 0 #020907aa; }.menubar nav .admin-menu-items button { min-height: 37px; padding: 0 12px; color: #263d36; border: 1px solid transparent; text-align: left; font-size: 9px; }.menubar nav .admin-menu-items button:hover, .menubar nav .admin-menu-items button:focus-visible { background: #173d34; border-color: #526c64; color: #f1f3ef; }
.brief-count { display: inline-grid; min-width: 17px; height: 17px; margin-left: 7px; padding: 0 4px; place-items: center; border-radius: 9px; background: #b56d30; color: #fff3df; font: 600 9px IBM Plex Mono; }
.terminal-status { font: 10px IBM Plex Mono; color: #769087; letter-spacing: .06em; display: flex; gap: 8px; align-items: center; white-space: nowrap; } .terminal-status { font: 10px IBM Plex Mono; color: #769087; letter-spacing: .06em; display: flex; gap: 8px; align-items: center; white-space: nowrap; }
.terminal-status i { width: 7px; height: 7px; background: #71c888; border-radius: 50%; box-shadow: 0 0 8px #71c888; } .terminal-status i { width: 7px; height: 7px; background: #71c888; border-radius: 50%; box-shadow: 0 0 8px #71c888; }
.terminal-status span { color: #d9a05d; margin-left: 20px; font-size: 13px; } .terminal-status span { color: #d9a05d; margin-left: 20px; font-size: 13px; }
@@ -24,11 +26,19 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.panel-heading h2 { font: 600 17px IBM Plex Mono; letter-spacing: .04em; margin: 6px 0 0; } .panel-heading h2 { font: 600 17px IBM Plex Mono; letter-spacing: .04em; margin: 6px 0 0; }
.panel-heading sup { color: #d89043; font-size: 10px; } .panel-heading sup { color: #d89043; font-size: 10px; }
.panel-heading button { background: none; border: 0; color: #6f8b83; cursor: pointer; } .panel-heading button { background: none; border: 0; color: #6f8b83; cursor: pointer; }
.search { margin: 0 15px 12px; height: 36px; border: 1px solid #314a44; display: flex; align-items: center; gap: 9px; padding: 0 12px; color: #607d75; font-size: 11px; } .search { margin: 0 15px 12px; height: 36px; border: 1px solid #314a44; display: flex; align-items: center; gap: 9px; padding: 0 10px 0 12px; color: #607d75; font-size: 11px; }
.search:focus-within { border-color: #9a683d; box-shadow: inset 0 0 0 1px #6f4b2f; color: #c18b55; }
.search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: #d6ded9; font: 10px IBM Plex Mono; }
.search input::placeholder { color: #607d75; }.search input::-webkit-search-cancel-button { display: none; }
.search button { display: grid; place-items: center; padding: 2px; border: 0; background: transparent; color: #8a9d97; cursor: pointer; }
.import-document { margin: 0 15px 12px; height: 34px; border: 1px dashed #a26d3d; background: #19322c; color: #d79754; display: flex; justify-content: center; align-items: center; gap: 8px; font: 600 9px IBM Plex Mono; letter-spacing: .08em; cursor: pointer; }.import-document:hover { background: #244039; border-style: solid; }.file-input { display: none; } .import-document { margin: 0 15px 12px; height: 34px; border: 1px dashed #a26d3d; background: #19322c; color: #d79754; display: flex; justify-content: center; align-items: center; gap: 8px; font: 600 9px IBM Plex Mono; letter-spacing: .08em; cursor: pointer; }.import-document:hover { background: #244039; border-style: solid; }.file-input { display: none; }
.doc-list { overflow: auto; border-top: 1px solid #243d36; } .doc-list { overflow: auto; border-top: 1px solid #243d36; }
.no-document-results { min-height: 150px; padding: 32px 20px; display: grid; place-items: center; align-content: center; gap: 9px; text-align: center; color: #617d75; }.no-document-results b { color: #91a59f; font: 600 9px IBM Plex Mono; letter-spacing: .08em; }.no-document-results span { font: 8px/1.5 IBM Plex Mono; }
.doc-row { width: 100%; min-height: 76px; border: 0; border-bottom: 1px solid #243d36; background: transparent; padding: 11px 13px; display: grid; grid-template-columns: 45px 1fr 18px; text-align: left; align-items: center; gap: 10px; cursor: pointer; } .doc-row { width: 100%; min-height: 76px; border: 0; border-bottom: 1px solid #243d36; background: transparent; padding: 11px 13px; display: grid; grid-template-columns: 45px 1fr 18px; text-align: left; align-items: center; gap: 10px; cursor: pointer; }
.doc-row:hover { background: #18342e; } .doc-row:hover { background: #18342e; }
.doc-row.selected { position: relative; background: #304534; box-shadow: inset 4px 0 #f1cf55, inset 0 0 20px #d8b94722; }
.doc-row.selected::after { content: 'LOCATED'; position: absolute; top: 7px; right: 9px; color: #f0ce57; font: 600 6px IBM Plex Mono; letter-spacing: .12em; }
.doc-row.selected .doc-icon { border-color: #e7c750; box-shadow: 0 0 12px #efd44f66, 3px 3px #081a16; }
.doc-row strong { display: block; color: #d8dfda; font-size: 12px; margin-bottom: 6px; } .doc-row strong { display: block; color: #d8dfda; font-size: 12px; margin-bottom: 6px; }
.doc-row span { display: block; font: 9px IBM Plex Mono; color: #708b83; } .doc-row span { display: block; font: 9px IBM Plex Mono; color: #708b83; }
.doc-row > svg { color: #527068; } .doc-row > svg { color: #527068; }
@@ -44,21 +54,64 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.case-number { border-left: 1px solid #415750; margin-left: 26px; padding-left: 17px; font: 8px IBM Plex Mono; color: #6d867f; line-height: 1.5; } .case-number { border-left: 1px solid #415750; margin-left: 26px; padding-left: 17px; font: 8px IBM Plex Mono; color: #6d867f; line-height: 1.5; }
.case-number b { color: #bdc9c3; font-size: 13px; } .case-number b { color: #bdc9c3; font-size: 13px; }
.board-viewport { position: absolute; inset: 0; overflow: hidden; touch-action: none; overscroll-behavior: contain; cursor: default; background-image: radial-gradient(#49615a55 1px, transparent 1px), linear-gradient(90deg, #18302a33 1px, transparent 1px), linear-gradient(#18302a33 1px, transparent 1px); background-size: 20px 20px, 100px 100px, 100px 100px; } .board-viewport { position: absolute; inset: 0; overflow: hidden; touch-action: none; overscroll-behavior: contain; cursor: default; background-image: radial-gradient(#49615a55 1px, transparent 1px), linear-gradient(90deg, #18302a33 1px, transparent 1px), linear-gradient(#18302a33 1px, transparent 1px); background-size: 20px 20px, 100px 100px, 100px 100px; }
.board-viewport.tool-move { cursor: grab; }
.board-viewport.tool-move:active { cursor: grabbing; }
.board-viewport.tool-hand, .board-viewport.tool-hand .evidence-card, .board-viewport.tool-hand .source-file-widget { cursor: grab; } .board-viewport.tool-hand, .board-viewport.tool-hand .evidence-card, .board-viewport.tool-hand .source-file-widget { cursor: grab; }
.board-viewport.tool-hand:active, .board-viewport.tool-hand .evidence-card:active, .board-viewport.tool-hand .source-file-widget:active { cursor: grabbing; } .board-viewport.tool-hand:active, .board-viewport.tool-hand .evidence-card:active, .board-viewport.tool-hand .source-file-widget:active { cursor: grabbing; }
.board-viewport.threading, .board-viewport.threading .evidence-card, .board-viewport.threading .source-file-widget { cursor: crosshair; }
.board-trash { position: absolute; z-index: 18; left: 18px; bottom: 18px; width: 136px; min-height: 64px; padding: 10px 12px; display: flex; align-items: center; gap: 10px; border: 1px dashed #6f5d50; background: #10231fdd; color: #866f61; box-shadow: 4px 5px 0 #02090788; opacity: .48; pointer-events: none; transition: opacity .16s ease, color .16s ease, border-color .16s ease, background .16s ease, transform .16s ease, box-shadow .16s ease; }
.board-trash span { display: grid; gap: 3px; }.board-trash b { font: 600 9px IBM Plex Mono; letter-spacing: .1em; }.board-trash small { font: 6px IBM Plex Mono; letter-spacing: .05em; white-space: nowrap; }
.board-trash.drag-ready { opacity: 1; color: #d69568; border-color: #b06e4f; background: #1c2b26ee; transform: translateY(-2px); }
.board-trash.active { color: #ffe2cf; border-color: #df5c48; background: #6d2823ed; box-shadow: 0 0 0 4px #c4473738, 6px 8px 0 #02090799; transform: scale(1.05); }
.board { transform-origin: 0 0; position: absolute; background: linear-gradient(110deg, #0c211d33, #122b251f); } .board { transform-origin: 0 0; position: absolute; background: linear-gradient(110deg, #0c211d33, #122b251f); }
.board-stamp { position: absolute; left: 1480px; top: 240px; color: #6e807a2e; border: 2px solid #6e807a20; padding: 9px 13px; transform: rotate(-4deg); font: 600 11px IBM Plex Mono; } .board-stamp { position: absolute; left: 1480px; top: 240px; color: #6e807a2e; border: 2px solid #6e807a20; padding: 9px 13px; transform: rotate(-4deg); font: 600 11px IBM Plex Mono; }
.board-stamp span { display: block; text-align: center; font-size: 8px; margin-top: 4px; } .board-stamp span { display: block; text-align: center; font-size: 8px; margin-top: 4px; }
.connections { position: absolute; inset: 0; overflow: visible; pointer-events: none; } .connections { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
.connections path { stroke: #8f2828; stroke-width: 3; fill: none; filter: drop-shadow(1px 2px 0 #020907aa); } .connections path { stroke: #8f2828; stroke-width: 3; fill: none; filter: drop-shadow(1px 2px 0 #020907aa); }
.connections circle { fill: #b33a32; stroke: #581916; stroke-width: 2; } .connections circle { fill: #b33a32; stroke: #581916; stroke-width: 2; }
.connections .thread-preview path { stroke: #bc3b35; stroke-width: 2.5; stroke-dasharray: 8 6; opacity: .82; }
.connections .thread-preview circle:last-child { fill: #0d211c; stroke: #c84a43; }
.connections g.tightening path { stroke-dasharray: 1400; animation: thread-tighten .9s cubic-bezier(.15,.75,.22,1) both; }
@keyframes thread-tighten { 0% { stroke-dashoffset: 1400; filter: drop-shadow(0 0 7px #d85a50); } 70% { stroke-dashoffset: 0; stroke-width: 4; } 100% { stroke-dashoffset: 0; stroke-width: 3; } }
.luggage-tag.luggage-tag { background: linear-gradient(100deg, #aa8755, #c7a773 52%, #a9834f); border: 1px solid #d3b681; color: #33291c; clip-path: polygon(13px 0, calc(100% - 13px) 0, 100% 14px, 100% 100%, 0 100%, 0 14px); transform-origin: 50% 8px; transition: transform .22s ease, filter .22s ease; box-shadow: 5px 7px 0 #020b0980, inset 0 0 18px #60452233; }
.luggage-tag.luggage-tag::before { content: ''; position: absolute; z-index: 3; top: 6px; left: 50%; width: 10px; height: 10px; translate: -50% 0; border-radius: 50%; background: #12211d; border: 2px solid #70583b; box-shadow: 0 0 0 2px #c5a66f, inset 1px 1px 2px #000; }
.luggage-tag.luggage-tag::after { content: ''; position: absolute; left: 9px; right: 9px; bottom: -1px; height: 4px; background: #745a37; clip-path: polygon(0 0, 5% 50%, 12% 0, 20% 70%, 28% 0, 40% 60%, 49% 0, 61% 70%, 73% 0, 83% 60%, 91% 0, 100% 50%, 100% 100%, 0 100%); }
.thread-tag { position: absolute; z-index: 7; border: 0; cursor: grab; touch-action: none; transition: left .1s ease-out, top .1s ease-out, transform .22s ease, filter .22s ease, opacity .15s ease; }
.board-viewport.threading .thread-tag { pointer-events: none; opacity: .72; }
.thread-tag.dragging { z-index: 15; cursor: grabbing; transition: transform .12s ease, filter .12s ease; filter: drop-shadow(0 0 7px #d99a58aa); }
.thread-tag-position { position: absolute; z-index: 8; left: 50%; top: -22px; translate: -50% 0; min-width: 34px; padding: 3px 5px; border: 1px solid #9a683d; background: #102a24; color: #e6a45e; font: 7px IBM Plex Mono; box-shadow: 2px 3px #02090799; }
.thread-tag.untagged { width: 13px; height: 13px; padding: 0; transform: translate(-50%, -50%); border: 2px solid #611d1c; border-radius: 50%; background: #a63531; box-shadow: 1px 2px #020907aa; }
.thread-tag.untagged i { display: none; }
.thread-tag.untagged:hover, .thread-tag.untagged:focus-visible { background: #e07158; box-shadow: 0 0 0 4px #b23e3544; }
.thread-tag.compact { transform: translate(-50%, -50%); display: flex; align-items: center; gap: 5px; max-width: 190px; padding: 0; background: transparent; color: #241d17; }
.thread-tag.compact i { flex: 0 0 auto; width: 9px; height: 9px; border: 2px solid #611d1c; border-radius: 50%; background: #a63531; box-shadow: 1px 2px #020907aa; }
.thread-tag-compact-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 5px 7px 4px; border: 1px solid #9c917b; background: #d7c9a9; box-shadow: 2px 3px #02090799; font: 8px Special Elite; }
.thread-tag.compact:hover i, .thread-tag.compact:focus-visible i { background: #e07158; box-shadow: 0 0 0 4px #b23e3544; }
.thread-tag.labelled { width: 108px; height: 154px; padding: 27px 10px 11px; transform: translate(-50%, 4px) rotate(-2deg); }
.thread-tag.labelled i { display: none; }
.thread-tag-content { display: grid; align-content: start; gap: 7px; height: 108px; padding-top: 10px; overflow: hidden; text-align: left; transition: transform .22s ease; }
.thread-tag-content small { padding-bottom: 4px; border-bottom: 1px solid #7e6542; color: #5b472d; font: 600 6px IBM Plex Mono; letter-spacing: .1em; }
.thread-tag-content b { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 5; -webkit-box-orient: vertical; color: #33291c; font: 12px/1.25 Special Elite; }
.thread-tag-content em { color: #68472c; font: 600 6px IBM Plex Mono; letter-spacing: .06em; }
.thread-tag.labelled.expanded { z-index: 14; transform: translate(-50%, 4px) rotate(90deg) scale(1.55); filter: drop-shadow(12px 8px 5px #0008); }
.thread-tag.labelled.expanded .thread-tag-content { width: 142px; height: 94px; margin: 4px 0 0 -17px; overflow: visible; transform: rotate(-90deg); }
.thread-tag.labelled.expanded .thread-tag-content b { display: block; overflow: visible; font-size: 13px; line-height: 1.32; }
.event-support-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
.event-support-lines line { stroke: #d3a05c; stroke-width: 2; stroke-dasharray: 7 6; opacity: .58; filter: drop-shadow(1px 1px 0 #020907); }
.party-association-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
.party-association-lines line { stroke: #6fa694; stroke-width: 2; stroke-dasharray: 3 5; opacity: .52; filter: drop-shadow(1px 1px 0 #020907); }
.folder-bands { position: absolute; inset: 0; overflow: visible; pointer-events: none; } .folder-bands { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
.folder-bands line { stroke: #b63232; stroke-width: 7; opacity: .2; filter: drop-shadow(1px 1px 0 #050a08); transition: x2 .42s cubic-bezier(.2,.75,.2,1), y2 .42s cubic-bezier(.2,.75,.2,1), opacity .24s ease; } .folder-bands line { stroke: #b63232; stroke-width: 7; opacity: .2; filter: drop-shadow(1px 1px 0 #050a08); transition: x2 .42s cubic-bezier(.2,.75,.2,1), y2 .42s cubic-bezier(.2,.75,.2,1), opacity .24s ease; }
.folder-bands line.closed { opacity: 0; } .folder-bands line.closed { opacity: 0; }
.evidence-card { position: absolute; min-height: 138px; color: #1b2925; background: #d8d8ca; border: 1px solid #edece0; padding: 13px 15px 12px; box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; cursor: move; user-select: none; } .evidence-card { position: absolute; min-height: 138px; color: #1b2925; background: #d8d8ca; border: 1px solid #edece0; padding: 13px 15px 12px; box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; cursor: move; user-select: none; }
.evidence-card::after { content: ''; position: absolute; left: 9px; right: 9px; bottom: -6px; height: 5px; background: #80847b; clip-path: polygon(0 0, 5% 50%, 12% 0, 20% 70%, 28% 0, 40% 60%, 49% 0, 61% 70%, 73% 0, 83% 60%, 91% 0, 100% 50%, 100% 100%, 0 100%); } .evidence-card::after { content: ''; position: absolute; left: 9px; right: 9px; bottom: -6px; height: 5px; background: #80847b; clip-path: polygon(0 0, 5% 50%, 12% 0, 20% 70%, 28% 0, 40% 60%, 49% 0, 61% 70%, 73% 0, 83% 60%, 91% 0, 100% 50%, 100% 100%, 0 100%); }
.evidence-card.selected { outline: 2px solid #e49a4a; outline-offset: 5px; } .evidence-card.selected { outline: 2px solid #e49a4a; outline-offset: 5px; }
.evidence-card.document-located, .source-file-widget.document-located { z-index: 6; outline: 3px solid #f2d254; outline-offset: 6px; filter: drop-shadow(0 0 10px #f3d75d99) brightness(1.08); animation: document-locator-target 1.35s ease-in-out infinite alternate; }
@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.arriving { z-index: 6; animation: exhibit-arrival 1.15s cubic-bezier(.18,.85,.22,1) both; }
@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; }
.evidence-card p { font: 500 16px Special Elite, serif; line-height: 1.35; margin: 0 0 12px; } .evidence-card p { font: 500 16px Special Elite, serif; line-height: 1.35; margin: 0 0 12px; }
@@ -69,28 +122,44 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.evidence-card.folder::after { background: #80643e; } .evidence-card.folder::after { background: #80643e; }
.evidence-card.folder header { border-color: #846e49; color: #594a33; } .evidence-card.folder header { border-color: #846e49; color: #594a33; }
.evidence-card.folder h3 { color: #66401f; } .evidence-card.folder h3 { color: #66401f; }
.folder-documents { clear: both; margin-top: 9px; border-top: 1px dashed #826c49; padding-top: 6px; } .evidence-card.folder p { max-height: 58px; padding-right: 2px; overflow: hidden; }
.folder-documents button { float: none; width: 100%; height: 23px; padding: 2px 0; display: grid; grid-template-columns: 14px 1fr auto; text-align: left; color: #493d2c; } .evidence-card.event { background: linear-gradient(112deg, #d6d1bd, #c8c5b6); border-left: 5px solid #9a6332; min-height: 174px; }
.folder-documents button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .evidence-card.event h3 { color: #70401e; }
.folder-documents button time { border: 0; padding: 0; font-size: 7px; } .evidence-card.event p { font-size: 15px; }
.folder-documents small { display: block; padding: 4px 0 1px 17px; color: #69573d; font: 7px IBM Plex Mono; } .event-actions { clear: both; margin-top: 10px; padding-top: 7px; border-top: 1px dashed #858c84; display: flex; align-items: center; justify-content: space-between; }
.folder-actions { clear: both; display: flex; justify-content: flex-end; gap: 12px; margin-top: 7px; border-top: 1px solid #9a7d50; padding-top: 5px; } .event-actions span { color: #6a736d; font: 7px IBM Plex Mono; }
.folder-actions button { float: none; color: #60401f; } .event-actions button { float: none; }
.evidence-card.party { min-height: 190px; background: linear-gradient(110deg, #d9d7c9, #c8c8bd); border-top: 5px solid #315a50; }
.evidence-card.party h3 { margin: 0 0 3px; color: #254d43; }
.party-identity { display: grid; grid-template-columns: 34px 1fr; gap: 8px; align-items: center; padding: 11px 0 8px; border-bottom: 1px solid #929a92; }
.party-identity > svg { color: #315a50; }
.party-identity small { color: #6a746e; font: 7px IBM Plex Mono; }
.evidence-card.party p { margin-top: 9px; font-size: 13px; }
.party-aliases { padding: 5px 0; color: #76552f; font: 7px IBM Plex Mono; }
.folder-actions { position: absolute; right: 13px; bottom: 10px; }
.folder-actions button { float: none; min-height: 29px; padding: 6px 9px; gap: 6px; border: 1px solid #81633d; background: #d9bb82; color: #563617; font-size: 9px; box-shadow: 2px 2px #75552f66; }
.folder-actions button:hover { background: #e5ca96; color: #321f0f; }
.source-file-widget { position: absolute; z-index: 4; width: 174px; min-height: 145px; padding: 8px; color: #1a2421; background: #d9d8cc; border: 1px solid #f1efe2; box-shadow: 5px 7px 0 #020b0980, 0 0 0 1px #53615c; cursor: move; user-select: none; transition: left .42s cubic-bezier(.2,.75,.2,1), top .42s cubic-bezier(.2,.75,.2,1), opacity .28s ease, transform .42s cubic-bezier(.2,.75,.2,1); } .source-file-widget { position: absolute; z-index: 4; width: 174px; min-height: 145px; padding: 8px; color: #1a2421; background: #d9d8cc; border: 1px solid #f1efe2; box-shadow: 5px 7px 0 #020b0980, 0 0 0 1px #53615c; cursor: move; user-select: none; transition: left .42s cubic-bezier(.2,.75,.2,1), top .42s cubic-bezier(.2,.75,.2,1), opacity .28s ease, transform .42s cubic-bezier(.2,.75,.2,1); }
.source-file-widget.closed { opacity: 0; transform: scale(.18) rotate(-8deg); pointer-events: none; } .source-file-widget.closed { opacity: 0; transform: scale(.18) rotate(-8deg); pointer-events: none; }
.source-file-widget.open { opacity: 1; transform: scale(1) rotate(.6deg); } .source-file-widget.open { opacity: 1; transform: scale(1) rotate(.6deg); }
.source-file-widget.selected { outline: 2px solid #e49a4a; outline-offset: 5px; }
.source-file-widget.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; }
.source-file-widget header { height: 18px; display: flex; justify-content: space-between; border-bottom: 1px solid #8b938d; color: #59645f; font: 600 7px IBM Plex Mono; letter-spacing: .1em; } .source-file-widget header { height: 18px; display: flex; justify-content: space-between; border-bottom: 1px solid #8b938d; color: #59645f; font: 600 7px IBM Plex Mono; letter-spacing: .1em; }
.source-file-preview { height: 79px; margin: 7px 0; display: grid; place-items: center; overflow: hidden; background: #263a35; border: 1px solid #707a74; } .source-file-preview { height: 79px; margin: 7px 0; display: grid; place-items: center; overflow: hidden; background: #263a35; border: 1px solid #707a74; }
.source-file-preview img { width: 100%; height: 100%; object-fit: cover; } .source-file-preview img { width: 100%; height: 100%; object-fit: cover; }
.source-file-preview > div { display: grid; justify-items: center; gap: 5px; color: #b8c2bd; } .source-file-preview > div { display: grid; justify-items: center; gap: 5px; color: #b8c2bd; }
.source-file-preview small { font: 7px IBM Plex Mono; } .source-file-preview small { font: 7px IBM Plex Mono; }
.source-file-widget.file-type-text .source-file-preview { height: 108px; display: block; background: #e8e4d7; border-color: #a19b8b; }
.text-document-preview { width: 100%; height: 100%; display: grid !important; grid-template-rows: 42px 1fr; justify-items: stretch !important; gap: 0 !important; color: #202722 !important; }
.text-source-excerpt { margin: 0; padding: 6px 7px; overflow: hidden; border-bottom: 1px solid #a29d8e; background: #d5d1c4; font: 6px/1.35 IBM Plex Mono; text-transform: uppercase; }
.text-document-preview textarea { width: 100%; min-height: 0; resize: none; overflow: hidden; border: 0; outline: 0; padding: 8px 7px 5px; background: repeating-linear-gradient(#ede9dc 0 20px, #c9c5b855 21px); color: #151b18; font: 900 15px/1.05 "Marker Felt", "Comic Sans MS", cursive; letter-spacing: .015em; text-align: center; transform: rotate(-1deg); }
.text-document-preview textarea::placeholder { color: #76786f; font-size: 9px; letter-spacing: .04em; }
.board-viewport.threading .text-document-preview textarea { pointer-events: none; }
.source-file-widget > strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; } .source-file-widget > strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }
.source-file-widget > time { display: block; margin-top: 3px; color: #86603b; font: 7px IBM Plex Mono; } .source-file-widget > time { display: block; margin-top: 3px; color: #86603b; font: 7px IBM Plex Mono; }
.source-file-actions { display: flex; justify-content: space-between; margin-top: 6px; border-top: 1px dashed #969b94; padding-top: 4px; } .source-file-actions { display: flex; justify-content: space-between; margin-top: 6px; border-top: 1px dashed #969b94; padding-top: 4px; }
.source-file-actions button { display: flex; align-items: center; gap: 3px; border: 0; background: transparent; padding: 2px; color: #4e5d57; cursor: pointer; font: 600 6px IBM Plex Mono; } .source-file-actions button { display: flex; align-items: center; gap: 3px; border: 0; background: transparent; padding: 2px; color: #4e5d57; cursor: pointer; font: 600 6px IBM Plex Mono; }
.evidence-card.note { width: 108px !important; height: 154px; min-height: 154px; padding: 27px 10px 11px; background: linear-gradient(100deg, #aa8755, #c7a773 52%, #a9834f); border: 1px solid #d3b681; color: #33291c; rotate: -2deg !important; clip-path: polygon(13px 0, calc(100% - 13px) 0, 100% 14px, 100% 100%, 0 100%, 0 14px); transform-origin: 50% 8px; transition: transform .22s ease, filter .22s ease; box-shadow: 5px 7px 0 #020b0980, inset 0 0 18px #60452233; z-index: 2; } .evidence-card.note { width: 108px !important; height: 154px; min-height: 154px; padding: 27px 10px 11px; rotate: -2deg !important; z-index: 2; }
.evidence-card.note::before { content: ''; position: absolute; z-index: 3; top: 6px; left: 50%; width: 10px; height: 10px; translate: -50% 0; border-radius: 50%; background: #12211d; border: 2px solid #70583b; box-shadow: 0 0 0 2px #c5a66f, inset 1px 1px 2px #000; }
.evidence-card.note::after { height: 4px; bottom: -1px; background: #745a37; }
.evidence-card.note header { position: absolute; left: 9px; right: 9px; top: 21px; padding-bottom: 3px; font-size: 6px; color: #5b472d; border-color: #7e6542; } .evidence-card.note header { position: absolute; left: 9px; right: 9px; top: 21px; padding-bottom: 3px; font-size: 6px; color: #5b472d; border-color: #7e6542; }
.evidence-card.note header i { display: none; } .evidence-card.note header i { display: none; }
.evidence-card.note .card-content { height: 108px; padding-top: 10px; overflow: hidden; transition: transform .22s ease; } .evidence-card.note .card-content { height: 108px; padding-top: 10px; overflow: hidden; transition: transform .22s ease; }
@@ -103,27 +172,56 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.board-actions { position: absolute; z-index: 4; bottom: 17px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; height: 43px; background: #102a24ee; border: 1px solid #3c564e; box-shadow: 0 8px 24px #0009; padding: 4px; } .board-actions { position: absolute; z-index: 4; bottom: 17px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; height: 43px; background: #102a24ee; border: 1px solid #3c564e; box-shadow: 0 8px 24px #0009; padding: 4px; }
.board-actions button { height: 33px; border: 0; background: transparent; padding: 0 10px; display: flex; align-items: center; gap: 7px; font: 9px IBM Plex Mono; cursor: pointer; color: #a8b8b2; } .board-actions button { height: 33px; border: 0; background: transparent; padding: 0 10px; display: flex; align-items: center; gap: 7px; font: 9px IBM Plex Mono; cursor: pointer; color: #a8b8b2; }
.board-actions button:hover, .board-actions button.active { background: #27443c; color: #e4a35e; } .board-actions button:hover, .board-actions button.active { background: #27443c; color: #e4a35e; }
.board-actions button.thread-tool:not(:disabled) { color: #d35b52; }
.board-actions button.thread-tool.active { background: #4a211f; color: #ff8a78; box-shadow: inset 0 0 0 1px #8f3934; }
.board-actions button:disabled { opacity: .35; cursor: default; } .board-actions button:disabled { opacity: .35; cursor: default; }
.board-actions span { width: 1px; height: 24px; background: #385149; margin: 0 5px; } .board-actions span { width: 1px; height: 24px; background: #385149; margin: 0 5px; }
.board-actions b { width: 44px; text-align: center; color: #91a69f; font: 9px IBM Plex Mono; } .board-actions b { width: 44px; text-align: center; color: #91a69f; font: 9px IBM Plex Mono; }
.open-files { position: absolute; top: 25px; left: 25px; z-index: 4; border: 1px solid #4c675f; background: #122e28; padding: 11px 13px; display: flex; gap: 8px; font: 9px IBM Plex Mono; cursor: pointer; } .open-files { position: absolute; top: 25px; left: 25px; z-index: 4; border: 1px solid #4c675f; background: #122e28; padding: 11px 13px; display: flex; gap: 8px; font: 9px IBM Plex Mono; cursor: pointer; }
.open-files b { color: #e19a4d; } .open-files b { color: #e19a4d; }
.story-strip { position: absolute; z-index: 4; right: 18px; top: 18px; width: 255px; max-height: 235px; overflow: auto; padding: 10px; background: #0d2721e8; border: 1px solid #466159; box-shadow: 5px 7px 18px #0008; }
.story-strip > small { display: block; padding: 2px 4px 8px; color: #b47a41; font: 600 8px IBM Plex Mono; letter-spacing: .12em; }
.story-strip button { width: 100%; display: grid; grid-template-columns: 64px 1fr; gap: 2px 7px; padding: 7px 5px; text-align: left; border: 0; border-top: 1px solid #304a43; background: transparent; cursor: pointer; }
.story-strip button:hover, .story-strip button.selected { background: #193a32; }
.story-strip time { grid-row: 1 / 3; color: #7b938b; font: 7px IBM Plex Mono; }
.story-strip b { color: #d9ddd8; font: 9px IBM Plex Mono; }
.story-strip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #849b93; font: 8px Special Elite; }
.brief-panel { position: absolute; z-index: 12; right: 18px; top: 18px; width: min(410px, 42vw); max-height: calc(100% - 36px); overflow: auto; background: #c6c9c1; color: #17231f; border: 2px solid #d8dbd4; box-shadow: 7px 9px 0 #020a08, 0 0 0 1px #46554f; }
.brief-panel > header { position: sticky; z-index: 3; top: 0; height: 44px; padding: 0 7px 0 13px; display: flex; align-items: center; background: #173d34; color: #e0e8e4; cursor: default; }
.brief-panel > header div { display: grid; gap: 2px; }.brief-panel > header > span { flex: 1; }.brief-panel > header small { color: #9bb0a9; font: 7px IBM Plex Mono; letter-spacing: .14em; }.brief-panel > header b { font: 10px IBM Plex Mono; }.brief-panel > header button { flex: 0 0 auto; width: 25px; height: 24px; margin-left: 4px; display: grid; place-items: center; padding: 0; border: 1px outset #e8ece8; background: #c9cec8; color: #17312b; cursor: pointer; }.brief-panel > header button:hover { background: #eef0eb; color: #070d0b; }
.brief-panel.minimized { width: min(330px, 42vw); overflow: hidden; }.brief-panel.minimized > :not(header) { display: none; }
.brief-panel > p { margin: 16px; padding: 13px; background: #e2dfd2; border-left: 3px solid #a66d37; font: 13px/1.55 Special Elite; }
.brief-concepts { border-top: 1px solid #8c958e; }.brief-concepts section { padding: 12px 15px; border-bottom: 1px solid #959c95; }.brief-concepts section.resolved { background: #d5ddcf; }.brief-concepts section.just-resolved { animation: concept-resolved 1.15s ease-out; }.brief-concepts section > div:first-child { display: grid; gap: 4px; }.brief-concepts b { font: 600 10px IBM Plex Mono; }.brief-concepts span { color: #616d67; font: 9px Special Elite; }
.classify-actions, .resolved-actions { display: flex; align-items: center; gap: 7px; margin-top: 9px; }.classify-actions button, .resolved-actions button, .edit-brief, .new-party-from-brief { display: inline-flex; align-items: center; gap: 5px; border: 1px outset #89948e; background: #e3e1d6; color: #29463e; padding: 7px 8px; cursor: pointer; font: 8px IBM Plex Mono; }.resolved-actions > span { margin-right: auto; display: inline-flex; align-items: center; gap: 5px; color: #31584d; font: 600 8px IBM Plex Mono; }.edit-brief, .new-party-from-brief { margin: 12px 15px 0; }.edit-brief { background: #234c41; color: white; }.new-party-from-brief { width: calc(100% - 30px); justify-content: center; border-style: dashed; background: #d8d8cf; }.dismiss-brief { width: calc(100% - 30px); margin: 12px 15px 14px; padding: 9px; border: 1px outset #73877f; background: #1e473d; color: white; cursor: pointer; font: 600 9px IBM Plex Mono; letter-spacing: .08em; }
@keyframes concept-resolved { 0% { background: #e8b76c; box-shadow: inset 4px 0 #b56127; } 100% { background: #d5ddcf; box-shadow: inset 0 0 transparent; } }
.concept-editor-list { max-height: 280px; overflow: auto; border: 1px solid #8d968f; background: #d4d5cd; }.concept-editor-row { display: grid; grid-template-columns: 150px 1fr 125px 30px; gap: 6px; padding: 7px; border-bottom: 1px solid #9fa59e; }.concept-editor-row input, .concept-editor-row select { min-width: 0; border: 1px solid #7d8780; background: #e8e5d8; padding: 7px; font: 9px IBM Plex Mono; }.concept-editor-row button { border: 0; color: #713c2d; }
.file-drop-overlay { position: absolute; z-index: 20; inset: 14px; border: 2px dashed #e19a4d; background: #0a211de8; display: grid; place-items: center; pointer-events: none; }.file-drop-overlay > div { width: 290px; height: 150px; border: 1px solid #5e786f; background: #102c25; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; box-shadow: 9px 10px #020b09; color: #d99a58; }.file-drop-overlay b { font: 600 12px IBM Plex Mono; letter-spacing: .08em; }.file-drop-overlay span { font: 9px IBM Plex Mono; color: #78928a; letter-spacing: .12em; } .file-drop-overlay { position: absolute; z-index: 20; inset: 14px; border: 2px dashed #e19a4d; background: #0a211de8; display: grid; place-items: center; pointer-events: none; }.file-drop-overlay > div { width: 290px; height: 150px; border: 1px solid #5e786f; background: #102c25; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; box-shadow: 9px 10px #020b09; color: #d99a58; }.file-drop-overlay b { font: 600 12px IBM Plex Mono; letter-spacing: .08em; }.file-drop-overlay span { font: 9px IBM Plex Mono; color: #78928a; letter-spacing: .12em; }
.timeline { background: #0d231e; border-top: 1px solid #3c544d; display: grid; grid-template-columns: 180px 1fr 165px; align-items: center; padding: 0 25px; z-index: 8; } .timeline { background: #0d231e; border-top: 1px solid #3c544d; display: grid; grid-template-columns: 180px 1fr 165px; align-items: center; padding: 0 25px; z-index: 8; }
.temporal-links { position: fixed; z-index: 7; inset: 0; width: 100vw; height: 100vh; pointer-events: none; overflow: visible; }.temporal-links line { stroke: #8b9792; stroke-width: 1; opacity: .42; vector-effect: non-scaling-stroke; } .temporal-links { position: fixed; z-index: 9; inset: 0; width: 100vw; height: 100vh; pointer-events: none; overflow: visible; }.temporal-links line { stroke: #8b9792; stroke-width: 1; opacity: .42; vector-effect: non-scaling-stroke; }
.document-locator-beam { position: fixed; z-index: 9; inset: 0; width: 100vw; height: 100vh; overflow: visible; pointer-events: none; }
.document-locator-beam path { fill: none; vector-effect: non-scaling-stroke; }
.document-locator-halo { stroke: #ffe276; stroke-width: 12; opacity: .13; filter: url(#document-locator-glow); }
.document-locator-ray { stroke: #ffe066; stroke-width: 2; stroke-dasharray: 10 8; opacity: .9; filter: url(#document-locator-glow); animation: document-locator-flow .7s linear infinite; }
.document-locator-beam circle { fill: #ffe36d; stroke: #fff3b0; stroke-width: 2; filter: url(#document-locator-glow); }
.document-locator-beam .document-locator-pulse { fill: none; opacity: .65; animation: document-locator-pulse 1.1s ease-out infinite; }
@keyframes document-locator-flow { to { stroke-dashoffset: -18; } }
@keyframes document-locator-pulse { from { r: 7; opacity: .8; } to { r: 24; opacity: 0; } }
.timeline-label { border-right: 1px solid #314a43; height: 67px; display: flex; flex-direction: column; justify-content: center; } .timeline-label { border-right: 1px solid #314a43; height: 67px; display: flex; flex-direction: column; justify-content: center; }
.timeline-label b { font: 600 14px IBM Plex Mono; margin: 4px 0; }.timeline-label span { font: 8px IBM Plex Mono; color: #a07142; } .timeline-label b { font: 600 15px IBM Plex Mono; margin: 4px 0; }.timeline-label button { width: max-content; max-width: 170px; padding: 1px 0; overflow: hidden; color: #d29a61; background: transparent; border: 0; text-overflow: ellipsis; white-space: nowrap; text-align: left; cursor: pointer; font: 500 11px IBM Plex Mono; }
.timeline-track { height: 75px; margin: 0 43px; position: relative; } .timeline-track { height: 75px; margin: 0 43px; position: relative; }
.axis { position: absolute; left: 0; right: 0; top: 45px; height: 1px; background: #61736d; } .axis { position: absolute; left: 0; right: 0; top: 45px; height: 1px; background: #61736d; }
.year { position: absolute; top: 53px; transform: translateX(-50%); font: 9px IBM Plex Mono; color: #6f8981; } .year { position: absolute; top: 53px; transform: translateX(-50%); color: #b7c7c1; font: 600 12px IBM Plex Mono; letter-spacing: .02em; text-shadow: 0 1px 1px #020907; }
.year::before { content: ''; position: absolute; left: 50%; top: -9px; height: 5px; border-left: 1px solid #6a7e78; } .year::before { content: ''; position: absolute; left: 50%; top: -9px; height: 5px; border-left: 1px solid #6a7e78; }
.marker { position: absolute; width: 19px; height: 29px; transform: translateX(-50%); border: 0; background: transparent; cursor: pointer; padding: 0; } .marker { position: absolute; width: 19px; height: 29px; transform: translateX(-50%); border: 0; background: transparent; cursor: pointer; padding: 0; }
.marker i { display: block; width: 10px; height: 10px; border: 2px solid #8eb3a7; background: #16352e; rotate: 45deg; box-shadow: 0 0 0 3px #0d231e; } .marker i { display: block; width: 10px; height: 10px; border: 2px solid #8eb3a7; background: #16352e; rotate: 45deg; box-shadow: 0 0 0 3px #0d231e; }
.marker.document i { border-radius: 50%; rotate: 0deg; width: 9px; height: 9px; border-color: #89948f; background: #263a34; } .marker.document i { border-radius: 50%; rotate: 0deg; width: 9px; height: 9px; border-color: #89948f; background: #263a34; }
.marker.selected i { border-color: #eea458; background: #eea458; } .marker.selected i { border-color: #eea458; background: #eea458; }
.timeline-key { border-left: 1px solid #314a43; padding-left: 24px; font: 8px IBM Plex Mono; color: #759087; display: flex; gap: 18px; }.timeline-key span { display: flex; gap: 5px; }.timeline-key i { width: 7px; height: 7px; background: #8eb3a7; rotate: 45deg; }.timeline-key .amber i { background: #eea458; } .timeline-key { border-left: 1px solid #314a43; padding-left: 24px; font: 8px IBM Plex Mono; color: #759087; display: flex; gap: 18px; }.timeline-key span { display: flex; gap: 5px; }.timeline-key i { width: 7px; height: 7px; background: #8eb3a7; rotate: 45deg; }.timeline-key .amber i { background: #eea458; }
.window { position: fixed; z-index: 30; background: #bfc4bc; color: #14201d; border: 2px solid #cfd3cc; box-shadow: 5px 6px 0 #020a08, 0 0 0 1px #45534e; } .window { position: fixed; z-index: 30; max-width: calc(100vw - 24px); max-height: calc(100vh - 24px); max-height: calc(100dvh - 24px); overflow-y: auto; overscroll-behavior: contain; background: #bfc4bc; color: #14201d; border: 2px solid #cfd3cc; box-shadow: 5px 6px 0 #020a08, 0 0 0 1px #45534e; }
.window > header { height: 31px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 9px; color: #dfe9e4; background: #183f36; font: 500 11px IBM Plex Mono; cursor: move; touch-action: none; } .timeline-editor { width: min(520px, 88vw); }.timeline-editor > div { padding: 24px 27px; }.timeline-editor p { margin: 12px 0 18px; font: 12px/1.5 Special Elite; }.timeline-range-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; }
.thread-editor { width: min(520px, 88vw); }.thread-editor > div { padding: 24px 27px; }.thread-editor > div > small { color: #8a4b32; font: 600 8px IBM Plex Mono; letter-spacing: .14em; }.thread-editor p { margin: 14px 0; font: 12px/1.5 Special Elite; }.thread-endpoints { margin-top: 15px; display: grid; grid-template-columns: minmax(0,1fr) 70px minmax(0,1fr); align-items: center; gap: 9px; }.thread-endpoints b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }.thread-endpoints b:last-child { text-align: right; }.thread-endpoints i { height: 3px; background: #982e2b; box-shadow: 0 1px #5b1d1b; }.thread-tightness { margin: 20px 0; }.thread-tightness output { margin-left: auto; color: #9a332e; }.thread-tightness input { accent-color: #9b302d; padding: 0; }.thread-tightness > small { display: flex; justify-content: space-between; color: #68736d; font: 7px IBM Plex Mono; }.thread-position-control { margin-top: 17px; }.thread-position-control output { margin-left: auto; color: #9a5c2f; }.thread-position-control input { accent-color: #9a5c2f; padding: 0; }.thread-position-control > small { color: #68736d; font: 7px/1.4 IBM Plex Mono; }.thread-editor .folder-editor-actions > span { flex: 1; }.folder-editor-actions button.danger { color: #7c2925; border-color: #a25b55; }
.tag-style-picker { margin: 16px 0 4px; padding: 0; border: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }.tag-style-picker legend { margin-bottom: 6px; color: #44504c; font: 600 8px IBM Plex Mono; letter-spacing: .1em; }.tag-style-picker label { position: relative; display: grid; gap: 5px; padding: 10px; border: 1px solid #909991; background: #d6d7cf; cursor: pointer; }.tag-style-picker label.selected { border-color: #8f3833; background: #e1d4bd; box-shadow: inset 3px 0 #9c3631; }.tag-style-picker input { position: absolute; opacity: 0; }.tag-style-picker label > span { display: flex; align-items: center; gap: 7px; color: #344b44; font: 600 8px IBM Plex Mono; }.tag-style-picker label > small { color: #69746e; font: 7px IBM Plex Mono; }.tag-style-luggage i { width: 15px; height: 21px; background: #b99562; border: 1px solid #7b6040; clip-path: polygon(3px 0,12px 0,15px 3px,15px 21px,0 21px,0 3px); }.tag-style-compact i { width: 25px; height: 8px; border-left: 7px solid #a63531; background: #d7c9a9; box-shadow: 1px 1px #6e6250; }
.window > header { position: sticky; z-index: 3; top: 0; height: 31px; min-height: 31px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 9px; color: #dfe9e4; background: #183f36; font: 500 11px IBM Plex Mono; cursor: move; touch-action: none; }
.window > header span { flex: 1; }.window > header button { width: 22px; height: 21px; display: grid; place-items: center; padding: 0; background: #b7bcb4; border: 1px outset white; color: #17221f; cursor: pointer; } .window > header span { flex: 1; }.window > header button { width: 22px; height: 21px; display: grid; place-items: center; padding: 0; background: #b7bcb4; border: 1px outset white; color: #17221f; cursor: pointer; }
.document-window { width: min(610px, 60vw); }.document-window.minimized { width: min(380px, 60vw); }.document-window > nav { height: 28px; padding: 7px 10px; background: #aeb4ac; border-bottom: 1px solid #727c76; font: 9px IBM Plex Mono; } .document-window { width: min(610px, 60vw); }.document-window.minimized { width: min(380px, 60vw); }.document-window > nav { height: 28px; padding: 7px 10px; background: #aeb4ac; border-bottom: 1px solid #727c76; font: 9px IBM Plex Mono; }
.paper { margin: 17px; padding: 34px 43px; height: min(500px, 58vh); overflow: auto; background: #e8e5d8; box-shadow: inset 0 0 24px #9a968566; font-family: IBM Plex Mono; } .paper { margin: 17px; padding: 34px 43px; height: min(500px, 58vh); overflow: auto; background: #e8e5d8; box-shadow: inset 0 0 24px #9a968566; font-family: IBM Plex Mono; }
@@ -146,6 +244,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.folder-members-heading small { color: #8c5c31; font: 7px IBM Plex Mono; } .folder-members-heading small { color: #8c5c31; font: 7px IBM Plex Mono; }
.folder-members-heading > span { display: flex; gap: 6px; align-items: center; color: #66716b; font: 7px IBM Plex Mono; } .folder-members-heading > span { display: flex; gap: 6px; align-items: center; color: #66716b; font: 7px IBM Plex Mono; }
.folder-members { max-height: 260px; overflow: auto; border: 1px solid #8a918b; border-top: 0; background: #d4d5cd; } .folder-members { max-height: 260px; overflow: auto; border: 1px solid #8a918b; border-top: 0; background: #d4d5cd; }
.event-support-list > p { margin: 20px; text-align: center; color: #68746e; font: 8px IBM Plex Mono; }
.event-date-field > span:last-child { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }.event-date-field > span:last-child button { border: 1px outset #8c948e; background: #c8cbc4; color: #4d5853; padding: 0 10px; cursor: pointer; font: 8px IBM Plex Mono; }.event-date-field > span:last-child button:disabled { opacity: .45; cursor: default; }
.folder-member { min-height: 56px; padding: 8px 10px; display: grid; grid-template-columns: minmax(0, 1fr) 190px; gap: 14px; align-items: center; border-bottom: 1px solid #a1a69f; opacity: .67; } .folder-member { min-height: 56px; padding: 8px 10px; display: grid; grid-template-columns: minmax(0, 1fr) 190px; gap: 14px; align-items: center; border-bottom: 1px solid #a1a69f; opacity: .67; }
.folder-member.included { background: #e2d7bd; opacity: 1; } .folder-member.included { background: #e2d7bd; opacity: 1; }
.folder-member label { min-width: 0; display: grid; grid-template-columns: 18px 18px minmax(0, 1fr); align-items: center; gap: 7px; cursor: pointer; } .folder-member label { min-width: 0; display: grid; grid-template-columns: 18px 18px minmax(0, 1fr); align-items: center; gap: 7px; cursor: pointer; }
@@ -174,4 +274,277 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.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; }
.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) { .menubar { grid-template-columns: 1fr auto; }.menubar nav { display: none; }.terminal-status { font-size: 0; }.documents-panel { width: 275px; }.documents-panel.closed { margin-left: -275px; }.timeline { grid-template-columns: 115px 1fr; padding: 0 12px; }.timeline-key { display: none; }.timeline-track { margin: 0 23px; }.document-window { width: 80vw; }.case-heading { left: 18px; }.case-number { display: none; }.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; } } @media (max-width: 900px) {
.menubar { grid-template-columns: 39px minmax(0, 1fr) 10px; gap: 5px; padding: 0 8px; }
.brand { gap: 0; }
.brand > span:not(.brand-mark) { display: none; }
.brand-mark { width: 33px; height: 33px; }
.menubar nav { min-width: 0; display: flex; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; overscroll-behavior-inline: contain; -webkit-overflow-scrolling: touch; }
.menubar nav::-webkit-scrollbar { display: none; }
.menubar nav button { flex: 0 0 auto; padding: 0 10px; font-size: 8px; letter-spacing: .05em; white-space: nowrap; }
.admin-menu { flex: 0 0 auto; }
.menubar nav .admin-menu-items { position: fixed; top: 68px; right: 8px; width: min(235px, calc(100vw - 16px)); height: auto; }
.terminal-status { justify-content: center; gap: 0; font-size: 0; }
.terminal-status span { display: none; }
.documents-panel { width: 275px; }
.documents-panel.closed { margin-left: -275px; }
.timeline { grid-template-columns: 115px 1fr; padding: 0 12px; }
.timeline-key { display: none; }
.timeline-track { margin: 0 23px; }
.document-window { width: 80vw; }
.case-heading { left: 18px; }
.case-number { display: none; }
.brief-panel { position: fixed; inset: 0; width: 100vw; height: 100dvh; max-height: none; border-width: 0; box-shadow: none; }
.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; }
.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; }
.board-actions button { width: 38px; padding: 0; justify-content: center; gap: 0; font-size: 0; }
.board-actions > b { display: none; }
.board-actions > span { margin: 0 2px; }
}
@media (max-width: 900px) and (orientation: portrait) {
.board-actions { top: 50%; right: 8px; bottom: auto; left: auto; width: 48px; height: auto; max-height: calc(100% - 20px); padding: 5px; transform: translateY(-50%); flex-direction: column; align-items: center; overflow-y: auto; }
.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; }
}
@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; } }
/* 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-plate { display: grid; justify-items: center; padding: 20px; }
.splash .seal { width: 78px; height: 78px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 15px IBM Plex Mono; margin-bottom: 30px; }
.splash-title { margin: 0; color: #e9ede8; font: 40px Special Elite; letter-spacing: .06em; }
.splash-sub { margin: 6px 0 0; font: 11px IBM Plex Mono; letter-spacing: .32em; color: #7f9a92; text-transform: uppercase; }
.splash-actions { display: flex; gap: 14px; margin: 40px 0 22px; }
.splash-button { background: #10362d; border: 1px solid #6f8f85; color: #d8ded9; padding: 13px 26px; font: 11px IBM Plex Mono; letter-spacing: .16em; cursor: pointer; }
.splash-button:hover:not(:disabled) { background: #1a493d; color: #fff; }
.splash-button.primary { background: #a2551f; border-color: #d58a46; color: #f6ead9; }
.splash-button.primary:hover:not(:disabled) { background: #b8622a; }
.splash-button:disabled { opacity: .5; cursor: progress; }
.splash-status { font: 9px IBM Plex Mono; letter-spacing: .2em; color: #5f7b73; }
/* Full-screen visual-novel cutscene: NPC art fills the viewport, a translucent
panel floats at the bottom. One fluid layout for mobile and desktop. */
.dialogue { position: fixed; inset: 0; z-index: 200; height: 100vh; height: 100dvh; overflow: hidden;
display: flex; flex-direction: column; justify-content: flex-end; background: #06140f; cursor: pointer; animation: dialogue-in .25s ease; }
@keyframes dialogue-in { from { opacity: 0; } to { opacity: 1; } }
/* The portrait is a full-bleed, top-cropped, centered cover image. Reserved (empty
dark) when no art has been uploaded yet, so layout does not shift when it arrives. */
.dialogue-portrait { position: absolute; inset: 0; }
.dialogue-portrait img { width: 100%; height: 100%; object-fit: cover; object-position: top center; }
/* Legibility gradient behind the panel, present with or without art. */
.dialogue-scrim { position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(to top, #04110ef2 0%, #04110ed0 20%, #04110e55 42%, #04110e00 68%); }
.dialogue-skip { position: absolute; top: max(16px, env(safe-area-inset-top)); right: max(16px, env(safe-area-inset-right));
background: #04110e99; border: 1px solid #3c5a52; color: #cfe0d9; padding: 8px 14px; font: 9px IBM Plex Mono; letter-spacing: .18em; cursor: pointer; z-index: 2; }
.dialogue-skip:hover { border-color: #6f8f85; color: #fff; }
.dialogue-box { position: relative; z-index: 1; width: 100%; padding: 0 clamp(14px, 4vw, 40px) max(20px, env(safe-area-inset-bottom)); }
.dialogue-panel { width: min(860px, 100%); margin: 0 auto; border: 1px solid #40655b8c; border-bottom: 0;
background: #0a211de6; backdrop-filter: blur(3px); box-shadow: 0 -8px 34px #000b; padding: clamp(16px, 3.4vw, 26px) clamp(18px, 4vw, 34px) clamp(20px, 4vw, 30px); }
.dialogue-speaker { display: flex; align-items: baseline; gap: 12px; margin-bottom: 11px; flex-wrap: wrap; }
.dialogue-speaker strong { color: #e7b57e; font: 600 clamp(13px, 1.1vw + .5rem, 16px) IBM Plex Mono; letter-spacing: .06em; }
.dialogue-speaker em { color: #7f9a92; font: clamp(8px, .5vw + .3rem, 9px) IBM Plex Mono; letter-spacing: .16em; font-style: normal; text-transform: uppercase; }
.dialogue-text { margin: 0; max-width: 62ch; min-height: 4.8em; color: #eef2ec; font: clamp(16px, 1vw + .7rem, 21px)/1.6 Special Elite; text-shadow: 0 1px 6px #0007; }
.dialogue-caret { color: #d58a46; }
.dialogue-advance { margin-top: 15px; text-align: right; min-height: 12px; color: #a9c7bd; font: 9px IBM Plex Mono; letter-spacing: .2em; animation: advance-pulse 1.4s ease-in-out infinite; }
@keyframes advance-pulse { 0%, 100% { opacity: .4; } 50% { opacity: 1; } }
/* Landscape phones: tiny height — keep the panel compact and never taller than the screen allows. */
@media (orientation: landscape) and (max-height: 520px) {
.dialogue-panel { padding: 12px 20px 14px; }
.dialogue-text { min-height: 3.4em; max-height: 40vh; overflow-y: auto; }
.dialogue-advance { margin-top: 8px; }
}
@media (max-width: 900px) { .splash-title { font-size: 30px; } .splash-actions { flex-direction: column; } }
@media (prefers-reduced-motion: reduce) { .dialogue { animation: none; } .dialogue-advance { animation: none; } }
/* Admin authoring panel */
.admin { height: 100vh; height: 100dvh; display: grid; grid-template-rows: 56px 1fr 30px; background: #071916; color: #d8ded9; }
.admin-head { display: flex; align-items: center; gap: 26px; padding: 0 22px; border-bottom: 1px solid #315049; background: #0a211d; }
.admin-tabs { display: flex; gap: 4px; margin-left: 14px; }
.admin-tabs button { background: none; border: 0; padding: 8px 16px; font: 500 11px IBM Plex Mono; letter-spacing: .12em; color: #94aaa4; cursor: pointer; border-bottom: 2px solid transparent; }
.admin-tabs button:hover { color: #e4e9e4; }
.admin-tabs button.active { color: #fff; border-bottom-color: #d58a46; }
.admin-link { margin-left: auto; color: #8fb0a6; text-decoration: none; font: 10px IBM Plex Mono; letter-spacing: .12em; }
.admin-link:hover { color: #e7b57e; }
.admin-body { display: grid; grid-template-columns: 300px 1fr; min-height: 0; }
.npc-list { border-right: 1px solid #243d36; background: #0b201b; overflow: auto; display: flex; flex-direction: column; }
.npc-list-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 16px 10px; font: 600 9px IBM Plex Mono; letter-spacing: .18em; color: #78958d; }
.npc-list-head button { background: #143229; border: 1px solid #3c5a52; color: #d79754; font: 9px IBM Plex Mono; padding: 5px 10px; cursor: pointer; }
.npc-list-head button:hover { background: #1c463a; }
.npc-row { display: flex; align-items: center; gap: 12px; padding: 11px 16px; border: 0; border-bottom: 1px solid #17302a; background: none; text-align: left; cursor: pointer; color: inherit; }
.npc-row:hover { background: #12312a; }
.npc-row.selected { background: #1b3d33; box-shadow: inset 3px 0 #d58a46; }
.npc-avatar { width: 40px; height: 40px; flex: 0 0 auto; display: grid; place-items: center; overflow: hidden; border: 1px solid #3c5a52; background: #0e2a24; color: #6f9084; font: 600 15px Special Elite; }
.npc-avatar img { width: 100%; height: 100%; object-fit: cover; object-position: top center; }
.npc-row-text strong { display: block; font: 13px IBM Plex Mono; color: #dfe5e0; }
.npc-row-text small { color: #7f9a92; font: 9px IBM Plex Mono; letter-spacing: .04em; }
.npc-editor { padding: 26px 32px; overflow: auto; max-width: 720px; }
.npc-editor.empty { display: grid; place-items: center; color: #607d75; font: 11px IBM Plex Mono; }
.npc-editor-head { display: flex; align-items: baseline; gap: 14px; margin-bottom: 22px; }
.npc-editor-head h2 { margin: 0; font: 500 24px Special Elite; color: #e9ede8; }
.npc-editor-head code { color: #8fb0a6; font: 10px IBM Plex Mono; background: #0e2a24; padding: 3px 8px; border: 1px solid #2c473f; }
.npc-editor-head .danger { margin-left: auto; background: none; border: 1px solid #7a3b34; color: #d78a7f; font: 9px IBM Plex Mono; padding: 6px 12px; cursor: pointer; }
.npc-editor-head .danger:disabled { opacity: .4; cursor: not-allowed; }
.npc-editor-head .danger:not(:disabled):hover { background: #4a221d; }
.admin-field { display: grid; gap: 5px; margin-bottom: 15px; }
.admin-field label { font: 600 9px IBM Plex Mono; letter-spacing: .14em; color: #86a199; }
.admin-field input, .admin-field select, .pose-key { background: #0c231e; border: 1px solid #35544c; color: #e4e9e4; padding: 9px 11px; font: 12px IBM Plex Mono; }
.admin-field input:focus, .admin-field select:focus, .pose-key:focus { outline: 0; border-color: #9a683d; box-shadow: inset 0 0 0 1px #6f4b2f; }
.admin-save { margin-top: 4px; background: #a2551f; border: 1px solid #d58a46; color: #f6ead9; padding: 10px 20px; font: 10px IBM Plex Mono; letter-spacing: .12em; cursor: pointer; }
.admin-save:disabled { opacity: .45; cursor: default; background: #143229; border-color: #35544c; color: #8fb0a6; }
.npc-editor h3 { margin: 30px 0 12px; font: 600 10px IBM Plex Mono; letter-spacing: .18em; color: #86a199; }
.pose-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 14px; }
.pose-card { position: relative; margin: 0; border: 1px solid #35544c; background: #0e2a24; }
.pose-card.is-default { border-color: #d58a46; box-shadow: 0 0 0 1px #d58a4655; }
.pose-card img { width: 100%; aspect-ratio: 3/4; object-fit: cover; object-position: top center; display: block; }
.pose-card figcaption { padding: 7px 9px; font: 10px IBM Plex Mono; color: #cfe0d9; letter-spacing: .04em; }
.pose-card.is-default figcaption::after { content: ' · default'; color: #d58a46; }
.pose-remove { position: absolute; top: 5px; right: 5px; width: 22px; height: 22px; border: 0; border-radius: 2px; background: #04110ecc; color: #e5b3ab; font-size: 15px; line-height: 1; cursor: pointer; }
.pose-remove:hover { background: #4a221d; color: #fff; }
.pose-upload { display: flex; gap: 12px; align-items: center; margin-top: 16px; flex-wrap: wrap; }
.pose-upload .pose-key { flex: 0 0 220px; }
.pose-upload input[type=file] { font: 10px IBM Plex Mono; color: #9bb0a9; }
.admin-hint, .admin-note { color: #6f8b83; font: 9px/1.5 IBM Plex Mono; }
.admin-hint { display: block; margin-top: 16px; }
.admin-note { margin-top: 20px; }
.admin-empty { color: #607d75; font: 10px IBM Plex Mono; padding: 16px; }
.mystery-list { padding: 26px 32px; overflow: auto; }
.mystery-row { padding: 12px 0; border-bottom: 1px solid #1c352e; display: grid; gap: 3px; }
.mystery-row strong { color: #dfe5e0; font: 14px IBM Plex Mono; }
.mystery-row span { color: #7f9a92; font: 9px IBM Plex Mono; letter-spacing: .06em; }
.admin-foot { display: flex; align-items: center; padding: 0 22px; border-top: 1px solid #243d36; background: #0a211d; font: 9px IBM Plex Mono; letter-spacing: .08em; color: #718d84; }
.boot .admin-link { color: #8fb0a6; }
@media (max-width: 760px) { .admin-body { grid-template-columns: 1fr; } .npc-list { max-height: 34vh; } }
/* Mystery story-flow graph editor */
.mystery-row { width: 100%; border: 0; border-bottom: 1px solid #1c352e; background: none; text-align: left; cursor: pointer; }
.mystery-row:hover { background: #12312a; }
.graph-loading, .graph-empty { display: grid; place-items: center; height: 100%; color: #607d75; font: 11px IBM Plex Mono; }
.graph-editor { display: flex; flex-direction: column; min-height: 0; background: #08191500; }
.graph-toolbar { display: flex; align-items: center; gap: 10px; padding: 8px 16px; border-bottom: 1px solid #243d36; background: #0a211d; flex-wrap: wrap; }
.graph-toolbar strong { color: #e9ede8; font: 13px IBM Plex Mono; margin-right: 8px; }
.graph-back { background: none; border: 1px solid #3c5a52; color: #9bb0a9; font: 9px IBM Plex Mono; padding: 6px 11px; cursor: pointer; }
.graph-add-label { color: #78958d; font: 9px IBM Plex Mono; letter-spacing: .12em; margin-left: 8px; }
.graph-add { background: #143229; border: 1px solid #3c5a52; color: #d79754; font: 9px IBM Plex Mono; padding: 6px 10px; cursor: pointer; }
.graph-add:hover { background: #1c463a; }
.graph-wiring { color: #e7b57e; font: 9px IBM Plex Mono; letter-spacing: .06em; }
.graph-zoom { margin-left: auto; color: #6f8b83; font: 9px IBM Plex Mono; }
.graph-main { flex: 1; display: flex; min-height: 0; }
.graph-canvas { position: relative; flex: 1; overflow: hidden; background:
radial-gradient(#49615a33 1px, transparent 1px), #0b1d19; background-size: 22px 22px; touch-action: none; cursor: grab; }
.graph-canvas.wiring { cursor: crosshair; }
.graph-world { position: absolute; top: 0; left: 0; transform-origin: 0 0; }
.graph-wires { position: absolute; top: 0; left: 0; overflow: visible; pointer-events: none; }
.graph-wire { fill: none; stroke: #c98a4c; stroke-width: 2; opacity: .8; }
.gnode { position: absolute; background: #0e2a24; border: 1px solid #40655b; box-shadow: 4px 5px 0 #04110e66; user-select: none; }
.gnode.selected { border-color: #e7b57e; box-shadow: 0 0 0 1px #e7b57e, 4px 5px 0 #04110e66; }
.gnode.entry { box-shadow: -4px 0 0 #6fbf8b, 4px 5px 0 #04110e66; }
.gnode.entry.selected { box-shadow: -4px 0 0 #6fbf8b, 0 0 0 1px #e7b57e; }
.gnode-head { height: 30px; display: flex; align-items: center; gap: 7px; padding: 0 10px; cursor: grab; border-bottom: 1px solid #24413a; }
.gnode-head:active { cursor: grabbing; }
.gnode-type { font: 600 8px IBM Plex Mono; letter-spacing: .1em; text-transform: uppercase; padding: 2px 5px; border-radius: 2px; background: #24413a; color: #9bd; }
.type-cutscene .gnode-type { color: #d7a0e0; } .type-dialogue .gnode-type { color: #7fc7b6; }
.type-level .gnode-type { color: #e7b57e; } .type-det_gate .gnode-type { color: #d89a9a; } .type-llm_gate .gnode-type { color: #c9b06e; }
.gnode-label { flex: 1; font: 11px IBM Plex Mono; color: #e4e9e4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.gnode-entry { color: #6fbf8b; font-size: 10px; }
.gnode-sub { height: 18px; padding: 0 10px; font: 8px IBM Plex Mono; color: #7f9a92; display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.gnode-terminals { padding-bottom: 4px; }
.gterm { position: relative; display: flex; align-items: center; padding: 0 12px; }
.gterm-label { font: 9px IBM Plex Mono; color: #b6c6c0; margin-left: auto; }
.gport { position: absolute; right: -7px; top: 50%; transform: translateY(-50%); width: 12px; height: 12px; border-radius: 50%; border: 2px solid #6f8f85; background: #0b1d19; cursor: pointer; padding: 0; }
.gport:hover { border-color: #e7b57e; }
.gport.wired { background: #c98a4c; border-color: #c98a4c; }
.gport.active { border-color: #e7b57e; box-shadow: 0 0 0 3px #e7b57e55; }
.ginput { position: absolute; left: -6px; top: 20px; width: 11px; height: 11px; border-radius: 50%; background: #2c473f; border: 2px solid #6f8f85; }
.graph-inspector { width: 280px; flex: 0 0 auto; border-left: 1px solid #243d36; background: #0b201b; overflow: auto; }
.inspector-body { padding: 16px; display: grid; gap: 12px; }
.inspector-head { display: flex; align-items: center; gap: 10px; }
.ins-entry { margin-left: auto; background: #143229; border: 1px solid #3c5a52; color: #6fbf8b; font: 8px IBM Plex Mono; padding: 5px 9px; cursor: pointer; }
.ins-field { display: grid; gap: 4px; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #86a199; }
.ins-field input, .ins-field select { background: #0c231e; border: 1px solid #35544c; color: #e4e9e4; padding: 7px 9px; font: 11px IBM Plex Mono; }
.ins-check { display: flex; align-items: center; gap: 8px; font: 10px IBM Plex Mono; color: #b6c6c0; }
.ins-terminals-head { display: flex; justify-content: space-between; align-items: center; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #86a199; margin-top: 6px; }
.ins-terminals-head button { background: #143229; border: 1px solid #3c5a52; color: #d79754; font: 8px IBM Plex Mono; padding: 4px 8px; cursor: pointer; }
.ins-terminal { display: flex; align-items: center; gap: 6px; }
.ins-terminal input { flex: 0 0 84px; background: #0c231e; border: 1px solid #35544c; color: #e4e9e4; padding: 5px 7px; font: 10px IBM Plex Mono; }
.ins-terminal-to { flex: 1; font: 9px IBM Plex Mono; color: #7f9a92; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ins-unwire, .ins-term-del { background: none; border: 1px solid #3c5a52; color: #9bb0a9; width: 22px; height: 24px; cursor: pointer; font-size: 12px; }
.ins-unwire:hover, .ins-term-del:hover { border-color: #d78a7f; color: #e5b3ab; }
.ins-delete { margin-top: 8px; background: none; border: 1px solid #7a3b34; color: #d78a7f; font: 9px IBM Plex Mono; padding: 8px; cursor: pointer; }
.ins-delete:hover { background: #4a221d; }
/* Utterance sub-canvas (dialogue crafter) */
.graph-editor { position: relative; }
.utterance-overlay { position: absolute; inset: 0; z-index: 20; display: flex; flex-direction: column; background: #071916; }
.ins-utterances { background: #143229; border: 1px solid #3c5a52; color: #7fc7b6; font: 9px IBM Plex Mono; padding: 8px; cursor: pointer; }
.ins-utterances:hover { background: #1c463a; }
.ucard { position: absolute; background: #10241f; border: 1px solid #40655b; box-shadow: 3px 4px 0 #04110e66; user-select: none; display: flex; flex-direction: column; }
.ucard.u-npc { border-left: 3px solid #7fc7b6; }
.ucard.u-player { border-left: 3px solid #e7b57e; background: #18231d; }
.ucard.selected { border-color: #e7b57e; box-shadow: 0 0 0 1px #e7b57e, 3px 4px 0 #04110e66; }
.ucard-head { height: 24px; display: flex; align-items: center; gap: 6px; padding: 0 9px; cursor: grab; border-bottom: 1px solid #24413a; }
.ucard-head:active { cursor: grabbing; }
.ucard-badge { font: 600 8px IBM Plex Mono; letter-spacing: .08em; color: #cfe0d9; text-transform: uppercase; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ucard-pose { margin-left: auto; font: 8px IBM Plex Mono; color: #86a199; }
.ucard-text { flex: 1; padding: 6px 9px; font: 12px/1.3 Special Elite; color: #dfe5e0; overflow: hidden; }
.ucard-text em { color: #5f7b73; }
.uinput { position: absolute; left: -6px; top: 50%; transform: translateY(-50%); width: 11px; height: 11px; border-radius: 50%; background: #2c473f; border: 2px solid #6f8f85; }
.uport { position: absolute; width: 13px; height: 13px; border-radius: 50%; border: 2px solid #6f8f85; background: #0b1d19; cursor: pointer; padding: 0; }
.uport.flow { right: -7px; top: 50%; transform: translateY(-50%); }
.uport.options { bottom: -7px; left: 50%; transform: translateX(-50%); border-color: #c98a4c; }
.uport:hover { border-color: #e7b57e; }
.uport.wired { background: #c98a4c; border-color: #c98a4c; }
.uport.active { box-shadow: 0 0 0 3px #e7b57e55; border-color: #e7b57e; }
.usink { position: absolute; height: 40px; display: flex; align-items: center; padding: 0 12px; background: #241a12; border: 1px dashed #c98a4c; color: #e7b57e; cursor: pointer; }
.usink:hover { background: #30241a; }
.usink-label { font: 9px IBM Plex Mono; letter-spacing: .06em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.graph-wire.exit { stroke: #e7b57e; }
.graph-wire.option { stroke: #7fc7b6; stroke-dasharray: 5 4; opacity: .7; }
.ins-text { min-height: 70px; resize: vertical; background: #0c231e; border: 1px solid #35544c; color: #e4e9e4; padding: 8px; font: 13px/1.4 Special Elite; }
.ins-links { font: 9px IBM Plex Mono; color: #9bb0a9; display: grid; gap: 6px; }
.ins-links > div { display: flex; align-items: center; gap: 8px; }
/* 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; }
.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; } }
.title-card-inner small { font: 10px IBM Plex Mono; letter-spacing: .3em; color: #7f9a92; text-transform: uppercase; }
.title-card-inner h1 { margin: 0; font: 44px Special Elite; letter-spacing: .04em; color: #eef2ec; max-width: 16ch; }
.cutscene-missing { color: #b56d30 !important; letter-spacing: .08em !important; }
.cutscene-begin { margin-top: 10px; background: #a2551f; border: 1px solid #d58a46; color: #f6ead9; padding: 11px 24px; font: 11px IBM Plex Mono; letter-spacing: .16em; cursor: pointer; }
.cutscene-begin:hover { background: #b8622a; }
.menubar nav button.report-back { color: #f6ead9; background: #a2551f; }
.menubar nav button.report-back:hover { background: #b8622a; }
@media (max-width: 900px) { .title-card-inner h1 { font-size: 30px; } }
/* Utterance cards: expand to full content, ports anchored to top, colour by speaker */
.ucard { height: auto; min-height: 58px; }
.ucard.u-npc { background: #0f2a24; border-left: 3px solid #6fbfa9; }
.ucard.u-player { background: #2b2113; border-left: 3px solid #e0a253; }
.u-npc .ucard-badge { color: #9fe0cd; }
.u-player .ucard-badge { color: #e8bd80; }
.ucard-text { flex: none; overflow: visible; white-space: pre-wrap; min-height: 20px; }
.uinput { top: 18px; transform: none; }
.uport.flow { top: 18px; transform: none; }
.uport.options { top: 44px; bottom: auto; left: auto; right: -7px; transform: none; }
/* Clickable wires (delete a connection by clicking it) */
.wire-hit { fill: none; stroke: transparent; stroke-width: 16; pointer-events: stroke; cursor: pointer; }
.wire-hit:hover + .graph-wire { stroke-width: 4; filter: drop-shadow(0 0 3px #e7b57e); }
/* Branching dialogue: player choice buttons */
.dialogue-choices { display: grid; gap: 8px; margin-top: 14px; }
.dialogue-choices button { text-align: left; background: #0e2a24e6; border: 1px solid #6f8f85; color: #eef2ec; padding: 12px 16px; font: 15px/1.4 Special Elite; cursor: pointer; min-height: 46px; }
.dialogue-choices button:hover { background: #1a493d; border-color: #e7b57e; }
/* Vertical mystery-graph nodes: input top, output terminals along the bottom */
.gnode { display: flex; flex-direction: column; }
.ginput { top: -6px; left: 50%; right: auto; bottom: auto; transform: translateX(-50%); }
.gnode-outs { display: flex; justify-content: space-around; align-items: flex-end; gap: 4px; margin-top: auto; padding: 2px 6px 0; }
.gout { position: relative; flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 3px; padding-bottom: 9px; }
.gout-label { font: 9px IBM Plex Mono; color: #b6c6c0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.gout .gport { position: absolute; bottom: -7px; left: 50%; top: auto; right: auto; transform: translateX(-50%); }
/* Utterance canvas vertical flow: input on top, output on the bottom */
.uinput { top: -6px; bottom: auto; left: 50%; right: auto; transform: translateX(-50%); }
.uport.flow { bottom: -7px; top: auto; left: 50%; right: auto; transform: translateX(-50%); }
+120 -31
View File
@@ -1,6 +1,24 @@
export type EvidenceType = 'folder' | 'evidence' | 'note' | 'event' export type ExhibitType = 'folder' | 'document' | 'note' | 'event' | 'party'
export type PartyKind = 'person' | 'organization'
export type OrganizationKind = 'business' | 'public_body' | 'association' | 'informal_group' | 'other'
export type SourceFileType = 'image' | 'pdf' | 'web_capture' | 'email' | 'article' | 'filing' | 'price_list' | 'text' | 'file' export type SourceFileType = 'image' | 'pdf' | 'web_capture' | 'email' | 'article' | 'filing' | 'price_list' | 'text' | 'file'
export interface CanvasPlacement {
x: number
y: number
width: number
height: number
rotation: number
zIndex: number
hidden: boolean
}
export interface ExhibitBase extends CanvasPlacement {
id: string
type: ExhibitType
title: string
}
export interface DocumentRegion { export interface DocumentRegion {
id: string id: string
label: string label: string
@@ -8,12 +26,17 @@ export interface DocumentRegion {
date?: string date?: string
} }
export interface CaseDocument { export interface FolderExhibit extends ExhibitBase {
id: string type: 'folder'
title: string content: string
kind: string isOpen: boolean
date: string }
export interface DocumentExhibit extends ExhibitBase {
type: 'document'
publishedAt?: string publishedAt?: string
capturedAt?: string
sourceUri?: string
body: string[] body: string[]
regions: DocumentRegion[] regions: DocumentRegion[]
assetId?: string assetId?: string
@@ -24,49 +47,115 @@ export interface CaseDocument {
metadata: Record<string, string> metadata: Record<string, string>
} }
export interface Evidence { export interface NoteExhibit extends ExhibitBase {
id: string type: 'note'
type: EvidenceType
title: string
content: string content: string
sourceDocumentId?: string
sourceRegionId?: string
eventDate?: string
x: number
y: number
width: number
config?: Record<string, unknown>
/** Compatibility projection for clients predating the generic relation graph. */
containedDocumentIds?: string[]
} }
export interface WidgetRelation { export interface EventExhibit extends ExhibitBase {
id: string type: 'event'
fromWidgetId: string content: string
toWidgetId: string eventDate?: string
type: string
sortOrder?: number
config?: Record<string, unknown>
} }
export interface PartyExhibit extends ExhibitBase {
type: 'party'
content: string
partyKind: PartyKind
organizationKind?: OrganizationKind
aliases: string[]
}
export type Exhibit = FolderExhibit | DocumentExhibit | NoteExhibit | EventExhibit | PartyExhibit
export type Evidence = Exclude<Exhibit, DocumentExhibit>
export type CaseDocument = DocumentExhibit
export type EvidenceType = Evidence['type']
interface ExhibitRelationBase {
id: string
fromExhibitId: string
toExhibitId: string
sortOrder: number
note?: string
}
export interface FolderMembership extends ExhibitRelationBase { type: 'contains' }
export interface EventSupportRelation extends ExhibitRelationBase { type: 'supports' }
export interface PartyAssociationRelation extends ExhibitRelationBase { type: 'concerns' }
export interface ProvenanceRelation extends ExhibitRelationBase { type: 'source'; sourceRegionId?: string }
export type ExhibitRelation = FolderMembership | EventSupportRelation | PartyAssociationRelation | ProvenanceRelation
export interface Connection { export interface Connection {
id: string id: string
fromEvidenceId: string fromExhibitId: string
toEvidenceId: string toExhibitId: string
label?: string
/** Percentage from slack (0) to taut (100). */
tightness?: number
tagStyle?: 'luggage' | 'compact'
/** Percentage along the thread from its source exhibit. */
tagPosition?: number
/** Signed perpendicular distance from the thread, constrained by tightness. */
tagOffset?: number
} }
export interface Viewport { x: number; y: number; zoom: number } export interface Viewport { x: number; y: number; zoom: number }
export interface TimelineRange { start: string; end: string }
export type BoardViewPlacement =
| { mode: 'docked'; dockEdge: 'top' | 'right' | 'bottom' | 'left'; size: number }
| { mode: 'canvas' | 'window'; x: number; y: number; width: number; height: number }
export interface TimelineView {
id: string
type: 'timeline'
placement: BoardViewPlacement
visible: boolean
zIndex: number
rangeMode: 'auto' | 'fixed'
range?: TimelineRange
}
export type BoardView = TimelineView
export interface TemporalFact {
id: string
exhibitId: string
kind: 'published' | 'captured' | 'occurred' | 'region_date'
start: string
end?: string
label: string
}
export interface BriefConcept {
id: string
label: string
context: string
expectedPartyKind?: PartyKind
resolvedPartyExhibitId?: string
}
export interface LevelBrief { body: string; concepts: BriefConcept[] }
export interface CaseState { export interface CaseState {
id: string id: string
title: string title: string
subtitle: string subtitle: string
documents: CaseDocument[] exhibits: Exhibit[]
evidence: Evidence[] relations: ExhibitRelation[]
relations: WidgetRelation[]
connections: Connection[] connections: Connection[]
views: BoardView[]
viewport: Viewport viewport: Viewport
brief: LevelBrief
revision: number
updatedAt?: string updatedAt?: string
levelStatus?: string levelStatus?: string
sourceTemplateVersionId?: string
editingAllowed?: boolean editingAllowed?: boolean
} }
export function isDocumentExhibit(exhibit: Exhibit): exhibit is DocumentExhibit { return exhibit.type === 'document' }
export function isEvidenceExhibit(exhibit: Exhibit): exhibit is Evidence { return exhibit.type !== 'document' }
export function isFolderExhibit(exhibit: Exhibit): exhibit is FolderExhibit { return exhibit.type === 'folder' }
export function isEventExhibit(exhibit: Exhibit): exhibit is EventExhibit { return exhibit.type === 'event' }
export function isPartyExhibit(exhibit: Exhibit): exhibit is PartyExhibit { return exhibit.type === 'party' }
+237
View File
@@ -0,0 +1,237 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react'
type Utterer = 'npc' | 'player'
type Utterance = {
id: string; nodeId: string; utterer: Utterer; npcId: string | null; poseKey: string | null; text: string
parentUtteranceId: string | null; terminalId: string | null
xpos: number; ypos: number; sortOrder: number
}
type Terminal = { id: string; terminalKey: string; label: string }
type Npc = { id: string; name: string; poses: { poseKey: string; url: string }[] }
// Vertical layout: input on top, output on the bottom; exit sinks in a row below.
const UW = 220, UH_DEFAULT = 72, SINK_W = 150
async function api<T>(url: string, method: string, body?: unknown): Promise<T> {
const response = await fetch(url, { method, headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined })
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || `Request failed (${response.status})`)
return response.json().catch(() => ({} as T))
}
export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStatus }: {
nodeId: string; nodeLabel: string; terminals: Terminal[]; onClose: () => void; setStatus: (message: string) => void
}) {
const [utterances, setUtterances] = useState<Utterance[]>([])
const [npcs, setNpcs] = useState<Npc[]>([])
const [view, setView] = useState({ x: 40, y: 40, zoom: 1 })
const [selectedId, setSelectedId] = useState<string | null>(null)
const [wiringFrom, setWiringFrom] = useState<{ id: string } | null>(null)
const canvasRef = useRef<HTMLDivElement>(null)
const drag = useRef<{ id: string; startX: number; startY: number; origX: number; origY: number } | 'pan' | null>(null)
const panRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null)
// Cards auto-expand to their text, so measure heights to place the bottom output
// port (offsetHeight is layout px, unaffected by the canvas's transform: scale).
const cardRefs = useRef(new Map<string, HTMLDivElement>())
const [heights, setHeights] = useState<Record<string, number>>({})
const reload = useCallback(async () => {
try { setUtterances(await api<Utterance[]>(`/api/admin/story-nodes/${nodeId}/utterances`, 'GET')) }
catch (error) { setStatus(String((error as Error).message || error)) }
}, [nodeId, setStatus])
useEffect(() => { void reload(); api<Npc[]>('/api/admin/npcs', 'GET').then(setNpcs).catch(() => {}) }, [reload])
useLayoutEffect(() => {
const next: Record<string, number> = {}
let changed = Object.keys(heights).length !== cardRefs.current.size
for (const [id, el] of cardRefs.current) { next[id] = el.offsetHeight; if (heights[id] !== next[id]) changed = true }
if (changed) setHeights(next)
}, [utterances, heights])
// Undo stack of inverse operations (connection edits, Tab creation).
const undoRef = useRef<Array<() => Promise<void>>>([])
const pushUndo = (fn: () => Promise<void>) => { undoRef.current.push(fn); if (undoRef.current.length > 40) undoRef.current.shift() }
const doUndo = async () => {
const fn = undoRef.current.pop()
if (!fn) { setStatus('Nothing to undo'); return }
try { await fn(); await reload() } catch (error) { setStatus(String((error as Error).message || error)) }
}
// Keyboard (ignored while typing in a field): Ctrl/Cmd+Z undoes; Tab adds a child under
// the selected utterance (one child = linear, a second makes them player options);
// 1/2 set the selected utterance's speaker.
const keyActionRef = useRef((_event: KeyboardEvent) => {})
keyActionRef.current = (event: KeyboardEvent) => {
const tag = (document.activeElement?.tagName || '').toLowerCase()
if (tag === 'input' || tag === 'textarea' || tag === 'select') return
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') { event.preventDefault(); void doUndo(); return }
if (!selectedId) return
if (event.key === 'Tab') {
event.preventDefault()
const parent = utterances.find(u => u.id === selectedId)
if (!parent) return
const siblings = utterances.filter(u => u.parentUtteranceId === parent.id)
void (async () => {
try {
const created = await api<Utterance>(`/api/admin/story-nodes/${nodeId}/utterances`, 'POST',
{ utterer: 'player', xpos: Math.round(parent.xpos + siblings.length * 240), ypos: Math.round(parent.ypos + 130), text: '' })
await api(`/api/admin/utterances/${created.id}`, 'PATCH', { parentUtteranceId: parent.id })
// 2+ children ⇒ player options; a lone child stays a linear NPC next line.
if (siblings.length + 1 >= 2) for (const child of [...siblings, created]) await api(`/api/admin/utterances/${child.id}`, 'PATCH', { utterer: 'player', npcId: null })
else await api(`/api/admin/utterances/${created.id}`, 'PATCH', { utterer: 'npc' })
pushUndo(() => api(`/api/admin/utterances/${created.id}`, 'DELETE'))
await reload(); setSelectedId(parent.id) // keep the parent selected to add more options
} catch (error) { setStatus(String((error as Error).message || error)) }
})()
} else if (event.key === '1') void patch(selectedId, { utterer: 'npc' })
else if (event.key === '2') void patch(selectedId, { utterer: 'player', npcId: null })
}
useEffect(() => {
const handler = (event: KeyboardEvent) => keyActionRef.current(event)
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
const patch = async (id: string, body: Record<string, unknown>, silent = false) => {
try { await api(`/api/admin/utterances/${id}`, 'PATCH', body); if (!silent) await reload() }
catch (error) { setStatus(String((error as Error).message || error)) }
}
const add = async (utterer: Utterer) => {
const rect = canvasRef.current?.getBoundingClientRect()
const cx = ((rect ? rect.width / 2 : 250) - view.x) / view.zoom, cy = ((rect ? rect.height / 2 : 200) - view.y) / view.zoom
try { const u = await api<Utterance>(`/api/admin/story-nodes/${nodeId}/utterances`, 'POST', { utterer, xpos: Math.round(cx), ypos: Math.round(cy) }); await reload(); setSelectedId(u.id) }
catch (error) { setStatus(String((error as Error).message || error)) }
}
const onBgPointerDown = (event: React.PointerEvent) => {
if (wiringFrom) { setWiringFrom(null); return }
panRef.current = { startX: event.clientX, startY: event.clientY, origX: view.x, origY: view.y }; drag.current = 'pan'; setSelectedId(null)
}
const onCardPointerDown = (event: React.PointerEvent, u: Utterance) => {
;(event.target as HTMLElement).setPointerCapture?.(event.pointerId)
drag.current = { id: u.id, startX: event.clientX, startY: event.clientY, origX: u.xpos, origY: u.ypos }; setSelectedId(u.id)
}
const onPointerMove = (event: React.PointerEvent) => {
if (drag.current === 'pan' && panRef.current) { const p = panRef.current; setView(v => ({ ...v, x: p.origX + event.clientX - p.startX, y: p.origY + event.clientY - p.startY })); return }
if (drag.current && drag.current !== 'pan') { const d = drag.current; setUtterances(list => list.map(u => u.id === d.id ? { ...u, xpos: d.origX + (event.clientX - d.startX) / view.zoom, ypos: d.origY + (event.clientY - d.startY) / view.zoom } : u)) }
}
const onPointerUp = async () => {
const state = drag.current; drag.current = null; panRef.current = null
if (state && state !== 'pan') { const u = utterances.find(x => x.id === state.id); if (u) await patch(u.id, { xpos: Math.round(u.xpos), ypos: Math.round(u.ypos) }, true) }
}
const onWheel = (event: React.WheelEvent) => {
const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return
const px = event.clientX - rect.left, py = event.clientY - rect.top, factor = event.deltaY < 0 ? 1.1 : 1 / 1.1
setView(v => { const zoom = Math.min(2, Math.max(0.35, v.zoom * factor)); return { zoom, x: px - (px - v.x) * (zoom / v.zoom), y: py - (py - v.y) * (zoom / v.zoom) } })
}
// A card's children are what come after it: dragging its port to another card makes
// that card a child. One child ⇒ solid (linear next line); two or more ⇒ dotted
// (player options). A card can instead exit the node by wiring to a terminal sink.
const link = (id: string, body: Record<string, unknown>, undoBody: Record<string, unknown>) => {
pushUndo(() => api(`/api/admin/utterances/${id}`, 'PATCH', undoBody)); void patch(id, body)
}
const targetCard = (u: Utterance) => {
if (!wiringFrom) { setSelectedId(u.id); return }
const source = wiringFrom.id; setWiringFrom(null)
if (source === u.id) return
link(u.id, { parentUtteranceId: source }, { parentUtteranceId: u.parentUtteranceId })
}
const targetSink = (terminalId: string) => {
const source = wiringFrom ? utterances.find(x => x.id === wiringFrom.id) : null; setWiringFrom(null)
if (source) link(source.id, { terminalId }, { terminalId: source.terminalId })
}
const byId = new Map(utterances.map(u => [u.id, u]))
const childCount = new Map<string, number>()
for (const u of utterances) if (u.parentUtteranceId) childCount.set(u.parentUtteranceId, (childCount.get(u.parentUtteranceId) || 0) + 1)
const npcName = (id: string | null) => npcs.find(n => n.id === id)?.name || 'NPC'
const selected = utterances.find(u => u.id === selectedId) || null
const cardH = (u: Utterance) => heights[u.id] ?? UH_DEFAULT
const topPort = (u: Utterance) => ({ x: u.xpos + UW / 2, y: u.ypos })
const bottomPort = (u: Utterance) => ({ x: u.xpos + UW / 2, y: u.ypos + cardH(u) })
// Exit sinks dock in a row below the utterances so flow reads top-to-bottom.
const sinkRowY = (utterances.length ? Math.max(...utterances.map(u => u.ypos + cardH(u))) : 120) + 56
const sinkPos = (i: number) => ({ x: 40 + i * (SINK_W + 24), y: sinkRowY })
const sinkInput = (i: number) => ({ x: sinkPos(i).x + SINK_W / 2, y: sinkRowY })
const curve = (a: { x: number; y: number }, b: { x: number; y: number }) => { const dy = Math.max(30, Math.abs(b.y - a.y) / 2); return `M${a.x},${a.y} C${a.x},${a.y + dy} ${b.x},${b.y - dy} ${b.x},${b.y}` }
return <div className="utterance-overlay">
<div className="graph-toolbar">
<button className="graph-back" onClick={onClose}> Graph</button>
<strong>{nodeLabel || 'Dialogue'} · utterances</strong>
<span className="graph-add-label">Add:</span>
<button className="graph-add" onClick={() => add('npc')}>NPC line</button>
<button className="graph-add" onClick={() => add('player')}>Player choice</button>
{wiringFrom && <span className="graph-wiring">Click the next card (2+ options) or an exit · click empty to cancel</span>}
<span className="graph-zoom">{Math.round(view.zoom * 100)}%</span>
</div>
<div className="graph-main">
<div ref={canvasRef} className={`graph-canvas${wiringFrom ? ' wiring' : ''}`} onPointerDown={onBgPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onWheel={onWheel}>
<div className="graph-world" style={{ transform: `translate(${view.x}px,${view.y}px) scale(${view.zoom})` }}>
<svg className="graph-wires" width="6000" height="6000">
{utterances.flatMap(u => {
const wires: ReactElement[] = []
const wire = (key: string, d: string, cls: string, onDelete: () => void) => {
wires.push(<path key={key + 'hit'} className="wire-hit" d={d} onClick={event => { event.stopPropagation(); onDelete() }} />)
wires.push(<path key={key} className={`graph-wire ${cls}`} d={d} />)
}
if (u.parentUtteranceId && byId.has(u.parentUtteranceId)) {
const parent = byId.get(u.parentUtteranceId)!
const cls = (childCount.get(parent.id) || 0) >= 2 ? 'option' : ''
wire(u.id + 'p', curve(bottomPort(parent), topPort(u)), cls, () => link(u.id, { parentUtteranceId: null }, { parentUtteranceId: parent.id }))
}
if (u.terminalId) { const idx = terminals.findIndex(t => t.id === u.terminalId); if (idx >= 0) wire(u.id + 't', curve(bottomPort(u), sinkInput(idx)), 'exit', () => link(u.id, { terminalId: null }, { terminalId: u.terminalId })) }
return wires
})}
</svg>
{terminals.map((t, i) => { const p = sinkPos(i); return <div key={t.id} className="usink" style={{ left: p.x, top: p.y, width: SINK_W }} onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); targetSink(t.id) }}>
<div className="uinput" />
<span className="usink-label"> {t.label || t.terminalKey}</span>
</div> })}
{utterances.map(u => <div key={u.id} ref={el => { if (el) cardRefs.current.set(u.id, el); else cardRefs.current.delete(u.id) }}
className={`ucard u-${u.utterer}${u.id === selectedId ? ' selected' : ''}`} style={{ left: u.xpos, top: u.ypos, width: UW }}
onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); targetCard(u) }}>
<div className="uinput" />
<div className="ucard-head" onPointerDown={e => onCardPointerDown(e, u)}>
<span className="ucard-badge">{u.utterer === 'npc' ? npcName(u.npcId) : 'PLAYER'}</span>
{u.utterer === 'npc' && u.poseKey && <span className="ucard-pose">{u.poseKey}</span>}
</div>
<div className="ucard-text">{u.text || <em>(empty)</em>}</div>
<button className={`uport flow${(childCount.get(u.id) || 0) > 0 || u.terminalId ? ' wired' : ''}${wiringFrom?.id === u.id ? ' active' : ''}`}
title="Connect to the next line(s) or an exit ⇥ · 1 = linear, 2+ = options"
onClick={e => { e.stopPropagation(); setWiringFrom(w => w?.id === u.id ? null : { id: u.id }) }} />
</div>)}
</div>
{utterances.length === 0 && <div className="graph-empty">No utterances yet add an NPC line or player choice.</div>}
</div>
{selected && <aside className="graph-inspector">
<div className="inspector-body">
<div className="inspector-head"><span className={`gnode-type type-${selected.utterer === 'npc' ? 'dialogue' : 'level'}`}>{selected.utterer}</span></div>
{selected.utterer === 'npc' && <>
<label className="ins-field"><span>Speaker</span>
<select value={selected.npcId || ''} onChange={e => patch(selected.id, { npcId: e.target.value || null, poseKey: null })}>
<option value=""> choose NPC </option>
{npcs.map(n => <option key={n.id} value={n.id}>{n.name}</option>)}
</select></label>
<label className="ins-field"><span>Pose</span>
<select value={selected.poseKey || ''} onChange={e => patch(selected.id, { poseKey: e.target.value || null })}>
<option value=""> default / none </option>
{(npcs.find(n => n.id === selected.npcId)?.poses || []).map(p => <option key={p.poseKey} value={p.poseKey}>{p.poseKey}</option>)}
</select></label>
</>}
<label className="ins-field"><span>{selected.utterer === 'npc' ? 'Line' : 'Choice text'}</span>
<textarea className="ins-text" defaultValue={selected.text} onBlur={e => e.target.value !== selected.text && patch(selected.id, { text: e.target.value })} /></label>
<div className="ins-links">
<div>Next: {(childCount.get(selected.id) || 0) > 0 ? `${childCount.get(selected.id)} ${(childCount.get(selected.id) || 0) >= 2 ? 'options' : 'line'}` : selected.terminalId ? `${terminals.find(t => t.id === selected.terminalId)?.label || 'exit'}` : '— none —'}
{selected.terminalId && <button className="ins-unwire" onClick={() => link(selected.id, { terminalId: null }, { terminalId: selected.terminalId })}></button>}</div>
{selected.parentUtteranceId && <div>Follows another utterance <button className="ins-unwire" onClick={() => link(selected.id, { parentUtteranceId: null }, { parentUtteranceId: selected.parentUtteranceId })}></button></div>}
</div>
<button className="ins-delete" onClick={async () => { if (!window.confirm('Delete utterance?')) return; try { await api(`/api/admin/utterances/${selected.id}`, 'DELETE'); setSelectedId(null); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}>Delete utterance</button>
</div>
</aside>}
</div>
</div>
}
+1 -1
View File
@@ -9,5 +9,5 @@
"strict": true, "strict": true,
"noEmit": true "noEmit": true
}, },
"include": ["vite.config.ts", "server/**/*.ts", "src/types.ts"] "include": ["vite.config.ts", "playwright.config.ts", "server/**/*.ts", "scripts/**/*.ts", "e2e/**/*.ts", "src/types.ts"]
} }