Compare commits
13
Commits
35685f765a
...
94ccbfd1b9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94ccbfd1b9 | ||
|
|
1893bf23af | ||
|
|
1b3ff1e78c | ||
|
|
80e5f548ac | ||
|
|
6d4026476e | ||
|
|
dafc70b047 | ||
|
|
0b1e5e7fc7 | ||
|
|
f10d7584d2 | ||
|
|
7fe8fadd8a | ||
|
|
3b72f38d00 | ||
|
|
aa789bbadb | ||
|
|
ddb3a386f0 | ||
|
|
0237da74cf |
@@ -4,3 +4,11 @@ CORS_ORIGIN=http://localhost:5173
|
|||||||
LEVEL_EDITING_ENABLED=true
|
LEVEL_EDITING_ENABLED=true
|
||||||
JWT_SECRET=osint-local-dev-secret
|
JWT_SECRET=osint-local-dev-secret
|
||||||
MAX_DOCUMENT_BYTES=26214400
|
MAX_DOCUMENT_BYTES=26214400
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ RUN npm run build
|
|||||||
FROM node:22-bookworm-slim
|
FROM node:22-bookworm-slim
|
||||||
ENV NODE_ENV=production PORT=8787
|
ENV NODE_ENV=production PORT=8787
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends tesseract-ocr tesseract-ocr-eng tesseract-ocr-nor \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ A standalone, server-backed proof of concept for the Glitch University investiga
|
|||||||
The accepted normalized domain model and terminology are specified in [`docs/exhibit-data-model.md`](docs/exhibit-data-model.md). PostgreSQL stores **exhibits**; frontend **widgets** visualize exhibit types.
|
The accepted normalized domain model and terminology are specified in [`docs/exhibit-data-model.md`](docs/exhibit-data-model.md). PostgreSQL stores **exhibits**; frontend **widgets** visualize exhibit types.
|
||||||
|
|
||||||
Planned schema and exhibit work is tracked in [`docs/TODO.md`](docs/TODO.md).
|
Planned schema and exhibit work is tracked in [`docs/TODO.md`](docs/TODO.md).
|
||||||
|
The intentionally narrow current demo is defined in [`docs/demo-scope.md`](docs/demo-scope.md): it opens directly on the board and focuses on investigation, flag-gated evidence reveals, and pasted screenshots.
|
||||||
|
|
||||||
## Run locally
|
## Run locally
|
||||||
|
|
||||||
@@ -74,7 +75,15 @@ The API surface is:
|
|||||||
- `PUT /api/levels/:id`
|
- `PUT /api/levels/:id`
|
||||||
- `POST /api/levels/:id/reset`
|
- `POST /api/levels/:id/reset`
|
||||||
- `POST /api/levels/:id/templates` (save a new immutable version; editor only)
|
- `POST /api/levels/:id/templates` (save a new immutable version; editor only)
|
||||||
- `POST /api/levels/:id/documents` (editor only)
|
- `POST /api/levels/:id/documents` (player-uploaded evidence)
|
||||||
|
- `POST /api/levels/:id/reveals/seen`
|
||||||
|
- `GET /api/levels/:id/flags` (admin only)
|
||||||
|
- `PUT /api/levels/:id/flags/:key` (admin only)
|
||||||
|
- `DELETE /api/levels/:id/flags/:key` (admin only)
|
||||||
|
- `GET /api/levels/:id/evidence-match-rules` (admin only)
|
||||||
|
- `POST /api/levels/:id/evidence-match-rules` (editor only)
|
||||||
|
- `PUT /api/levels/:id/evidence-match-rules/:ruleId` (editor only)
|
||||||
|
- `DELETE /api/levels/:id/evidence-match-rules/:ruleId` (editor only)
|
||||||
- `GET /api/assets/:id`
|
- `GET /api/assets/:id`
|
||||||
- `GET /api/session` (verified session and admin capability summary)
|
- `GET /api/session` (verified session and admin capability summary)
|
||||||
- `GET /api/health`
|
- `GET /api/health`
|
||||||
@@ -87,7 +96,11 @@ Set `LEVEL_EDITING_ENABLED=true` and open `/?edit=1` while signed in with a JWT
|
|||||||
|
|
||||||
For the standalone development Compose stack, visit `/api/dev/admin-session?returnTo=/?edit=1` once to receive a local signed admin cookie. This helper does not exist in production.
|
For the standalone development Compose stack, visit `/api/dev/admin-session?returnTo=/?edit=1` once to receive a local signed admin cookie. This helper does not exist in production.
|
||||||
|
|
||||||
In edit mode, files can be dragged from the desktop onto the board or selected with **Import Document**. Images, PDFs, and text files render inside document windows; unknown formats remain downloadable source files. Extracted evidence becomes an editable folder widget. Its editor controls the title, annotation, contained documents, and each source document's publication time. The default upload limit is 25 MB and can be changed with `MAX_DOCUMENT_BYTES`.
|
Files can be dragged from the desktop onto the board or selected with **Add Document**. Pasting a clipboard image creates a persisted image Document, which supports ordinary macOS and Windows screenshot workflows. Images, PDFs, and text files render inside document windows; unknown formats remain downloadable source files. Extracted evidence becomes an editable folder widget. Its editor controls the title, annotation, contained documents, and each source document's publication time. The default upload limit is 25 MB and can be changed with `MAX_DOCUMENT_BYTES`.
|
||||||
|
|
||||||
|
Image uploads are OCRed by the Tesseract executable bundled into the application image; text-file uploads use their text directly. Extracted text is stored against the immutable asset, copied into the Document's searchable body, and evaluated against level-authored fuzzy passage rules. A successful rule awards its configured flag and immediately participates in ordinary document reveals. Rules, anchors, per-anchor scores, and evaluation provenance are normalized PostgreSQL data—no case text is compiled into the engine. OCR is time-limited and failure-tolerant: the source remains on the board even when text extraction fails. `OCR_LANGUAGES`, `OCR_TIMEOUT_MS`, `MAX_OCR_BYTES`, and `MAX_EXTRACTED_TEXT_CHARACTERS` tune the worker; `OCR_ENABLED=false` disables image OCR without disabling uploads. Authors configure passages under **Admin → Evidence Matching** while editing a level.
|
||||||
|
|
||||||
|
In author mode, a Document may be assigned comma-separated reveal flags in its metadata editor. Play-mode level responses omit gated Documents until all requirements are earned. Admins can exercise the demo through **Admin → Level Flags**; newly delivered evidence receives a one-time arrival animation.
|
||||||
|
|
||||||
Production defaults editing to disabled. Set `LEVEL_EDITING_ENABLED=true` in `/opt/gu_common/.env.prod` only when the authoring surface should be available. `JWT_SECRET` is inherited from that shared environment, and authoring endpoints additionally require a verified admin claim.
|
Production defaults editing to disabled. Set `LEVEL_EDITING_ENABLED=true` in `/opt/gu_common/.env.prod` only when the authoring surface should be available. `JWT_SECRET` is inherited from that shared environment, and authoring endpoints additionally require a verified admin claim.
|
||||||
|
|
||||||
@@ -126,4 +139,4 @@ The deploy script builds and syncs the application, reads production database cr
|
|||||||
|
|
||||||
## Deliberate POC boundaries
|
## Deliberate POC boundaries
|
||||||
|
|
||||||
Authentication is supplied by the shared Glitch University account system. There is no OSINT-specific account model, real-world web browsing, OCR, or collaboration yet. The server data model and provenance fields leave room for those later without making them part of the first playability test.
|
Authentication is supplied by the shared Glitch University account system. There is no OSINT-specific account model, real-world web browsing, or collaboration yet. OCR deliberately recognizes only evidence the player brings onto the board; it does not fetch or search the web.
|
||||||
|
|||||||
+46
-1
@@ -30,12 +30,57 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET:-osint-local-dev-secret}
|
JWT_SECRET: ${JWT_SECRET:-osint-local-dev-secret}
|
||||||
LEVEL_EDITING_ENABLED: "true"
|
LEVEL_EDITING_ENABLED: "true"
|
||||||
MAX_DOCUMENT_BYTES: 26214400
|
MAX_DOCUMENT_BYTES: 26214400
|
||||||
|
OCR_ENABLED: "true"
|
||||||
|
OCR_LANGUAGES: nor+eng
|
||||||
|
OCR_TIMEOUT_MS: 20000
|
||||||
|
MAX_OCR_BYTES: 15728640
|
||||||
|
MAX_EXTRACTED_TEXT_CHARACTERS: 200000
|
||||||
|
S3_ENDPOINT: http://minio:9000
|
||||||
|
S3_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:
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
LEVEL_EDITING_ENABLED: ${LEVEL_EDITING_ENABLED:-false}
|
LEVEL_EDITING_ENABLED: ${LEVEL_EDITING_ENABLED:-false}
|
||||||
MAX_DOCUMENT_BYTES: ${MAX_DOCUMENT_BYTES:-26214400}
|
MAX_DOCUMENT_BYTES: ${MAX_DOCUMENT_BYTES:-26214400}
|
||||||
|
OCR_ENABLED: ${OCR_ENABLED:-true}
|
||||||
|
OCR_LANGUAGES: ${OCR_LANGUAGES:-nor+eng}
|
||||||
|
OCR_TIMEOUT_MS: ${OCR_TIMEOUT_MS:-20000}
|
||||||
|
MAX_OCR_BYTES: ${MAX_OCR_BYTES:-15728640}
|
||||||
|
MAX_EXTRACTED_TEXT_CHARACTERS: ${MAX_EXTRACTED_TEXT_CHARACTERS:-200000}
|
||||||
expose:
|
expose:
|
||||||
- "8787"
|
- "8787"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -60,3 +62,82 @@ This is the ordered implementation roadmap following the accepted exhibit model.
|
|||||||
- [x] 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.
|
||||||
- [x] 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.
|
||||||
- [x] 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.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Board demo scope
|
||||||
|
|
||||||
|
The active demo starts directly on a mutable OSINT board. Campaign navigation,
|
||||||
|
cutscenes, dialogue, NPC presentation, and story-graph progression are not part of
|
||||||
|
this slice. Their existing code and migrations remain isolated for possible later
|
||||||
|
work, but the board does not invoke them.
|
||||||
|
|
||||||
|
## Included gameplay
|
||||||
|
|
||||||
|
- A level loads its currently available normalized exhibits, board arrangement,
|
||||||
|
folders, threads, parties, events, and timeline.
|
||||||
|
- A document may require one or more boolean level flags. Play-mode responses omit
|
||||||
|
an unavailable document and every relation or thread touching it. Author mode
|
||||||
|
exposes the requirements in the document metadata editor.
|
||||||
|
- The first time an available document is delivered to a level, it receives a
|
||||||
|
short **NEW EVIDENCE** arrival animation. The acknowledgement is persisted so a
|
||||||
|
normal reload does not replay it.
|
||||||
|
- Administrators can inspect, award, and revoke demo flags from **Admin → Level
|
||||||
|
Flags**. This is a test/authoring control; future gameplay systems may award the
|
||||||
|
same flags without changing the document-gating model.
|
||||||
|
- Any player may add evidence by choosing a file, dragging a file onto the board,
|
||||||
|
or pasting an image from the clipboard. A pasted macOS or Windows screenshot is
|
||||||
|
uploaded to object storage and created as a normalized image Document near the
|
||||||
|
center of the current viewport.
|
||||||
|
- Uploaded images are OCRed and uploaded text files are read directly. Extracted
|
||||||
|
text becomes searchable Document content and is compared with board-owned fuzzy
|
||||||
|
passage rules. Matching enough distinctive anchors awards the rule's flag; an
|
||||||
|
OCR error never rejects or removes the player's source.
|
||||||
|
|
||||||
|
## Gate semantics
|
||||||
|
|
||||||
|
Flags are lowercase keys such as `lead.auction_catalogue`. A document with no
|
||||||
|
requirements is visible at level load. A document with several requirements is
|
||||||
|
visible only after **all** of them have been earned. Flags belong to the mutable
|
||||||
|
level; requirements belong to the board and therefore clone with template
|
||||||
|
versions. The server is the visibility authority.
|
||||||
|
|
||||||
|
Evidence recognition rules also belong to the board and clone with template
|
||||||
|
versions. Each rule names a flag, contains one or more reference passages, gives
|
||||||
|
each passage a similarity threshold, and states how many passages must match.
|
||||||
|
Evaluation rows and their per-anchor scores belong to the mutable level, providing
|
||||||
|
an audit trail for automatic victories and reveals.
|
||||||
|
|
||||||
|
The bundled Glass Harbor manifest gates the auction catalogue behind
|
||||||
|
`lead.auction_catalogue`, providing one small reveal for the demo.
|
||||||
@@ -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, 3–5 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.
|
||||||
@@ -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 1–2).
|
||||||
|
- [ ] 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 2–3, 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.
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Persistent boards, gated exhibits, and citation codes
|
||||||
|
|
||||||
|
Status: **demo subset implemented**. Migration 025 implements level-local boolean
|
||||||
|
flags, normalized Document requirements, server-side filtering, persisted reveal
|
||||||
|
acknowledgements, and the arrival animation. Board-key reuse, narrative-triggered
|
||||||
|
live reveals, citation groups, and per-user playthrough overlays remain deferred.
|
||||||
|
The broader design extends the story flow graph
|
||||||
|
([story-graph.md](story-graph.md)) and the narrative layer
|
||||||
|
([narrative-todo.md](narrative-todo.md)); interlocks with the Claim/Case Report
|
||||||
|
work in [TODO.md](TODO.md) Milestone 5. Depends on the **flags / case-state**
|
||||||
|
primitive (a per-playthrough key→value store) that the gates and dialogue effects
|
||||||
|
also read and write.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
A mystery is broken into short pieces so we can **meter how much evidence the
|
||||||
|
player carries at once**. Interruptions — a phone call, a knock, a revealed note —
|
||||||
|
should not throw the player off the board they are working on. So a single
|
||||||
|
investigation scene is a **persistent, player-mutated board** that several graph
|
||||||
|
nodes return to, and new evidence **arrives into that same board** when the story
|
||||||
|
warrants it rather than being dumped up front or split across duplicated templates.
|
||||||
|
|
||||||
|
Two capabilities, independent and both wanted:
|
||||||
|
|
||||||
|
1. **A board that survives across nodes** — `[board] → [phone call] → [same board]`
|
||||||
|
returns the player to their exact arrangement and connections.
|
||||||
|
2. **Flag-gated exhibits inside that board**, revealed with a diegetic "it arrived"
|
||||||
|
moment (reuses the existing document locator beam).
|
||||||
|
|
||||||
|
## Board identity and reuse
|
||||||
|
|
||||||
|
An authored level node **cannot** reference a clone id — the clone is created per
|
||||||
|
playthrough at runtime. Instead:
|
||||||
|
|
||||||
|
- A **level node** references a `level_template_version_id` **plus a `board_key`**
|
||||||
|
(a logical board slot, e.g. `"harbor-desk"`). `board_key` defaults to the
|
||||||
|
template if the author doesn't care about sharing.
|
||||||
|
- A **playthrough** holds a map `board_key → level_id` (the mutable clone).
|
||||||
|
- Entering a level node:
|
||||||
|
- if the playthrough already has a clone for that `board_key` → **reuse it**
|
||||||
|
(the player's mutated board, arrangement and connections intact);
|
||||||
|
- otherwise → **clone the template** and record `board_key → new level_id`.
|
||||||
|
|
||||||
|
`board_key` is the thing that makes two nodes share a board. Two nodes on the same
|
||||||
|
template with **different** keys get **separate** boards.
|
||||||
|
|
||||||
|
## Gated exhibits
|
||||||
|
|
||||||
|
- Every exhibit is **cloned in**, including gated ones; a gated exhibit carries a
|
||||||
|
**`reveal` condition over flags**. The player's saved board already contains the
|
||||||
|
note — it is simply inert until the flag flips.
|
||||||
|
- **Visibility is computed server-side at load.** Given the playthrough's flags,
|
||||||
|
the play-mode payload includes only exhibits whose `reveal` condition is met;
|
||||||
|
hidden exhibits are stripped from the response (same discipline that already
|
||||||
|
hides authoring-only fields like `expectedPartyKind`). Players cannot peek
|
||||||
|
unrevealed evidence in the API.
|
||||||
|
- **Gate the edges too.** Any `exhibit_connection` or event link whose endpoint is
|
||||||
|
a not-yet-revealed exhibit is hidden until that exhibit appears, so there are no
|
||||||
|
dangling references.
|
||||||
|
|
||||||
|
## The arrival reveal
|
||||||
|
|
||||||
|
- The board's effective content can change **while the player is standing on it** —
|
||||||
|
the phone is an always-available surface, so a call can set a flag mid-scene. So
|
||||||
|
reveal is **not load-only**: after any phone/dialogue interaction that sets flags,
|
||||||
|
re-evaluate board visibility and reveal live (the phone overlay refetches
|
||||||
|
visibility on hang-up).
|
||||||
|
- Track a per-playthrough **`revealed-seen` set**. On (re)load or after an
|
||||||
|
interaction, any exhibit now visible but not yet in the set → play the arrival
|
||||||
|
animation (a sibling/reuse of the **document locator beam**), then add it to the
|
||||||
|
set. The flourish fires **once**, not on every reload.
|
||||||
|
|
||||||
|
## Reset and New Game
|
||||||
|
|
||||||
|
- **Reset = tidy the desk back to the delivered state.** Authored exhibits return
|
||||||
|
to their arrival positions and folders; the player's rearrangement is undone.
|
||||||
|
**Flags are untouched**, so anything a flag has already revealed is still
|
||||||
|
"arrived" and is restored at its authored position.
|
||||||
|
- **New Game** re-clones the whole tree and is therefore the only thing that
|
||||||
|
**resets flags** and returns gated exhibits to hidden.
|
||||||
|
|
||||||
|
**Open decision — player notes on reset.** Milestone 5's rule is "reset discards
|
||||||
|
player-created claims." Current lean: reset also removes player-created exhibits
|
||||||
|
(it restores the *delivered* state), and New Game is the only wipe of flags. The
|
||||||
|
alternative is a gentler reset that re-files arrived exhibits but leaves the
|
||||||
|
player's own notes in place. Settle before implementing.
|
||||||
|
|
||||||
|
## Exhibit citation codes (A / B / M)
|
||||||
|
|
||||||
|
A single running number breaks when players create many note exhibits, so codes
|
||||||
|
live in fixed namespaces:
|
||||||
|
|
||||||
|
- **Authored exhibits: group letter + index** — `A1, A2, …` for the initial
|
||||||
|
dossier, `B1, B2, …` for a batch that arrives later via a flag reveal, etc.
|
||||||
|
**The letter is the arrival group**, which ties straight into gated reveal: "new
|
||||||
|
evidence arrived" *is* the B-series lighting up. Indices are assigned **within
|
||||||
|
each group at freeze time** and frozen in the template, so authored codes never
|
||||||
|
shift, no matter what the player does.
|
||||||
|
- **Player-created exhibits: a running `M` (miscellaneous) series** — `M1, M2,
|
||||||
|
M3…` in creation order, in their own namespace so player note-making cannot
|
||||||
|
disturb authored codes. M-numbering is per-level-instance and (given the reset
|
||||||
|
lean above) restarts only when player notes are cleared.
|
||||||
|
- The author chooses an exhibit's **group**; the system owns the **numbering**.
|
||||||
|
|
||||||
|
These codes are the **citation token everywhere** — board, luggage-tag Claims, the
|
||||||
|
Case Report, and the LLM gate.
|
||||||
|
|
||||||
|
## LLM gate contract
|
||||||
|
|
||||||
|
- A gate can be **scripted to require specific authored codes** — e.g. "the report
|
||||||
|
must cite `A1` and `B2`, connected." That authored requirement list is the
|
||||||
|
deterministic backbone *under* the LLM, per the section-9 intent of scripting the
|
||||||
|
cognitive shim rather than trusting it blind.
|
||||||
|
- If the player types the full report freehand, they must include the Exhibit codes;
|
||||||
|
the gate checks the required codes are present (and, later, that the reasoning
|
||||||
|
holds).
|
||||||
|
|
||||||
|
## Relationship to fresh templates
|
||||||
|
|
||||||
|
This **complements**, not replaces, new templates:
|
||||||
|
|
||||||
|
- **Shared `board_key`** — interruptions *within* one investigation scene (calls, a
|
||||||
|
knock, a revealed note). Cleanly subsumes the earlier "note appears on the next
|
||||||
|
level" idea: the note is a gated exhibit on the *same* board that reveals after
|
||||||
|
the call.
|
||||||
|
- **A fresh template** — the player genuinely moves to a new location/chapter with a
|
||||||
|
different board.
|
||||||
|
|
||||||
|
## Implemented vs deferred
|
||||||
|
|
||||||
|
Nothing here is built yet. New plumbing, smallest-first:
|
||||||
|
|
||||||
|
1. **Flags** — the per-playthrough key→value store (shared with gates/dialogue).
|
||||||
|
2. **`board_key` reuse** in level traversal + the playthrough `board_key → level_id`
|
||||||
|
map (clone-or-reuse).
|
||||||
|
3. **`reveal` conditions on exhibits** + server-side play-mode visibility filtering
|
||||||
|
(exhibits and their edges).
|
||||||
|
4. **`revealed-seen` set** + the arrival animation (locator-beam sibling), live
|
||||||
|
after flag changes.
|
||||||
|
5. **A / B / M citation codes** — authored group + frozen index, player M-series;
|
||||||
|
surfaced on the board and threaded into Claims/report/LLM gate.
|
||||||
|
|
||||||
|
Reset (desk-restore, flags-persist) and New Game (full re-clone) semantics as above.
|
||||||
@@ -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).
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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.';
|
||||||
@@ -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.';
|
||||||
@@ -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;
|
||||||
@@ -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.';
|
||||||
@@ -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).';
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Optional scene music per story node. The runtime plays/loops it while the node
|
||||||
|
-- is active; a NULL value inherits whatever is already playing (so a track set on
|
||||||
|
-- one node carries through the region until another node changes it).
|
||||||
|
ALTER TABLE osint.story_nodes ADD COLUMN music_asset_id UUID REFERENCES osint.assets(id);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Per-node scene-music volume (0-100%). Applied whenever a node sets a track;
|
||||||
|
-- selecting the same track on a later node with a different volume just adjusts
|
||||||
|
-- the level without restarting the music.
|
||||||
|
ALTER TABLE osint.story_nodes ADD COLUMN music_volume SMALLINT NOT NULL DEFAULT 100 CHECK (music_volume BETWEEN 0 AND 100);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- Level-local achievements and normalized document reveal requirements.
|
||||||
|
-- Requirements live on boards so they clone with immutable template versions;
|
||||||
|
-- earned/seen state lives on the mutable level instance.
|
||||||
|
|
||||||
|
ALTER TABLE osint.levels
|
||||||
|
ADD CONSTRAINT levels_id_board_unique UNIQUE (id,board_id);
|
||||||
|
|
||||||
|
CREATE TABLE osint.level_flags (
|
||||||
|
level_id UUID NOT NULL,
|
||||||
|
board_id UUID NOT NULL,
|
||||||
|
flag_key TEXT NOT NULL CHECK (flag_key ~ '^[a-z][a-z0-9_.-]{0,63}$'),
|
||||||
|
earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (level_id,flag_key),
|
||||||
|
FOREIGN KEY (level_id,board_id) REFERENCES osint.levels(id,board_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX level_flags_board_idx ON osint.level_flags (board_id,flag_key);
|
||||||
|
|
||||||
|
CREATE TABLE osint.document_flag_requirements (
|
||||||
|
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
|
||||||
|
document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
|
||||||
|
flag_key TEXT NOT NULL CHECK (flag_key ~ '^[a-z][a-z0-9_.-]{0,63}$'),
|
||||||
|
PRIMARY KEY (document_exhibit_id,flag_key),
|
||||||
|
FOREIGN KEY (board_id,document_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX document_flag_requirements_board_idx ON osint.document_flag_requirements (board_id,flag_key);
|
||||||
|
|
||||||
|
CREATE TABLE osint.level_seen_documents (
|
||||||
|
level_id UUID NOT NULL,
|
||||||
|
board_id UUID NOT NULL,
|
||||||
|
document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
|
||||||
|
seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (level_id,document_exhibit_id),
|
||||||
|
FOREIGN KEY (level_id,board_id) REFERENCES osint.levels(id,board_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (board_id,document_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE osint.level_flags IS 'Boolean achievements earned within one mutable level instance.';
|
||||||
|
COMMENT ON TABLE osint.document_flag_requirements IS 'All listed flags must be earned before a document is included in the play-mode level payload.';
|
||||||
|
COMMENT ON TABLE osint.level_seen_documents IS 'Documents whose first-arrival animation has already been acknowledged for this level.';
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Achievements: the playthrough case-state. Boolean facts earned by a player, each
|
||||||
|
-- remembering the story node that awarded it. This is the NARRATIVE scope the phone
|
||||||
|
-- gate, node-enable requirements, and gates read; level-local flags (025) promote up
|
||||||
|
-- into it when a level node's achievement fires. A fact is true once (PK on player +
|
||||||
|
-- key); the awarding node is provenance and is nullable (New Game / manual grants).
|
||||||
|
|
||||||
|
CREATE TABLE osint.achievements (
|
||||||
|
playthrough_id UUID NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
|
||||||
|
flag_key TEXT NOT NULL CHECK (flag_key ~ '^[a-z][a-z0-9_.-]{0,63}$'),
|
||||||
|
awarded_by_node_id UUID REFERENCES osint.story_nodes(id) ON DELETE SET NULL,
|
||||||
|
earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (playthrough_id, flag_key)
|
||||||
|
);
|
||||||
|
CREATE INDEX achievements_playthrough_idx ON osint.achievements (playthrough_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE osint.achievements IS 'Boolean achievements earned by a player (playthrough), each remembering the awarding story node. The narrative case-state that gates the phone, node-enable requirements, and story gates.';
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
-- Data-defined evidence recognition for player-uploaded sources. Text extraction
|
||||||
|
-- belongs to the immutable asset; matching rules belong to a clonable board;
|
||||||
|
-- evaluations and awarded flags belong to the mutable level instance.
|
||||||
|
|
||||||
|
CREATE TABLE osint.asset_text_extractions (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
asset_id UUID NOT NULL REFERENCES osint.assets(id) ON DELETE CASCADE,
|
||||||
|
extractor TEXT NOT NULL,
|
||||||
|
extractor_version TEXT NOT NULL,
|
||||||
|
language TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('succeeded','unsupported','failed')),
|
||||||
|
extracted_text TEXT NOT NULL DEFAULT '',
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (asset_id,extractor,extractor_version,language)
|
||||||
|
);
|
||||||
|
CREATE INDEX asset_text_extractions_asset_idx ON osint.asset_text_extractions (asset_id,created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE osint.evidence_match_rules (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
|
||||||
|
origin_rule_id UUID REFERENCES osint.evidence_match_rules(id) ON DELETE SET NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
flag_key TEXT NOT NULL CHECK (flag_key ~ '^[a-z][a-z0-9_.-]{0,63}$'),
|
||||||
|
matcher_version TEXT NOT NULL DEFAULT 'char_trigram_v1' CHECK (matcher_version = 'char_trigram_v1'),
|
||||||
|
minimum_anchor_matches SMALLINT NOT NULL DEFAULT 1 CHECK (minimum_anchor_matches > 0),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (board_id,id),
|
||||||
|
UNIQUE (board_id,name)
|
||||||
|
);
|
||||||
|
CREATE INDEX evidence_match_rules_board_flag_idx ON osint.evidence_match_rules (board_id,flag_key) WHERE enabled;
|
||||||
|
|
||||||
|
CREATE TABLE osint.evidence_match_anchors (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
rule_id UUID NOT NULL REFERENCES osint.evidence_match_rules(id) ON DELETE CASCADE,
|
||||||
|
phrase_text TEXT NOT NULL CHECK (char_length(btrim(phrase_text)) >= 12),
|
||||||
|
minimum_similarity NUMERIC(4,3) NOT NULL DEFAULT 0.720 CHECK (minimum_similarity >= 0.500 AND minimum_similarity <= 1),
|
||||||
|
sort_order SMALLINT NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||||
|
UNIQUE (rule_id,sort_order)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE osint.evidence_match_evaluations (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
level_id UUID NOT NULL,
|
||||||
|
board_id UUID NOT NULL,
|
||||||
|
document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
|
||||||
|
extraction_id UUID NOT NULL REFERENCES osint.asset_text_extractions(id) ON DELETE CASCADE,
|
||||||
|
rule_id UUID NOT NULL,
|
||||||
|
matched BOOLEAN NOT NULL,
|
||||||
|
matched_anchor_count SMALLINT NOT NULL CHECK (matched_anchor_count >= 0),
|
||||||
|
score NUMERIC(4,3) NOT NULL CHECK (score >= 0 AND score <= 1),
|
||||||
|
evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
FOREIGN KEY (level_id,board_id) REFERENCES osint.levels(id,board_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (board_id,document_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (board_id,rule_id) REFERENCES osint.evidence_match_rules(board_id,id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (level_id,document_exhibit_id,rule_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX evidence_match_evaluations_level_idx ON osint.evidence_match_evaluations (level_id,evaluated_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE osint.evidence_match_anchor_evaluations (
|
||||||
|
evaluation_id UUID NOT NULL REFERENCES osint.evidence_match_evaluations(id) ON DELETE CASCADE,
|
||||||
|
anchor_id UUID NOT NULL REFERENCES osint.evidence_match_anchors(id) ON DELETE CASCADE,
|
||||||
|
similarity NUMERIC(4,3) NOT NULL CHECK (similarity >= 0 AND similarity <= 1),
|
||||||
|
matched BOOLEAN NOT NULL,
|
||||||
|
matched_text TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (evaluation_id,anchor_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE osint.level_flags
|
||||||
|
ADD COLUMN awarded_by_evidence_match_id UUID REFERENCES osint.evidence_match_evaluations(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
COMMENT ON TABLE osint.asset_text_extractions IS 'Cached OCR or direct-text extraction for one immutable uploaded asset.';
|
||||||
|
COMMENT ON TABLE osint.evidence_match_rules IS 'Clonable level-authored rule that awards a flag when enough distinctive text anchors match an uploaded source.';
|
||||||
|
COMMENT ON TABLE osint.evidence_match_anchors IS 'Alternative or cumulative reference passages used by one evidence match rule.';
|
||||||
|
COMMENT ON TABLE osint.evidence_match_evaluations IS 'Auditable result of applying one board rule to one player-uploaded document.';
|
||||||
|
COMMENT ON TABLE osint.evidence_match_anchor_evaluations IS 'Per-anchor fuzzy score and best normalized text window for an evidence evaluation.';
|
||||||
@@ -112,6 +112,7 @@
|
|||||||
"key": "auction-catalogue",
|
"key": "auction-catalogue",
|
||||||
"title": "Meridian Maritime Auction · Lot 117",
|
"title": "Meridian Maritime Auction · Lot 117",
|
||||||
"fileType": "article",
|
"fileType": "article",
|
||||||
|
"requiredFlags": ["lead.auction_catalogue"],
|
||||||
"publishedAt": "1987-10-24T12:00:00.000Z",
|
"publishedAt": "1987-10-24T12:00:00.000Z",
|
||||||
"body": [
|
"body": [
|
||||||
"MERIDIAN MARITIME AUCTION — ADVANCE CATALOGUE · 24 OCTOBER 1987",
|
"MERIDIAN MARITIME AUCTION — ADVANCE CATALOGUE · 24 OCTOBER 1987",
|
||||||
@@ -123,6 +124,44 @@
|
|||||||
"metadata": { "lot": "117", "seller_reference": "MV-3/771", "estimate": "45,000–52,000 NOK" }
|
"metadata": { "lot": "117", "seller_reference": "MV-3/771", "estimate": "45,000–52,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": [
|
"folders": [
|
||||||
{
|
{
|
||||||
"key": "procurement-file",
|
"key": "procurement-file",
|
||||||
|
|||||||
Generated
+461
-1
@@ -8,6 +8,7 @@
|
|||||||
"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",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"dotenv": "16.5.0",
|
"dotenv": "16.5.0",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"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",
|
||||||
|
"three": "^0.185.1",
|
||||||
"tsx": "4.20.3"
|
"tsx": "4.20.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
"@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",
|
||||||
|
"@types/three": "^0.185.4",
|
||||||
"@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",
|
||||||
@@ -41,6 +44,314 @@
|
|||||||
"node": "^20.0.0 || >=22.0.0"
|
"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": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -323,6 +634,13 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dimforge/rapier3d-compat": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@@ -1179,6 +1497,94 @@
|
|||||||
"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/@tweenjs/tween.js": {
|
||||||
|
"version": "23.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
|
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"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",
|
||||||
@@ -1427,6 +1833,35 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/stats.js": {
|
||||||
|
"version": "0.17.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||||
|
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/three": {
|
||||||
|
"version": "0.185.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.4.tgz",
|
||||||
|
"integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
"@types/stats.js": "*",
|
||||||
|
"@types/webxr": ">=0.5.17",
|
||||||
|
"fflate": "~0.8.2",
|
||||||
|
"meshoptimizer": "~1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/webxr": {
|
||||||
|
"version": "0.5.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
|
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.5.2",
|
"version": "4.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.2.tgz",
|
||||||
@@ -1668,6 +2103,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",
|
||||||
@@ -2296,6 +2737,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/finalhandler": {
|
"node_modules/finalhandler": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||||
@@ -2747,6 +3195,13 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/meshoptimizer": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/mime-db": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.54.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
@@ -3681,6 +4136,12 @@
|
|||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.185.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
|
||||||
|
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -3765,7 +4226,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": {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"test:e2e": "npm run build && playwright test"
|
"test:e2e": "npm run build && playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1111.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"dotenv": "16.5.0",
|
"dotenv": "16.5.0",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"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",
|
||||||
|
"three": "^0.185.1",
|
||||||
"tsx": "4.20.3"
|
"tsx": "4.20.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -40,6 +42,7 @@
|
|||||||
"@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",
|
||||||
|
"@types/three": "^0.185.4",
|
||||||
"@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",
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ type MysteryDocument = {
|
|||||||
body?: string[]
|
body?: string[]
|
||||||
metadata?: Record<string, string>
|
metadata?: Record<string, string>
|
||||||
asset?: string
|
asset?: string
|
||||||
|
requiredFlags?: 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 = {
|
type MysteryManifest = {
|
||||||
slug: string
|
slug: string
|
||||||
@@ -22,6 +34,7 @@ type MysteryManifest = {
|
|||||||
brief: { body: string; concepts: { label: string; context: string; expectedPartyKind: PartyKind }[] }
|
brief: { body: string; concepts: { label: string; context: string; expectedPartyKind: PartyKind }[] }
|
||||||
documents: MysteryDocument[]
|
documents: MysteryDocument[]
|
||||||
folders: { key: string; title: string; content: string; x: number; y: number; width: number; members: string[] }[]
|
folders: { key: string; title: string; content: string; x: number; y: number; width: number; members: string[] }[]
|
||||||
|
narrative?: MysteryNarrative
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireOk(response: Response, action: string) {
|
function requireOk(response: Response, action: string) {
|
||||||
@@ -29,15 +42,18 @@ function requireOk(response: Response, action: string) {
|
|||||||
return response.text().then(body => { throw new Error(`${action} failed (${response.status}): ${body}`) })
|
return response.text().then(body => { throw new Error(`${action} failed (${response.status}): ${body}`) })
|
||||||
}
|
}
|
||||||
|
|
||||||
function documentKind(type: SourceFileType) {
|
const MIME_BY_EXT: Record<string, string> = {
|
||||||
return type === 'web_capture' ? 'WEB CAPTURE' : type.toUpperCase()
|
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
||||||
|
pdf: 'application/pdf', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4', txt: 'text/plain',
|
||||||
}
|
}
|
||||||
|
function mimeFor(filename: string) { return MIME_BY_EXT[filename.split('.').pop()?.toLowerCase() || ''] || 'application/octet-stream' }
|
||||||
|
|
||||||
async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string, document: MysteryDocument, authorization?: string) {
|
async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string, document: MysteryDocument, authorization?: string) {
|
||||||
if (!document.asset) return undefined
|
if (!document.asset) return undefined
|
||||||
const assetPath = path.resolve(manifestDir, document.asset)
|
const assetPath = path.resolve(manifestDir, document.asset)
|
||||||
|
const filename = path.basename(assetPath)
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', new Blob([await readFile(assetPath)]), path.basename(assetPath))
|
form.append('file', new Blob([await readFile(assetPath)], { type: mimeFor(filename) }), filename)
|
||||||
const response = await requireOk(await fetch(`${baseUrl}/api/levels/${levelId}/documents?edit=1`, { method: 'POST', headers: authorization ? { authorization } : undefined, body: form }), `Upload ${document.asset}`)
|
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
|
return await response.json() as CaseDocument
|
||||||
}
|
}
|
||||||
@@ -55,34 +71,35 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
|
|||||||
}), 'Create authoring level')
|
}), 'Create authoring level')
|
||||||
const state = await createdResponse.json() as CaseState
|
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>()
|
const documents = new Map<string, CaseDocument>()
|
||||||
for (const source of manifest.documents) {
|
for (const source of manifest.documents) {
|
||||||
const uploaded = await uploadAsset(baseUrl, state.id, manifestDir, source, authorization)
|
const uploaded = await uploadAsset(baseUrl, state.id, manifestDir, source, authorization)
|
||||||
documents.set(source.key, {
|
documents.set(source.key, {
|
||||||
id: uploaded?.id || randomUUID(), title: source.title, kind: documentKind(source.fileType),
|
id: uploaded?.id || randomUUID(), type: 'document', title: source.title, publishedAt: source.publishedAt,
|
||||||
date: source.publishedAt.slice(0, 10), 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,
|
body: source.body || [], regions: [], assetId: uploaded?.assetId,
|
||||||
fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize,
|
fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize,
|
||||||
fileType: source.fileType, metadata: source.metadata || {},
|
fileType: source.fileType, metadata: source.metadata || {}, requiredFlags: source.requiredFlags || [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderIds = new Map(manifest.folders.map(folder => [folder.key, randomUUID()]))
|
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.brief = { body: manifest.brief.body, concepts: manifest.brief.concepts.map(concept => ({ id: randomUUID(), ...concept })) }
|
||||||
state.timelineRange = manifest.timelineRange
|
state.views = state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: manifest.timelineRange ? 'fixed' : 'auto', range: manifest.timelineRange } : view)
|
||||||
state.documents = [...documents.values()]
|
const folders = manifest.folders.map(folder => ({
|
||||||
state.evidence = manifest.folders.map(folder => ({
|
|
||||||
id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content,
|
id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content,
|
||||||
x: folder.x, y: folder.y, width: folder.width, config: { open: false },
|
x: folder.x, y: folder.y, width: folder.width, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false,
|
||||||
containedDocumentIds: folder.members.map(key => documents.get(key)!.id),
|
} as const))
|
||||||
}))
|
state.exhibits = [...documents.values(), ...folders]
|
||||||
state.relations = manifest.folders.flatMap((folder, folderIndex) => folder.members.map((key, memberIndex) => {
|
state.relations = manifest.folders.flatMap(folder => folder.members.map((key, memberIndex) => {
|
||||||
const document = documents.get(key)
|
const document = documents.get(key)
|
||||||
if (!document) throw new Error(`Folder ${folder.key} refers to unknown document ${key}`)
|
if (!document) throw new Error(`Folder ${folder.key} refers to unknown document ${key}`)
|
||||||
return {
|
return {
|
||||||
id: `contains:${folderIds.get(folder.key)}:${document.id}`,
|
id: `contains:${folderIds.get(folder.key)}:${document.id}`,
|
||||||
fromWidgetId: folderIds.get(folder.key)!, toWidgetId: document.id, type: 'contains', sortOrder: memberIndex,
|
fromExhibitId: folderIds.get(folder.key)!, toExhibitId: document.id, type: 'contains' as const, sortOrder: memberIndex,
|
||||||
config: { x: folder.x + 70 + memberIndex * 205, y: folder.y + 230 + folderIndex * 35 },
|
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
state.connections = []
|
state.connections = []
|
||||||
@@ -95,12 +112,32 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
|
|||||||
method: 'POST', headers, body: JSON.stringify({ slug: manifest.slug, name: manifest.name }),
|
method: 'POST', headers, body: JSON.stringify({ slug: manifest.slug, name: manifest.name }),
|
||||||
}), 'Freeze mystery template')
|
}), 'Freeze mystery template')
|
||||||
const template = await templateResponse.json() as { slug: string; currentVersion: number }
|
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 playableId = `${manifest.slug}-case-${Date.now()}`
|
||||||
const playableResponse = await requireOk(await fetch(`${baseUrl}/api/templates/${manifest.slug}/levels?edit=1`, {
|
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 }),
|
method: 'POST', headers, body: JSON.stringify({ id: playableId, title: manifest.title }),
|
||||||
}), 'Instantiate playable mystery')
|
}), 'Instantiate playable mystery')
|
||||||
const playable = await playableResponse.json() as CaseState
|
const playable = await playableResponse.json() as CaseState
|
||||||
return { manifest, template, authoringLevelId: state.id, playableLevel: playable }
|
return { manifest, template, mystery, authoringLevelId: state.id, playableLevel: playable }
|
||||||
}
|
}
|
||||||
|
|
||||||
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ''
|
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ''
|
||||||
@@ -110,8 +147,9 @@ if (invokedPath === fileURLToPath(import.meta.url)) {
|
|||||||
const result = await importMysteryTemplate(manifestPath, process.env.OSINT_BOARD_URL)
|
const result = await importMysteryTemplate(manifestPath, process.env.OSINT_BOARD_URL)
|
||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
template: `${result.template.slug}@v${result.template.currentVersion}`,
|
template: `${result.template.slug}@v${result.template.currentVersion}`,
|
||||||
|
mystery: result.mystery ? result.mystery.slug : undefined,
|
||||||
authoringLevelId: result.authoringLevelId,
|
authoringLevelId: result.authoringLevelId,
|
||||||
playableLevelId: result.playableLevel.id,
|
playableLevelId: result.playableLevel.id,
|
||||||
playUrl: `${process.env.OSINT_BOARD_URL || 'http://localhost:8787'}/?level=${encodeURIComponent(result.playableLevel.id)}`,
|
playUrl: `${process.env.OSINT_BOARD_URL || 'http://localhost:8787'}/`,
|
||||||
}, null, 2))
|
}, null, 2))
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-197
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import pg from 'pg'
|
import pg from 'pg'
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
import type { CaseState } from '../src/types.js'
|
import type { CaseState, DocumentExhibit, EventExhibit, FolderExhibit, NoteExhibit, PartyExhibit, TimelineView } from '../src/types.js'
|
||||||
import { runMigrations } from './migrations.js'
|
import { runMigrations } from './migrations.js'
|
||||||
|
|
||||||
const { Client } = pg
|
const { Client } = pg
|
||||||
@@ -36,7 +36,9 @@ async function availablePort() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
suite('level persistence API', () => {
|
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 () => {
|
beforeAll(async () => {
|
||||||
const adminUrl = new URL(baseDatabaseUrl!)
|
const adminUrl = new URL(baseDatabaseUrl!)
|
||||||
adminUrl.pathname = '/postgres'
|
adminUrl.pathname = '/postgres'
|
||||||
@@ -46,13 +48,13 @@ suite('level persistence API', () => {
|
|||||||
const testUrl = new URL(baseDatabaseUrl!)
|
const testUrl = new URL(baseDatabaseUrl!)
|
||||||
testUrl.pathname = `/${databaseName}`
|
testUrl.pathname = `/${databaseName}`
|
||||||
const databaseUrl = testUrl.toString()
|
const databaseUrl = testUrl.toString()
|
||||||
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
await runMigrations(databaseUrl, path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations'), () => undefined)
|
||||||
await runMigrations(databaseUrl, migrationsDir, () => undefined)
|
|
||||||
|
|
||||||
const port = await availablePort()
|
const port = await availablePort()
|
||||||
process.env.DATABASE_URL = databaseUrl
|
process.env.DATABASE_URL = databaseUrl
|
||||||
process.env.LEVEL_EDITING_ENABLED = 'true'
|
process.env.LEVEL_EDITING_ENABLED = 'true'
|
||||||
process.env.JWT_SECRET = 'osint-integration-jwt-secret'
|
process.env.JWT_SECRET = 'osint-integration-jwt-secret'
|
||||||
|
process.env.ASSET_STORAGE_DRIVER = 'memory'
|
||||||
process.env.PORT = String(port)
|
process.env.PORT = String(port)
|
||||||
const serverModule = await import('./index.js')
|
const serverModule = await import('./index.js')
|
||||||
appServer = serverModule.server
|
appServer = serverModule.server
|
||||||
@@ -69,217 +71,107 @@ suite('level persistence API', () => {
|
|||||||
await adminClient.end()
|
await adminClient.end()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('persists one normalized level across authoring and play views', async () => {
|
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 })
|
expect(await (await fetch(`${baseUrl}/api/session`)).json()).toEqual({ authenticated: false, isAdmin: false })
|
||||||
expect(await (await adminFetch(`${baseUrl}/api/session`)).json()).toEqual({ authenticated: true, isAdmin: true })
|
|
||||||
expect((await fetch(`${baseUrl}/api/levels`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status).toBe(403)
|
|
||||||
const createResponse = await adminFetch(`${baseUrl}/api/levels`, {
|
const createResponse = await adminFetch(`${baseUrl}/api/levels`, {
|
||||||
method: 'POST',
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }),
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }),
|
|
||||||
})
|
})
|
||||||
expect(createResponse.status).toBe(201)
|
expect(createResponse.status).toBe(201)
|
||||||
const state = await createResponse.json() as CaseState
|
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 }
|
state.viewport = { x: 91, y: -42, zoom: 0.85 }
|
||||||
state.timelineRange = { start: '2021-04-01', end: '2021-04-30' }
|
|
||||||
const documentId = randomUUID()
|
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 folderId = randomUUID()
|
const gatedDocument: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Later tip', body: [], regions: [], fileType: 'image', metadata: {}, requiredFlags: ['tip.received'], ...placed(1260, 417, 174, 145, 3) }
|
||||||
const noteId = randomUUID()
|
const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) }
|
||||||
const eventId = randomUUID()
|
const note: NoteExhibit = { id: randomUUID(), type: 'note', title: 'Extract', content: 'Date matters', ...placed(420, 300, 108, 154) }
|
||||||
const personConceptId = randomUUID()
|
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 organizationConceptId = randomUUID()
|
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.brief = { body: 'Identify Ada Lovelace and Analytical Engines Ltd in the source material.', concepts: [
|
state.exhibits = [document, gatedDocument, folder, note, event, party]
|
||||||
{ id: personConceptId, label: 'Ada Lovelace', context: 'Named as the correspondent.', expectedPartyKind: 'person' },
|
state.relations = [
|
||||||
{ id: organizationConceptId, label: 'Analytical Engines Ltd', context: 'Issued the filing.', expectedPartyKind: 'organization' },
|
{ 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 },
|
||||||
state.documents = [{ id: documentId, title: 'Evidence', kind: 'IMAGE', date: '2021-04-17', 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: {} }]
|
{ id: randomUUID(), fromExhibitId: event.id, toExhibitId: document.id, type: 'supports', sortOrder: 0 },
|
||||||
state.evidence = [
|
{ id: randomUUID(), fromExhibitId: event.id, toExhibitId: note.id, type: 'supports', sortOrder: 1 },
|
||||||
{ id: folderId, type: 'folder', title: 'Folder', content: 'Evidence folder', x: 685, y: 417, width: 260, config: { open: true }, containedDocumentIds: [documentId] },
|
{ id: randomUUID(), fromExhibitId: party.id, toExhibitId: document.id, type: 'concerns', sortOrder: 0 },
|
||||||
{ id: noteId, type: 'note', title: 'Extract', content: 'Date matters', sourceDocumentId: documentId, sourceRegionId: 'stamp', x: 420, y: 300, width: 108 },
|
|
||||||
{ id: eventId, type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', supportingEvidenceIds: [documentId, noteId], x: 520, y: 610, width: 270 },
|
|
||||||
]
|
]
|
||||||
state.relations = [{ id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }]
|
state.connections = [{ id: randomUUID(), fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 }]
|
||||||
state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: documentId, 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 saveResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify(state),
|
|
||||||
})
|
|
||||||
expect(await saveResponse.json()).toEqual({ ok: true, mode: 'author' })
|
|
||||||
|
|
||||||
|
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
|
const loaded = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||||
expect(loaded.viewport).toEqual(state.viewport)
|
expect(loaded.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
|
||||||
expect(loaded.timelineRange).toEqual(state.timelineRange)
|
expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true })
|
||||||
expect(loaded.evidence[0]).toMatchObject({ id: folderId, x: 685, y: 417, config: { open: true } })
|
expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })]))
|
||||||
expect(loaded.relations[0]).toMatchObject({ id: `contains:${folderId}:${documentId}`, config: { x: 1051, y: 417 } })
|
expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' })
|
||||||
expect(loaded.connections).toContainEqual(expect.objectContaining({ fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 }))
|
expect(loaded.exhibits.find(item => item.id === gatedDocument.id)).toMatchObject({ requiredFlags: ['tip.received'] })
|
||||||
expect(loaded.brief.concepts).toEqual(expect.arrayContaining([
|
|
||||||
expect.objectContaining({ label: 'Ada Lovelace', expectedPartyKind: 'person' }),
|
const beforeFlag = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
|
||||||
expect.objectContaining({ label: 'Analytical Engines Ltd', expectedPartyKind: 'organization' }),
|
expect(beforeFlag.exhibits.map(item => item.id)).not.toContain(gatedDocument.id)
|
||||||
]))
|
expect(beforeFlag.newlyVisibleDocumentIds).toContain(document.id)
|
||||||
const legacyClientState = structuredClone(loaded)
|
expect(await (await adminFetch(`${baseUrl}/api/levels/${state.id}/flags`)).json()).toEqual([
|
||||||
delete legacyClientState.timelineRange
|
{ key: 'tip.received', gatedDocumentCount: 1 },
|
||||||
const legacySave = await fetch(`${baseUrl}/api/levels/${state.id}`, {
|
])
|
||||||
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(legacyClientState),
|
expect((await adminFetch(`${baseUrl}/api/levels/${state.id}/flags/tip.received`, { method: 'PUT' })).status).toBe(200)
|
||||||
|
const afterFlag = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
|
||||||
|
expect(afterFlag.exhibits.map(item => item.id)).toContain(gatedDocument.id)
|
||||||
|
expect(afterFlag.newlyVisibleDocumentIds).toContain(gatedDocument.id)
|
||||||
|
expect((await fetch(`${baseUrl}/api/levels/${state.id}/reveals/seen`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: afterFlag.newlyVisibleDocumentIds }) })).status).toBe(200)
|
||||||
|
expect((await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState).newlyVisibleDocumentIds).toEqual([])
|
||||||
|
|
||||||
|
expect((await adminFetch(`${baseUrl}/api/levels/${state.id}/flags/tip.received`, { method: 'DELETE' })).status).toBe(200)
|
||||||
|
const matchRuleResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/evidence-match-rules`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({
|
||||||
|
name: 'Smoke source passage', flagKey: 'tip.received', minimumAnchorMatches: 1,
|
||||||
|
anchors: [{ phrase: 'OSINT smoke evidence from the archive', minimumSimilarity: 0.72 }],
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
expect(legacySave.ok).toBe(true)
|
expect(matchRuleResponse.status).toBe(201)
|
||||||
expect((await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState).timelineRange).toEqual(state.timelineRange)
|
expect(await matchRuleResponse.json()).toMatchObject({ name: 'Smoke source passage', flagKey: 'tip.received', anchors: [{ phrase: 'OSINT smoke evidence from the archive' }] })
|
||||||
|
expect(await (await adminFetch(`${baseUrl}/api/levels/${state.id}/evidence-match-rules`)).json()).toHaveLength(1)
|
||||||
|
|
||||||
const upload = new FormData()
|
const upload = new FormData()
|
||||||
upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt')
|
upload.append('file', new Blob(['OSINT smoke evidence from the archlve'], { type: 'text/plain' }), 'smoke-evidence.txt')
|
||||||
const uploadResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/documents?edit=1`, { method: 'POST', body: upload })
|
upload.append('x', '812')
|
||||||
|
upload.append('y', '438')
|
||||||
|
const uploadResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents`, { method: 'POST', body: upload })
|
||||||
expect(uploadResponse.status).toBe(201)
|
expect(uploadResponse.status).toBe(201)
|
||||||
const uploaded = await uploadResponse.json() as CaseState['documents'][number]
|
const uploaded = await uploadResponse.json() as DocumentExhibit & { analysis: { extractionStatus: string; matchedFlags: string[]; awardedFlags: string[] } }
|
||||||
expect(uploaded).toMatchObject({ title: 'smoke-evidence.txt', fileName: 'smoke-evidence.txt', mimeType: 'text/plain', fileType: 'text' })
|
expect(uploaded).toMatchObject({ type: 'document', fileName: 'smoke-evidence.txt', fileType: 'text', x: 812, y: 438,
|
||||||
expect(uploaded.assetId).toBeTruthy()
|
body: ['OSINT smoke evidence from the archlve'], analysis: { extractionStatus: 'succeeded', matchedFlags: ['tip.received'], awardedFlags: ['tip.received'] } })
|
||||||
expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence')
|
expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence from the archlve')
|
||||||
|
const assetRow = await appPool.query<{ storage_provider: string; content: Buffer | null; object_key: string | null }>('SELECT storage_provider,content,object_key FROM osint.assets WHERE id=$1', [uploaded.assetId])
|
||||||
|
expect(assetRow.rows[0]).toMatchObject({ storage_provider: 's3', content: null, object_key: expect.stringMatching(/^assets\//) })
|
||||||
|
const automaticallyRevealed = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
|
||||||
|
expect(automaticallyRevealed.exhibits.map(item => item.id)).toContain(gatedDocument.id)
|
||||||
|
const evaluationRows = await appPool.query<{ matched: boolean; matched_anchor_count: number }>(
|
||||||
|
'SELECT matched,matched_anchor_count FROM osint.evidence_match_evaluations WHERE document_exhibit_id=$1', [uploaded.id])
|
||||||
|
expect(evaluationRows.rows).toEqual([{ matched: true, matched_anchor_count: 1 }])
|
||||||
|
|
||||||
const withUpload = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
const screenshot = new FormData()
|
||||||
const uploadedDocument = withUpload.documents.find(document => document.id === uploaded.id)!
|
screenshot.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), 'Screenshot 2026-08-22.png')
|
||||||
uploadedDocument.title = 'Renamed smoke evidence'
|
const screenshotResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents`, { method: 'POST', body: screenshot })
|
||||||
uploadedDocument.publishedAt = '2022-06-15T10:30:00.000Z'
|
expect(screenshotResponse.status).toBe(201)
|
||||||
uploadedDocument.metadata = { witness: 'Integration test', confidence: 'high' }
|
expect(await screenshotResponse.json()).toMatchObject({ type: 'document', fileType: 'image', fileName: 'Screenshot 2026-08-22.png' })
|
||||||
const metadataSave = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify(withUpload),
|
|
||||||
})
|
|
||||||
expect(metadataSave.ok).toBe(true)
|
|
||||||
const afterMetadataSave = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
|
||||||
expect(afterMetadataSave.documents.find(document => document.id === uploaded.id)).toMatchObject({
|
|
||||||
title: 'Renamed smoke evidence',
|
|
||||||
publishedAt: '2022-06-15T10:30:00.000Z',
|
|
||||||
metadata: { witness: 'Integration test', confidence: 'high' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const undatedState = structuredClone(afterMetadataSave)
|
const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }) })
|
||||||
const undatedEvent = undatedState.evidence.find(exhibit => exhibit.id === eventId)!
|
|
||||||
delete undatedEvent.eventDate
|
|
||||||
const undatedSave = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
|
||||||
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(undatedState),
|
|
||||||
})
|
|
||||||
expect(undatedSave.ok).toBe(true)
|
|
||||||
const loadedUndated = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
|
||||||
expect(loadedUndated.evidence.find(exhibit => exhibit.id === eventId)?.eventDate).toBeUndefined()
|
|
||||||
expect((await appPool.query<{ occurred_at: Date | null }>('SELECT occurred_at FROM osint.event_exhibits WHERE exhibit_id=$1', [eventId])).rows[0].occurred_at).toBeNull()
|
|
||||||
loadedUndated.evidence.find(exhibit => exhibit.id === eventId)!.eventDate = '2021-04-18T14:30:00Z'
|
|
||||||
expect((await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
|
||||||
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(loadedUndated),
|
|
||||||
})).ok).toBe(true)
|
|
||||||
|
|
||||||
const playerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
|
|
||||||
expect(playerState.brief.concepts.every(concept => concept.expectedPartyKind === undefined)).toBe(true)
|
|
||||||
const personPartyId = randomUUID()
|
|
||||||
const organizationPartyId = randomUUID()
|
|
||||||
playerState.evidence.push(
|
|
||||||
{ id: personPartyId, type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as the correspondent.', aliases: ['A. A. L.'], relatedEvidenceIds: [documentId, noteId], x: 720, y: 250, width: 280 },
|
|
||||||
{ id: organizationPartyId, type: 'party', partyKind: 'organization', organizationKind: 'business', title: 'Analytical Engines Ltd', content: 'Issued the filing.', aliases: ['AEL'], relatedEvidenceIds: [documentId], x: 1020, y: 250, width: 280 },
|
|
||||||
)
|
|
||||||
playerState.brief.concepts = playerState.brief.concepts.map(concept => ({ ...concept,
|
|
||||||
resolvedPartyExhibitId: concept.id === personConceptId ? personPartyId : organizationPartyId }))
|
|
||||||
playerState.viewport = { x: -150, y: 88, zoom: 1.1 }
|
|
||||||
playerState.evidence[0] = { ...playerState.evidence[0], x: 812, y: 533 }
|
|
||||||
const playerSave = await fetch(`${baseUrl}/api/levels/${state.id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify(playerState),
|
|
||||||
})
|
|
||||||
expect(await playerSave.json()).toEqual({ ok: true, mode: 'play' })
|
|
||||||
const savedPlayerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
|
|
||||||
expect(savedPlayerState.viewport).toEqual(playerState.viewport)
|
|
||||||
expect(savedPlayerState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
|
|
||||||
const sameLevelInEditView = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
|
||||||
expect(sameLevelInEditView.viewport).toEqual(playerState.viewport)
|
|
||||||
expect(sameLevelInEditView.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
|
|
||||||
|
|
||||||
const normalized = await appPool.query<{ exhibits: string; documents: string; folders: string; memberships: string; metadata: string; sources: string; connections: string; events: string; event_evidence: string; parties: string; people: string; organizations: string; party_evidence: string; concepts: string; hidden_answers: string }>(`SELECT
|
|
||||||
(SELECT COUNT(*) FROM osint.exhibits)::text AS exhibits,
|
|
||||||
(SELECT COUNT(*) FROM osint.document_exhibits)::text AS documents,
|
|
||||||
(SELECT COUNT(*) FROM osint.folder_exhibits)::text AS folders,
|
|
||||||
(SELECT COUNT(*) FROM osint.folder_memberships)::text AS memberships,
|
|
||||||
(SELECT COUNT(*) FROM osint.exhibit_metadata_text_values)::text AS metadata,
|
|
||||||
(SELECT COUNT(*) FROM osint.exhibit_sources)::text AS sources,
|
|
||||||
(SELECT COUNT(*) FROM osint.exhibit_connections)::text AS connections,
|
|
||||||
(SELECT COUNT(*) FROM osint.event_exhibits)::text AS events,
|
|
||||||
(SELECT COUNT(*) FROM osint.event_evidence)::text AS event_evidence,
|
|
||||||
(SELECT COUNT(*) FROM osint.party_exhibits)::text AS parties,
|
|
||||||
(SELECT COUNT(*) FROM osint.person_parties)::text AS people,
|
|
||||||
(SELECT COUNT(*) FROM osint.organization_parties)::text AS organizations,
|
|
||||||
(SELECT COUNT(*) FROM osint.party_evidence)::text AS party_evidence,
|
|
||||||
(SELECT COUNT(*) FROM osint.brief_concepts)::text AS concepts,
|
|
||||||
(SELECT COUNT(*) FROM osint.brief_concepts WHERE expected_party_kind IS NOT NULL)::text AS hidden_answers`)
|
|
||||||
expect(normalized.rows[0]).toEqual({ exhibits: '7', documents: '2', folders: '1', memberships: '1', metadata: '2', sources: '1', connections: '1', events: '1', event_evidence: '2', parties: '2', people: '1', organizations: '1', party_evidence: '3', concepts: '2', hidden_answers: '2' })
|
|
||||||
|
|
||||||
const resetResponse = await fetch(`${baseUrl}/api/levels/${state.id}/reset`, { method: 'POST' })
|
|
||||||
expect(resetResponse.ok).toBe(true)
|
|
||||||
const resetState = await resetResponse.json() as CaseState
|
|
||||||
expect(resetState.viewport).toEqual(playerState.viewport)
|
|
||||||
expect(resetState.timelineRange).toEqual(state.timelineRange)
|
|
||||||
expect(resetState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
|
|
||||||
|
|
||||||
const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
|
|
||||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }),
|
|
||||||
})
|
|
||||||
expect(templateResponse.status).toBe(201)
|
expect(templateResponse.status).toBe(201)
|
||||||
expect(await templateResponse.json()).toMatchObject({ slug: 'smoke-template', currentVersion: 1, versionCount: 1 })
|
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(await (await fetch(`${baseUrl}/api/templates`)).json()).toEqual([
|
|
||||||
expect.objectContaining({ slug: 'smoke-template', currentVersion: 1, versionCount: 1 }),
|
|
||||||
])
|
|
||||||
|
|
||||||
const changedSource = structuredClone(savedPlayerState)
|
|
||||||
changedSource.title = 'Changed after template freeze'
|
|
||||||
changedSource.evidence[0].x = 999
|
|
||||||
await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
|
||||||
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(changedSource),
|
|
||||||
})
|
|
||||||
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)
|
expect(cloneResponse.status).toBe(201)
|
||||||
const clone = await cloneResponse.json() as CaseState
|
const clone = await cloneResponse.json() as CaseState
|
||||||
expect(clone).toMatchObject({ id: 'smoke-template-copy', title: 'Playable copy', sourceTemplateVersionId: expect.any(String) })
|
expect(clone.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
|
||||||
expect(clone.timelineRange).toEqual(state.timelineRange)
|
expect(clone.exhibits.map(item => item.id)).not.toContain(folder.id)
|
||||||
expect(clone.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812, y: 533 })
|
expect(clone.exhibits.find(item => item.type === 'folder')).toMatchObject({ x: 685, y: 417 })
|
||||||
expect(clone.documents.find(item => item.title === 'Renamed smoke evidence')?.assetId).toBe(uploaded.assetId)
|
expect(clone.relations.filter(relation => relation.type === 'supports')).toHaveLength(2)
|
||||||
expect(clone.documents[0].id).not.toBe(savedPlayerState.documents[0].id)
|
expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 })
|
||||||
expect(clone.evidence.map(item => item.id)).not.toContain(folderId)
|
expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id)
|
||||||
expect(clone.connections).toHaveLength(1)
|
|
||||||
expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12, toEvidenceId: clone.documents[0].id })
|
|
||||||
expect(clone.evidence.find(item => item.type === 'note')).toMatchObject({ sourceRegionId: 'stamp' })
|
|
||||||
const clonedEvent = clone.evidence.find(item => item.type === 'event')!
|
|
||||||
expect(clonedEvent).toMatchObject({ title: 'The meeting occurred', eventDate: '2021-04-18T14:30:00.000Z' })
|
|
||||||
expect(clonedEvent.supportingEvidenceIds).toHaveLength(2)
|
|
||||||
expect(clonedEvent.supportingEvidenceIds).not.toContain(documentId)
|
|
||||||
expect(clonedEvent.supportingEvidenceIds).not.toContain(noteId)
|
|
||||||
expect(clone.evidence.filter(item => item.type === 'party')).toHaveLength(2)
|
|
||||||
expect(clone.brief.concepts.every(concept => Boolean(concept.resolvedPartyExhibitId))).toBe(true)
|
|
||||||
expect(clone.brief.concepts.map(concept => concept.resolvedPartyExhibitId)).not.toContain(personPartyId)
|
|
||||||
const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
|
const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
|
||||||
expect(authoredClone.brief.concepts.map(concept => concept.expectedPartyKind).sort()).toEqual(['organization', 'person'])
|
expect(authoredClone.exhibits.find(item => item.type === 'document' && item.title === 'Later tip')).toMatchObject({ requiredFlags: ['tip.received'] })
|
||||||
|
expect(await (await adminFetch(`${baseUrl}/api/levels/${clone.id}/evidence-match-rules`)).json()).toEqual([
|
||||||
const clonedFolder = clone.evidence.find(item => item.type === 'folder')!
|
expect.objectContaining({ name: 'Smoke source passage', flagKey: 'tip.received', anchors: [expect.objectContaining({ phrase: 'OSINT smoke evidence from the archive' })] }),
|
||||||
clonedFolder.x = 1234
|
])
|
||||||
clone.viewport = { x: 333, y: 222, zoom: 1.2 }
|
|
||||||
await fetch(`${baseUrl}/api/levels/${clone.id}`, {
|
|
||||||
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(clone),
|
|
||||||
})
|
|
||||||
const cloneReset = await (await fetch(`${baseUrl}/api/levels/${clone.id}/reset`, { method: 'POST' })).json() as CaseState
|
|
||||||
expect(cloneReset).toMatchObject({ title: 'API Smoke Level', viewport: { x: 0, y: 28, zoom: 0.7 } })
|
|
||||||
expect(cloneReset.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812, y: 533 })
|
|
||||||
expect(cloneReset.evidence.map(item => item.id)).not.toContain(clonedFolder.id)
|
|
||||||
|
|
||||||
const versionTwoResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
|
|
||||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }),
|
|
||||||
})
|
|
||||||
expect(await versionTwoResponse.json()).toMatchObject({ currentVersion: 2, versionCount: 2 })
|
|
||||||
const oldVersion = await (await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
|
|
||||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'old-version-copy', version: 1 }),
|
|
||||||
})).json() as CaseState
|
|
||||||
const currentVersion = await (await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
|
|
||||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'current-version-copy' }),
|
|
||||||
})).json() as CaseState
|
|
||||||
expect(oldVersion.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812 })
|
|
||||||
expect(currentVersion.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 999 })
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ export function hasAdminClaim(req: Request) {
|
|||||||
return req.authClaims?.role === 'admin' || req.authClaims?.isAdmin === true
|
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) {
|
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||||
if (!hasAdminClaim(req)) return res.status(403).json({ error: 'Administrator claim required' })
|
if (!hasAdminClaim(req)) return res.status(403).json({ error: 'Administrator claim required' })
|
||||||
next()
|
next()
|
||||||
|
|||||||
+41
-6
@@ -10,9 +10,10 @@ function mapped(ids: IdMap, sourceId: string, label: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function clearBoard(client: PoolClient, boardId: string) {
|
export async function clearBoard(client: PoolClient, boardId: string) {
|
||||||
await client.query('DELETE FROM osint.board_timeline_settings WHERE board_id=$1', [boardId])
|
await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [boardId])
|
||||||
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [boardId])
|
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [boardId])
|
||||||
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [boardId])
|
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [boardId])
|
||||||
|
await client.query('DELETE FROM osint.evidence_match_rules WHERE board_id=$1', [boardId])
|
||||||
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [boardId])
|
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [boardId])
|
||||||
await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [boardId])
|
await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [boardId])
|
||||||
await client.query('UPDATE osint.boards SET revision=0,updated_at=NOW() WHERE id=$1', [boardId])
|
await client.query('UPDATE osint.boards SET revision=0,updated_at=NOW() WHERE id=$1', [boardId])
|
||||||
@@ -24,11 +25,22 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
|
|||||||
const regionIds: IdMap = new Map()
|
const regionIds: IdMap = new Map()
|
||||||
const fieldIds: IdMap = new Map()
|
const fieldIds: IdMap = new Map()
|
||||||
|
|
||||||
const timeline = await client.query<{ range_start: string; range_end: string }>(
|
const views = await client.query<{
|
||||||
'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [sourceBoardId])
|
id: string; view_type_id: string; placement_mode: string; dock_edge: string | null; xpos: number | null; ypos: number | null
|
||||||
if (timeline.rows[0]) await client.query(
|
width: number | null; height: number; z_index: number; visible: boolean; range_mode: string | null; range_start: string | null; range_end: string | null
|
||||||
'INSERT INTO osint.board_timeline_settings (board_id,range_start,range_end) VALUES ($1,$2,$3)',
|
}>(`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,
|
||||||
[targetBoardId, timeline.rows[0].range_start, timeline.rows[0].range_end])
|
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<{
|
const exhibits = await client.query<{
|
||||||
id: string; exhibit_type_id: string; xpos: number; ypos: number; width: number; height: number
|
id: string; exhibit_type_id: string; xpos: number; ypos: number; width: number; height: number
|
||||||
@@ -57,6 +69,29 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
|
|||||||
(exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
(exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||||
[mapped(exhibitIds, row.exhibit_id, 'document'), row.document_type_id, row.asset_id, row.title, row.published_at, row.captured_at, row.source_uri])
|
[mapped(exhibitIds, row.exhibit_id, 'document'), row.document_type_id, row.asset_id, row.title, row.published_at, row.captured_at, row.source_uri])
|
||||||
|
|
||||||
|
const documentRequirements = await client.query<{ document_exhibit_id: string; flag_key: string }>(
|
||||||
|
'SELECT document_exhibit_id,flag_key FROM osint.document_flag_requirements WHERE board_id=$1 ORDER BY document_exhibit_id,flag_key', [sourceBoardId])
|
||||||
|
for (const row of documentRequirements.rows) await client.query(
|
||||||
|
'INSERT INTO osint.document_flag_requirements (board_id,document_exhibit_id,flag_key) VALUES ($1,$2,$3)',
|
||||||
|
[targetBoardId, mapped(exhibitIds, row.document_exhibit_id, 'document reveal requirement'), row.flag_key])
|
||||||
|
|
||||||
|
const ruleIds: IdMap = new Map()
|
||||||
|
const matchRules = await client.query<{
|
||||||
|
id: string; name: string; flag_key: string; matcher_version: string; minimum_anchor_matches: number; enabled: boolean
|
||||||
|
}>('SELECT id,name,flag_key,matcher_version,minimum_anchor_matches,enabled FROM osint.evidence_match_rules WHERE board_id=$1 ORDER BY created_at,id', [sourceBoardId])
|
||||||
|
for (const row of matchRules.rows) {
|
||||||
|
const id = randomUUID(); ruleIds.set(row.id, id)
|
||||||
|
await client.query(`INSERT INTO osint.evidence_match_rules
|
||||||
|
(id,board_id,origin_rule_id,name,flag_key,matcher_version,minimum_anchor_matches,enabled)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, [id,targetBoardId,row.id,row.name,row.flag_key,row.matcher_version,row.minimum_anchor_matches,row.enabled])
|
||||||
|
}
|
||||||
|
const matchAnchors = await client.query<{ rule_id: string; phrase_text: string; minimum_similarity: string; sort_order: number }>(
|
||||||
|
`SELECT a.rule_id,a.phrase_text,a.minimum_similarity::text,a.sort_order FROM osint.evidence_match_anchors a
|
||||||
|
JOIN osint.evidence_match_rules r ON r.id=a.rule_id WHERE r.board_id=$1 ORDER BY a.rule_id,a.sort_order,a.id`, [sourceBoardId])
|
||||||
|
for (const row of matchAnchors.rows) await client.query(`INSERT INTO osint.evidence_match_anchors
|
||||||
|
(id,rule_id,phrase_text,minimum_similarity,sort_order) VALUES ($1,$2,$3,$4,$5)`,
|
||||||
|
[randomUUID(), mapped(ruleIds, row.rule_id, 'evidence match rule'), row.phrase_text, row.minimum_similarity, row.sort_order])
|
||||||
|
|
||||||
const images = await client.query<{ exhibit_id: string; pixel_width: number | null; pixel_height: number | null; alt_text: string }>(
|
const images = await client.query<{ exhibit_id: string; pixel_width: number | null; pixel_height: number | null; alt_text: string }>(
|
||||||
`SELECT i.* FROM osint.image_documents i JOIN osint.exhibits e ON e.id=i.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
`SELECT i.* FROM osint.image_documents i JOIN osint.exhibits e ON e.id=i.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||||
for (const row of images.rows) await client.query(
|
for (const row of images.rows) await client.query(
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ process.env.LEVEL_EDITING_ENABLED = 'true'
|
|||||||
process.env.JWT_SECRET = 'osint-e2e-jwt-secret'
|
process.env.JWT_SECRET = 'osint-e2e-jwt-secret'
|
||||||
process.env.PORT = String(port)
|
process.env.PORT = String(port)
|
||||||
process.env.OSINT_MANAGED_SERVER = 'true'
|
process.env.OSINT_MANAGED_SERVER = 'true'
|
||||||
|
process.env.ASSET_STORAGE_DRIVER = 'memory'
|
||||||
const { server, pool } = await import('./index.js')
|
const { server, pool } = await import('./index.js')
|
||||||
if (!server.listening) await once(server, 'listening')
|
if (!server.listening) await once(server, 'listening')
|
||||||
const baseUrl = `http://127.0.0.1:${port}`
|
const baseUrl = `http://127.0.0.1:${port}`
|
||||||
@@ -49,17 +50,15 @@ state.brief = { body: 'Classify the named people and organizations in this inves
|
|||||||
{ id: '44444444-4444-4444-8444-444444444444', label: 'Ada Lovelace', context: 'Named as the correspondent.', expectedPartyKind: 'person' },
|
{ 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' },
|
{ id: '55555555-5555-4555-8555-555555555555', label: 'Difference Engine Bureau', context: 'Issued the archive notice.', expectedPartyKind: 'organization' },
|
||||||
] }
|
] }
|
||||||
state.documents = [{
|
state.exhibits = [{
|
||||||
id: documentId, title: 'Dated source image', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00.000Z',
|
id: documentId, type: 'document', title: 'Dated source image', publishedAt: '2021-04-17T12:00:00.000Z',
|
||||||
body: [], regions: [], fileType: 'image', metadata: {},
|
body: [], regions: [], fileType: 'image', metadata: {}, x: 980, y: 360, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false,
|
||||||
}]
|
}, {
|
||||||
state.evidence = [{
|
|
||||||
id: folderId, type: 'folder', title: 'BROWSER TEST FOLDER', content: 'Disposable evidence',
|
id: folderId, type: 'folder', title: 'BROWSER TEST FOLDER', content: 'Disposable evidence',
|
||||||
x: 600, y: 360, width: 260, config: { open: false }, containedDocumentIds: [documentId],
|
x: 600, y: 360, width: 260, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false,
|
||||||
}]
|
}]
|
||||||
state.relations = [{
|
state.relations = [{
|
||||||
id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0,
|
id: `contains:${folderId}:${documentId}`, fromExhibitId: folderId, toExhibitId: documentId, type: 'contains', sortOrder: 0,
|
||||||
config: { x: 980, y: 360 },
|
|
||||||
}]
|
}]
|
||||||
state.connections = []
|
state.connections = []
|
||||||
state.viewport = { x: 0, y: 28, zoom: 0.7 }
|
state.viewport = { x: 0, y: 28, zoom: 0.7 }
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { evaluateEvidenceRules, normalizeEvidenceText, scoreEvidenceAnchor } from './evidenceMatching.js'
|
||||||
|
|
||||||
|
const barricelliRule = {
|
||||||
|
id: 'rule-barricelli',
|
||||||
|
name: 'Contemporary Barricelli fire report',
|
||||||
|
flagKey: 'barricelli.child-rescue-source',
|
||||||
|
minimumAnchorMatches: 1,
|
||||||
|
anchors: [{
|
||||||
|
id: 'parents',
|
||||||
|
phrase: 'den italienske maler og opfinder Barricelli og frue, født Aall',
|
||||||
|
minimumSimilarity: 0.72,
|
||||||
|
}, {
|
||||||
|
id: 'drink',
|
||||||
|
phrase: 'Han vækkede nemlig sin mor for at faa noget at drikke',
|
||||||
|
minimumSimilarity: 0.72,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('evidence text matching', () => {
|
||||||
|
it('normalizes historical Norwegian characters and page layout noise', () => {
|
||||||
|
expect(normalizeEvidenceText('Født Aall — 2½ aar\n gammel')).toBe('fodt aall 2 1 2 aar gammel')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tolerates plausible OCR substitutions in a distinctive passage', () => {
|
||||||
|
const result = scoreEvidenceAnchor(
|
||||||
|
normalizeEvidenceText('I kvistleiligheden boede den italienske maler og opfinder Barrioelli og frue, født Aall, med sin lille søn.'),
|
||||||
|
barricelliRule.anchors[0].phrase,
|
||||||
|
)
|
||||||
|
expect(result.similarity).toBeGreaterThan(0.9)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('awards the data-defined flag when one configured anchor is present', () => {
|
||||||
|
const evaluations = evaluateEvidenceRules('Han vækkede nemlig sin mor for at faa noget at drikke, og da ser hun huset brænder.', [barricelliRule])
|
||||||
|
expect(evaluations[0]).toMatchObject({ matched: true, matchedAnchorCount: 1, flagKey: 'barricelli.child-rescue-source' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not match generic words from an unrelated fire report', () => {
|
||||||
|
const evaluations = evaluateEvidenceRules('A family escaped from a boarding-house fire during the night.', [barricelliRule])
|
||||||
|
expect(evaluations[0]).toMatchObject({ matched: false, matchedAnchorCount: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can require multiple anchors for a stricter level rule', () => {
|
||||||
|
const rule = { ...barricelliRule, minimumAnchorMatches: 2 }
|
||||||
|
expect(evaluateEvidenceRules(barricelliRule.anchors[0].phrase, [rule])[0].matched).toBe(false)
|
||||||
|
expect(evaluateEvidenceRules(barricelliRule.anchors.map(anchor => anchor.phrase).join(' '), [rule])[0].matched).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
export type EvidenceMatchAnchor = {
|
||||||
|
id: string
|
||||||
|
phrase: string
|
||||||
|
minimumSimilarity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EvidenceMatchRule = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
flagKey: string
|
||||||
|
minimumAnchorMatches: number
|
||||||
|
anchors: EvidenceMatchAnchor[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EvidenceAnchorEvaluation = {
|
||||||
|
anchorId: string
|
||||||
|
similarity: number
|
||||||
|
matched: boolean
|
||||||
|
matchedText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EvidenceRuleEvaluation = {
|
||||||
|
ruleId: string
|
||||||
|
flagKey: string
|
||||||
|
matched: boolean
|
||||||
|
matchedAnchorCount: number
|
||||||
|
score: number
|
||||||
|
anchors: EvidenceAnchorEvaluation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_MATCH_TEXT_CHARACTERS = 200_000
|
||||||
|
|
||||||
|
/** Normalize historical spelling characters, punctuation, line breaks, and accents without changing word order. */
|
||||||
|
export function normalizeEvidenceText(value: string) {
|
||||||
|
return value.slice(0, MAX_MATCH_TEXT_CHARACTERS)
|
||||||
|
.toLocaleLowerCase('en')
|
||||||
|
.replace(/æ/g, 'ae')
|
||||||
|
.replace(/ø/g, 'o')
|
||||||
|
.replace(/å/g, 'aa')
|
||||||
|
.replace(/½/g, ' 1 2 ')
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/\p{Mark}/gu, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function grams(value: string, size = 3) {
|
||||||
|
const compact = value.replace(/\s+/g, ' ')
|
||||||
|
if (compact.length <= size) return [compact]
|
||||||
|
const result: string[] = []
|
||||||
|
for (let index = 0; index <= compact.length - size; index += 1) result.push(compact.slice(index, index + size))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function diceSimilarity(left: string, right: string) {
|
||||||
|
if (left === right) return 1
|
||||||
|
if (!left || !right) return 0
|
||||||
|
const leftGrams = grams(left)
|
||||||
|
const rightGrams = grams(right)
|
||||||
|
const rightCounts = new Map<string, number>()
|
||||||
|
for (const gram of rightGrams) rightCounts.set(gram, (rightCounts.get(gram) || 0) + 1)
|
||||||
|
let overlap = 0
|
||||||
|
for (const gram of leftGrams) {
|
||||||
|
const count = rightCounts.get(gram) || 0
|
||||||
|
if (!count) continue
|
||||||
|
overlap += 1
|
||||||
|
rightCounts.set(gram, count - 1)
|
||||||
|
}
|
||||||
|
return (2 * overlap) / (leftGrams.length + rightGrams.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreEvidenceAnchor(normalizedDocument: string, phrase: string) {
|
||||||
|
const normalizedPhrase = normalizeEvidenceText(phrase)
|
||||||
|
if (!normalizedDocument || !normalizedPhrase) return { similarity: 0, matchedText: '' }
|
||||||
|
if (normalizedDocument.includes(normalizedPhrase)) return { similarity: 1, matchedText: normalizedPhrase }
|
||||||
|
|
||||||
|
const documentTokens = normalizedDocument.split(' ')
|
||||||
|
const phraseTokens = normalizedPhrase.split(' ')
|
||||||
|
const spread = Math.max(2, Math.min(8, Math.ceil(phraseTokens.length * 0.2)))
|
||||||
|
const minimumWindow = Math.max(1, phraseTokens.length - spread)
|
||||||
|
const maximumWindow = Math.min(documentTokens.length, phraseTokens.length + spread)
|
||||||
|
let best = { similarity: 0, matchedText: '' }
|
||||||
|
|
||||||
|
for (let windowSize = minimumWindow; windowSize <= maximumWindow; windowSize += 1) {
|
||||||
|
for (let start = 0; start + windowSize <= documentTokens.length; start += 1) {
|
||||||
|
const candidate = documentTokens.slice(start, start + windowSize).join(' ')
|
||||||
|
const similarity = diceSimilarity(normalizedPhrase, candidate)
|
||||||
|
if (similarity > best.similarity) best = { similarity, matchedText: candidate }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateEvidenceRules(text: string, rules: EvidenceMatchRule[]): EvidenceRuleEvaluation[] {
|
||||||
|
const normalizedDocument = normalizeEvidenceText(text)
|
||||||
|
return rules.map(rule => {
|
||||||
|
const anchors = rule.anchors.map(anchor => {
|
||||||
|
const result = scoreEvidenceAnchor(normalizedDocument, anchor.phrase)
|
||||||
|
const similarity = Math.max(0, Math.min(1, result.similarity))
|
||||||
|
return { anchorId: anchor.id, similarity, matched: similarity >= anchor.minimumSimilarity, matchedText: result.matchedText }
|
||||||
|
})
|
||||||
|
const matchedAnchors = anchors.filter(anchor => anchor.matched)
|
||||||
|
const requiredScores = [...anchors].sort((left, right) => right.similarity - left.similarity).slice(0, rule.minimumAnchorMatches)
|
||||||
|
const score = requiredScores.length ? requiredScores.reduce((sum, anchor) => sum + anchor.similarity, 0) / requiredScores.length : 0
|
||||||
|
return {
|
||||||
|
ruleId: rule.id,
|
||||||
|
flagKey: rule.flagKey,
|
||||||
|
matched: matchedAnchors.length >= rule.minimumAnchorMatches,
|
||||||
|
matchedAnchorCount: matchedAnchors.length,
|
||||||
|
score,
|
||||||
|
anchors,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+309
-12
@@ -8,8 +8,12 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import multer from 'multer'
|
import multer from 'multer'
|
||||||
import pg from 'pg'
|
import pg from 'pg'
|
||||||
import type { CaseState } from '../src/types.js'
|
import type { CaseState } from '../src/types.js'
|
||||||
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin } from './auth.js'
|
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolveUserId } from './auth.js'
|
||||||
import { createLevelRepository } from './levelRepository.js'
|
import { createLevelRepository } from './levelRepository.js'
|
||||||
|
import { createNarrativeRepository } from './narrativeRepository.js'
|
||||||
|
import { createTextExtractorFromEnv } from './ocr.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
|
||||||
@@ -20,7 +24,13 @@ if (!databaseUrl) {
|
|||||||
|
|
||||||
export 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 levels = createLevelRepository(pool, editingEnabled)
|
const objectStorage = createObjectStorageFromEnv()
|
||||||
|
await objectStorage.initialize()
|
||||||
|
const textExtractor = createTextExtractorFromEnv()
|
||||||
|
const levels = createLevelRepository(pool, editingEnabled, objectStorage)
|
||||||
|
const narrative = createNarrativeRepository(pool, objectStorage)
|
||||||
|
const storyGraph = createStoryGraphRepository(pool)
|
||||||
|
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' && hasAdminClaim(req)
|
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
|
||||||
@@ -41,7 +51,7 @@ const upload = multer({
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.get('/api/health', async (_req, res) => {
|
app.get('/api/health', async (_req, res) => {
|
||||||
try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', schema: 'osint', editingEnabled }) }
|
try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', objectStorage: objectStorage.provider, textExtraction: textExtractor.provider, schema: 'osint', editingEnabled }) }
|
||||||
catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
|
catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
|
||||||
})
|
})
|
||||||
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req) }))
|
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req) }))
|
||||||
@@ -89,22 +99,50 @@ app.get('/api/assets/:id', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const asset = await levels.getAsset(req.params.id)
|
const asset = await levels.getAsset(req.params.id)
|
||||||
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', requireAdmin, upload.single('file'), async (req, res, next) => {
|
app.post('/api/levels/:id/documents', upload.single('file'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
|
|
||||||
if (!req.file) return res.status(400).json({ error: 'A file is required' })
|
if (!req.file) return res.status(400).json({ error: 'A file is required' })
|
||||||
const document = await levels.uploadDocument(String(req.params.id), req.file)
|
const extraction = await textExtractor.extract(req.file)
|
||||||
|
const x = Number(req.body?.x); const y = Number(req.body?.y)
|
||||||
|
const placement = Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined
|
||||||
|
const document = await levels.uploadDocument(String(req.params.id), req.file, extraction, placement)
|
||||||
document ? res.status(201).json(document) : res.status(404).json({ error: 'Level not found' })
|
document ? res.status(201).json(document) : res.status(404).json({ error: 'Level not found' })
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
|
app.post('/api/levels/:id/reveals/seen', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const ids = Array.isArray(req.body?.documentIds) ? req.body.documentIds.map(String) : []
|
||||||
|
const acknowledged = await levels.acknowledgeRevealedDocuments(String(req.params.id), ids)
|
||||||
|
acknowledged === null ? res.status(404).json({ error: 'Level not found' }) : res.json({ acknowledged })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.get('/api/levels/:id/flags', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const flags = await levels.listFlags(String(req.params.id))
|
||||||
|
flags ? res.json(flags) : res.status(404).json({ error: 'Level not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.put('/api/levels/:id/flags/:key', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const updated = await levels.setFlag(String(req.params.id), String(req.params.key), true)
|
||||||
|
updated ? res.json({ ok: true }) : res.status(404).json({ error: 'Level not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.delete('/api/levels/:id/flags/:key', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const updated = await levels.setFlag(String(req.params.id), String(req.params.key), false)
|
||||||
|
updated ? res.json({ ok: true }) : res.status(404).json({ error: 'Level not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
app.get('/api/levels/:id', async (req, res, next) => {
|
app.get('/api/levels/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const level = await levels.getLevel(req.params.id, wantsEdit(req))
|
const level = await levels.getLevel(req.params.id, wantsEdit(req))
|
||||||
@@ -113,7 +151,7 @@ app.get('/api/levels/:id', async (req, res, next) => {
|
|||||||
})
|
})
|
||||||
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' })
|
||||||
try {
|
try {
|
||||||
const authorMode = wantsEdit(req)
|
const authorMode = wantsEdit(req)
|
||||||
await levels.saveLevel(state, authorMode)
|
await levels.saveLevel(state, authorMode)
|
||||||
@@ -127,6 +165,265 @@ app.post('/api/levels/:id/reset', async (req, res, next) => {
|
|||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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/levels/:id/evidence-match-rules', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rules = await levels.listEvidenceMatchRules(String(req.params.id))
|
||||||
|
rules ? res.json(rules) : res.status(404).json({ error: 'Level not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.post('/api/levels/:id/evidence-match-rules', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!requireEditing(res)) return
|
||||||
|
const rule = await levels.createEvidenceMatchRule(String(req.params.id), req.body)
|
||||||
|
rule ? res.status(201).json(rule) : res.status(404).json({ error: 'Level not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.put('/api/levels/:id/evidence-match-rules/:ruleId', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!requireEditing(res)) return
|
||||||
|
const rule = await levels.updateEvidenceMatchRule(String(req.params.id), String(req.params.ruleId), req.body)
|
||||||
|
rule ? res.json(rule) : res.status(404).json({ error: 'Level or evidence match rule not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.delete('/api/levels/:id/evidence-match-rules/:ruleId', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!requireEditing(res)) return
|
||||||
|
const removed = await levels.deleteEvidenceMatchRule(String(req.params.id), String(req.params.ruleId))
|
||||||
|
if (removed === null) return res.status(404).json({ error: 'Level not found' })
|
||||||
|
removed ? res.json({ ok: true }) : res.status(404).json({ error: 'Evidence match rule not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.get('/api/admin/mysteries', requireAdmin, async (_req, res, next) => {
|
||||||
|
try { res.json(await narrative.listMysteries()) } catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.delete('/api/admin/mysteries/:id', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!requireEditing(res)) return
|
||||||
|
const ok = await narrative.deleteMystery(String(req.params.id))
|
||||||
|
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Mystery not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
// Shared asset library (images, audio, PDFs) — reuses the immutable, deduplicated
|
||||||
|
// osint.assets store; bytes served via GET /api/assets/:id.
|
||||||
|
app.get('/api/admin/assets', requireAdmin, async (_req, res, next) => {
|
||||||
|
try { res.json(await narrative.listAssets()) } catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.post('/api/admin/assets', requireAdmin, upload.single('file'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!requireEditing(res)) return
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'A file is required' })
|
||||||
|
res.status(201).json(await narrative.uploadAsset(req.file))
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.delete('/api/admin/assets/:id', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!requireEditing(res)) return
|
||||||
|
const outcome = await narrative.deleteAsset(String(req.params.id))
|
||||||
|
if (outcome === 'deleted') return res.json({ ok: true })
|
||||||
|
res.status(outcome === 'in_use' ? 409 : 404).json({ error: outcome === 'in_use' ? 'Asset is in use' : 'Asset not found' })
|
||||||
|
} 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) }
|
||||||
|
})
|
||||||
|
// Resolved runtime dialogue tree for the editor's live preview (same resolver as play).
|
||||||
|
app.get('/api/admin/story-nodes/:id/dialogue', requireAdmin, async (req, res, next) => {
|
||||||
|
try { res.json(await narrative.resolveDialogue(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) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// The playthrough case-state (achievements). Read is open; granting is a dev-only
|
||||||
|
// stand-in until the server-side achievement rule engine drives awards from play.
|
||||||
|
app.get('/api/playthroughs/:id/achievements', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const flags = await narrative.listAchievements(String(req.params.id))
|
||||||
|
flags ? res.json(flags) : res.status(404).json({ error: 'Playthrough not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.post('/api/playthroughs/:id/achievements', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Manual grants are disabled' })
|
||||||
|
if (!req.body?.flagKey) return res.status(400).json({ error: 'A flagKey is required' })
|
||||||
|
const result = await narrative.awardAchievement(String(req.params.id), String(req.body.flagKey), req.body.nodeId ? String(req.body.nodeId) : null)
|
||||||
|
result.ok ? res.json({ earned: result.earned }) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
if (error instanceof multer.MulterError) {
|
if (error instanceof multer.MulterError) {
|
||||||
return res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ error: error.code === 'LIMIT_FILE_SIZE' ? 'Document exceeds the upload limit' : error.message })
|
return res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ error: error.code === 'LIMIT_FILE_SIZE' ? 'Document exceeds the upload limit' : error.message })
|
||||||
|
|||||||
+375
-138
@@ -1,11 +1,25 @@
|
|||||||
import { createHash, randomUUID } from 'node:crypto'
|
import { createHash, randomUUID } from 'node:crypto'
|
||||||
|
import { Readable } from 'node:stream'
|
||||||
import type { Pool, PoolClient } from 'pg'
|
import type { Pool, PoolClient } from 'pg'
|
||||||
import type { BriefConcept, CaseDocument, CaseState, Evidence, OrganizationKind, PartyKind, SourceFileType, WidgetRelation } from '../src/types.js'
|
import type { BoardView, BriefConcept, CaseDocument, CaseState, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, LevelFlag, OrganizationKind, PartyKind, SourceFileType, UploadedCaseDocument } from '../src/types.js'
|
||||||
|
import { isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js'
|
||||||
import { clearBoard, cloneBoard } from './boardClone.js'
|
import { clearBoard, cloneBoard } from './boardClone.js'
|
||||||
|
import { evaluateEvidenceRules, type EvidenceMatchRule } from './evidenceMatching.js'
|
||||||
|
import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVisibility.js'
|
||||||
|
import type { ObjectStorage } from './objectStorage.js'
|
||||||
|
import type { TextExtractionResult } from './ocr.js'
|
||||||
|
|
||||||
export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number }
|
export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number }
|
||||||
export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer }
|
export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer | null; storage_provider: 'postgres' | 's3'; object_key: string | null }
|
||||||
|
export type AssetResponse = { originalName: string; mimeType: string; byteSize: number; stream: NodeJS.ReadableStream }
|
||||||
export type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string }
|
export type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string }
|
||||||
|
export type EvidenceMatchRuleInput = {
|
||||||
|
name: string
|
||||||
|
flagKey: string
|
||||||
|
minimumAnchorMatches?: number
|
||||||
|
enabled?: boolean
|
||||||
|
anchors: { phrase: string; minimumSimilarity?: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface LevelRepository {
|
export interface LevelRepository {
|
||||||
listLevels(): Promise<unknown[]>
|
listLevels(): Promise<unknown[]>
|
||||||
@@ -16,17 +30,24 @@ export interface LevelRepository {
|
|||||||
getLevel(levelId: string, authorMode?: boolean): Promise<CaseState | null>
|
getLevel(levelId: string, authorMode?: boolean): Promise<CaseState | null>
|
||||||
saveLevel(state: CaseState, authorMode: boolean): Promise<void>
|
saveLevel(state: CaseState, authorMode: boolean): Promise<void>
|
||||||
resetLevel(levelId: string): Promise<CaseState | null>
|
resetLevel(levelId: string): Promise<CaseState | null>
|
||||||
getAsset(assetId: string): Promise<AssetRecord | null>
|
getAsset(assetId: string): Promise<AssetResponse | null>
|
||||||
uploadDocument(levelId: string, file: UploadedDocument): Promise<CaseDocument | null>
|
uploadDocument(levelId: string, file: UploadedDocument, extraction: TextExtractionResult, placement?: { x: number; y: number }): Promise<UploadedCaseDocument | null>
|
||||||
|
listFlags(levelId: string): Promise<LevelFlag[] | null>
|
||||||
|
setFlag(levelId: string, key: string, earned: boolean): Promise<boolean>
|
||||||
|
acknowledgeRevealedDocuments(levelId: string, documentIds: string[]): Promise<number | null>
|
||||||
|
listEvidenceMatchRules(levelId: string): Promise<EvidenceMatchRuleDefinition[] | null>
|
||||||
|
createEvidenceMatchRule(levelId: string, input: EvidenceMatchRuleInput): Promise<EvidenceMatchRuleDefinition | null>
|
||||||
|
updateEvidenceMatchRule(levelId: string, ruleId: string, input: EvidenceMatchRuleInput): Promise<EvidenceMatchRuleDefinition | null>
|
||||||
|
deleteEvidenceMatchRule(levelId: string, ruleId: string): Promise<boolean | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
type LevelRow = {
|
type LevelRow = {
|
||||||
id: string; slug: string; board_id: string; title: string; subtitle: string; status: string
|
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
|
viewport_x: number; viewport_y: number; viewport_zoom: number; updated_at: Date; revision: string
|
||||||
source_template_version_id: string | null
|
source_template_version_id: string | null
|
||||||
}
|
}
|
||||||
type ExhibitRow = {
|
type ExhibitRow = {
|
||||||
id: string; exhibit_type_id: 'folder' | 'document' | 'note' | 'event' | 'party'; xpos: number; ypos: number; width: number; hidden: boolean
|
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
|
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
|
asset_id: string | null; published_at: Date | null; occurred_at: Date | null
|
||||||
original_name: string | null; mime_type: string | null; byte_size: string | null
|
original_name: string | null; mime_type: string | null; byte_size: string | null
|
||||||
@@ -35,6 +56,7 @@ type ExhibitRow = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
const flagPattern = /^[a-z][a-z0-9_.-]{0,63}$/
|
||||||
function requireUuid(value: string, label: string) {
|
function requireUuid(value: string, label: string) {
|
||||||
if (!uuidPattern.test(value)) throw new Error(`${label} must be a UUID`)
|
if (!uuidPattern.test(value)) throw new Error(`${label} must be a UUID`)
|
||||||
return value
|
return value
|
||||||
@@ -44,28 +66,89 @@ function timestamp(value: string | undefined) {
|
|||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
return Number.isFinite(date.getTime()) ? date.toISOString() : null
|
return Number.isFinite(date.getTime()) ? date.toISOString() : null
|
||||||
}
|
}
|
||||||
|
function requireFlagKey(value: string) {
|
||||||
|
if (!flagPattern.test(value)) throw new Error('Flag keys must start with a lowercase letter and contain only lowercase letters, numbers, dots, dashes, or underscores')
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
function requireRuleInput(input: EvidenceMatchRuleInput) {
|
||||||
|
const name = String(input.name || '').trim()
|
||||||
|
if (!name || name.length > 160) throw new Error('Evidence match rule names must be between 1 and 160 characters')
|
||||||
|
const flagKey = requireFlagKey(String(input.flagKey || '').trim())
|
||||||
|
if (!Array.isArray(input.anchors) || !input.anchors.length || input.anchors.length > 20) throw new Error('Evidence match rules require between 1 and 20 anchors')
|
||||||
|
const anchors = input.anchors.map(anchor => {
|
||||||
|
const phrase = String(anchor.phrase || '').trim()
|
||||||
|
if (phrase.length < 12 || phrase.length > 1_000) throw new Error('Evidence match anchors must be between 12 and 1000 characters')
|
||||||
|
const minimumSimilarity = anchor.minimumSimilarity === undefined ? 0.72 : Number(anchor.minimumSimilarity)
|
||||||
|
if (!Number.isFinite(minimumSimilarity) || minimumSimilarity < 0.5 || minimumSimilarity > 1) throw new Error('Anchor similarity must be between 0.5 and 1')
|
||||||
|
return { phrase, minimumSimilarity }
|
||||||
|
})
|
||||||
|
const minimumAnchorMatches = input.minimumAnchorMatches === undefined ? 1 : Number(input.minimumAnchorMatches)
|
||||||
|
if (!Number.isInteger(minimumAnchorMatches) || minimumAnchorMatches < 1 || minimumAnchorMatches > anchors.length) throw new Error('Required anchor matches must be between 1 and the number of anchors')
|
||||||
|
return { name, flagKey, minimumAnchorMatches, enabled: input.enabled !== false, anchors }
|
||||||
|
}
|
||||||
function documentType(document: CaseDocument): SourceFileType {
|
function documentType(document: CaseDocument): SourceFileType {
|
||||||
const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
|
const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
|
||||||
return allowed.includes(document.fileType) ? document.fileType : 'file'
|
return allowed.includes(document.fileType) ? document.fileType : 'file'
|
||||||
}
|
}
|
||||||
function documentKind(type: SourceFileType) {
|
export function createLevelRepository(pool: Pool, editingEnabled: boolean, objectStorage: ObjectStorage): LevelRepository {
|
||||||
return type === 'web_capture' ? 'WEB CAPTURE' : type.toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createLevelRepository(pool: Pool, editingEnabled: boolean): LevelRepository {
|
|
||||||
async function findLevel(client: Pool | PoolClient, slug: string, lock = false) {
|
async function findLevel(client: Pool | PoolClient, slug: string, lock = false) {
|
||||||
const result = await client.query<LevelRow>(`SELECT id, slug, board_id, title, subtitle, status,
|
const result = await client.query<LevelRow>(`SELECT l.id,l.slug,l.board_id,l.title,l.subtitle,l.status,
|
||||||
viewport_x, viewport_y, viewport_zoom, updated_at, source_template_version_id
|
l.viewport_x,l.viewport_y,l.viewport_zoom,l.updated_at,l.source_template_version_id,b.revision::text
|
||||||
FROM osint.levels WHERE slug = $1${lock ? ' FOR UPDATE' : ''}`, [slug])
|
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
|
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 evidenceMatchRules(client: Pool | PoolClient, boardId: string, includeDisabled = false): Promise<EvidenceMatchRuleDefinition[]> {
|
||||||
|
const result = await client.query<{
|
||||||
|
rule_id: string; name: string; flag_key: string; matcher_version: 'char_trigram_v1'; minimum_anchor_matches: number; enabled: boolean
|
||||||
|
anchor_id: string; phrase_text: string; minimum_similarity: string; sort_order: number
|
||||||
|
}>(`SELECT r.id AS rule_id,r.name,r.flag_key,r.matcher_version,r.minimum_anchor_matches,r.enabled,
|
||||||
|
a.id AS anchor_id,a.phrase_text,a.minimum_similarity::text,a.sort_order
|
||||||
|
FROM osint.evidence_match_rules r
|
||||||
|
JOIN osint.evidence_match_anchors a ON a.rule_id=r.id
|
||||||
|
WHERE r.board_id=$1 ${includeDisabled ? '' : 'AND r.enabled'}
|
||||||
|
ORDER BY r.created_at,r.id,a.sort_order,a.id`, [boardId])
|
||||||
|
const rules = new Map<string, EvidenceMatchRuleDefinition>()
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const rule = rules.get(row.rule_id) || { id: row.rule_id, name: row.name, flagKey: row.flag_key,
|
||||||
|
matcherVersion: row.matcher_version, minimumAnchorMatches: row.minimum_anchor_matches, enabled: row.enabled, anchors: [] }
|
||||||
|
rule.anchors.push({ id: row.anchor_id, phrase: row.phrase_text, minimumSimilarity: Number(row.minimum_similarity), sortOrder: row.sort_order })
|
||||||
|
rules.set(row.rule_id, rule)
|
||||||
|
}
|
||||||
|
return [...rules.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeEvidenceMatchRule(client: PoolClient, level: LevelRow, ruleId: string, rawInput: EvidenceMatchRuleInput, update: boolean) {
|
||||||
|
const input = requireRuleInput(rawInput)
|
||||||
|
if (update) {
|
||||||
|
const changed = await client.query(`UPDATE osint.evidence_match_rules SET name=$3,flag_key=$4,minimum_anchor_matches=$5,enabled=$6,updated_at=NOW()
|
||||||
|
WHERE id=$1 AND board_id=$2`, [ruleId, level.board_id, input.name, input.flagKey, input.minimumAnchorMatches, input.enabled])
|
||||||
|
if (!changed.rowCount) return null
|
||||||
|
await client.query('DELETE FROM osint.evidence_match_anchors WHERE rule_id=$1', [ruleId])
|
||||||
|
} else {
|
||||||
|
await client.query(`INSERT INTO osint.evidence_match_rules (id,board_id,name,flag_key,minimum_anchor_matches,enabled)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6)`, [ruleId, level.board_id, input.name, input.flagKey, input.minimumAnchorMatches, input.enabled])
|
||||||
|
}
|
||||||
|
for (const [sortOrder, anchor] of input.anchors.entries()) await client.query(`INSERT INTO osint.evidence_match_anchors
|
||||||
|
(id,rule_id,phrase_text,minimum_similarity,sort_order) VALUES ($1,$2,$3,$4,$5)`,
|
||||||
|
[randomUUID(), ruleId, anchor.phrase, anchor.minimumSimilarity, sortOrder])
|
||||||
|
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
||||||
|
return (await evidenceMatchRules(client, level.board_id, true)).find(rule => rule.id === ruleId) || null
|
||||||
|
}
|
||||||
|
|
||||||
async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> {
|
async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> {
|
||||||
const level = await findLevel(pool, slug)
|
const level = await findLevel(pool, slug)
|
||||||
if (!level) return null
|
if (!level) return null
|
||||||
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
|
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
|
||||||
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, timelineResult] = await Promise.all([
|
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult, requirementsResult, flagsResult, seenResult] = await Promise.all([
|
||||||
pool.query<ExhibitRow>(`SELECT e.id, e.exhibit_type_id, e.xpos, e.ypos, e.width, e.hidden,
|
pool.query<ExhibitRow>(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden,
|
||||||
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, '') AS title,
|
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, '') AS title,
|
||||||
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
|
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
|
||||||
f.is_open, d.document_type_id, d.asset_id, d.published_at, ev.occurred_at,
|
f.is_open, d.document_type_id, d.asset_id, d.published_at, ev.occurred_at,
|
||||||
@@ -98,21 +181,27 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
|||||||
pool.query<{ exhibit_id: string; field_key: string; value: string }>(
|
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
|
`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]),
|
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 }>(
|
pool.query<{ event_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
|
||||||
`SELECT event_exhibit_id,evidence_exhibit_id FROM osint.event_evidence WHERE board_id=$1
|
`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]),
|
ORDER BY event_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
|
||||||
pool.query<{ party_exhibit_id: string; alias: string }>(
|
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
|
`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]),
|
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 }>(
|
pool.query<{ party_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
|
||||||
`SELECT party_exhibit_id,evidence_exhibit_id FROM osint.party_evidence WHERE board_id=$1
|
`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]),
|
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<{ 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 }>(
|
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
|
`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]),
|
WHERE board_id=$1 ORDER BY sort_order,id`, [level.board_id]),
|
||||||
pool.query<{ range_start: string; range_end: string }>(
|
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 range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id]),
|
`SELECT v.id,v.view_type_id,v.placement_mode,v.dock_edge,v.xpos,v.ypos,v.width,v.height,v.z_index,v.visible,
|
||||||
|
t.range_mode,t.range_start::text,t.range_end::text FROM osint.board_views v
|
||||||
|
JOIN osint.timeline_views t ON t.view_id=v.id WHERE v.board_id=$1 ORDER BY v.z_index,v.created_at`, [level.board_id]),
|
||||||
|
pool.query<{ document_exhibit_id: string; flag_key: string }>(
|
||||||
|
'SELECT document_exhibit_id,flag_key FROM osint.document_flag_requirements WHERE board_id=$1 ORDER BY document_exhibit_id,flag_key', [level.board_id]),
|
||||||
|
pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.level_flags WHERE level_id=$1 ORDER BY flag_key', [level.id]),
|
||||||
|
pool.query<{ document_exhibit_id: string }>('SELECT document_exhibit_id FROM osint.level_seen_documents WHERE level_id=$1', [level.id]),
|
||||||
])
|
])
|
||||||
|
|
||||||
const blocks = new Map<string, string[]>()
|
const blocks = new Map<string, string[]>()
|
||||||
@@ -123,45 +212,55 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
|||||||
}])
|
}])
|
||||||
const metadata = new Map<string, Record<string, string>>()
|
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 })
|
for (const row of metadataResult.rows) metadata.set(row.exhibit_id, { ...(metadata.get(row.exhibit_id) || {}), [row.field_key]: row.value })
|
||||||
const contained = new Map<string, string[]>()
|
|
||||||
const eventEvidence = new Map<string, string[]>()
|
|
||||||
for (const row of eventEvidenceResult.rows) eventEvidence.set(row.event_exhibit_id, [...(eventEvidence.get(row.event_exhibit_id) || []), row.evidence_exhibit_id])
|
|
||||||
const aliases = new Map<string, string[]>()
|
const aliases = new Map<string, string[]>()
|
||||||
for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias])
|
for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias])
|
||||||
const partyEvidence = new Map<string, string[]>()
|
const requirements = new Map<string, string[]>()
|
||||||
for (const row of partyEvidenceResult.rows) partyEvidence.set(row.party_exhibit_id, [...(partyEvidence.get(row.party_exhibit_id) || []), row.evidence_exhibit_id])
|
for (const row of requirementsResult.rows) requirements.set(row.document_exhibit_id, [...(requirements.get(row.document_exhibit_id) || []), row.flag_key])
|
||||||
const relations: WidgetRelation[] = membershipsResult.rows.map(row => {
|
const relations: ExhibitRelation[] = [
|
||||||
contained.set(row.folder_exhibit_id, [...(contained.get(row.folder_exhibit_id) || []), row.child_exhibit_id])
|
...membershipsResult.rows.map(row => ({ id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromExhibitId: row.folder_exhibit_id,
|
||||||
return { id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromWidgetId: row.folder_exhibit_id,
|
toExhibitId: row.child_exhibit_id, type: 'contains' as const, sortOrder: row.sort_order })),
|
||||||
toWidgetId: row.child_exhibit_id, type: 'contains', sortOrder: row.sort_order, config: { x: row.xpos, y: row.ypos } }
|
...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 documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => {
|
||||||
const type = row.document_type_id || 'file'
|
const type = row.document_type_id || 'file'
|
||||||
const publishedAt = row.published_at?.toISOString()
|
const publishedAt = row.published_at?.toISOString()
|
||||||
return { id: row.id, title: row.title, kind: documentKind(type), date: publishedAt?.slice(0, 10) || '', publishedAt,
|
return { ...base(row), type: 'document', publishedAt, requiredFlags: requirements.get(row.id) || [],
|
||||||
body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined,
|
body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined,
|
||||||
fileName: row.original_name || undefined, mimeType: row.mime_type || undefined,
|
fileName: row.original_name || undefined, mimeType: row.mime_type || undefined,
|
||||||
fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, metadata: metadata.get(row.id) || {} }
|
fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, metadata: metadata.get(row.id) || {} }
|
||||||
})
|
})
|
||||||
const evidence: Evidence[] = exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document' && !row.hidden).map(row => ({
|
const evidence: Evidence[] = []
|
||||||
id: row.id, type: row.exhibit_type_id as Evidence['type'], title: row.title, content: row.content,
|
for (const row of exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document')) {
|
||||||
sourceDocumentId: row.source_document_id || undefined, sourceRegionId: row.source_region_key || undefined,
|
const common = { ...base(row), title: row.title, content: row.content }
|
||||||
eventDate: row.occurred_at?.toISOString(), x: row.xpos, y: row.ypos, width: row.width,
|
if (row.exhibit_type_id === 'folder') evidence.push({ ...common, type:'folder', isOpen:Boolean(row.is_open) })
|
||||||
supportingEvidenceIds: eventEvidence.get(row.id) || [],
|
else if (row.exhibit_type_id === 'event') evidence.push({ ...common, type:'event', eventDate:row.occurred_at?.toISOString() })
|
||||||
partyKind: row.party_kind || undefined, organizationKind: row.organization_kind || undefined,
|
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) || [] })
|
||||||
aliases: aliases.get(row.id) || [], relatedEvidenceIds: partyEvidence.get(row.id) || [],
|
else if (row.exhibit_type_id === 'note') evidence.push({ ...common, type:'note' })
|
||||||
config: row.exhibit_type_id === 'folder' ? { open: Boolean(row.is_open) } : {}, containedDocumentIds: contained.get(row.id) || [],
|
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,
|
const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text,
|
||||||
...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined }))
|
...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined }))
|
||||||
return { id: level.slug, title: level.title, subtitle: level.subtitle, documents, evidence, relations,
|
const fullState: CaseState = { id: level.slug, title: level.title, subtitle: level.subtitle, exhibits: [...documents, ...evidence], relations,
|
||||||
connections: connectionsResult.rows.map(row => ({ id: row.id, fromEvidenceId: row.from_exhibit_id, toEvidenceId: row.to_exhibit_id,
|
connections: connectionsResult.rows.map(row => ({ id: row.id, fromExhibitId: row.from_exhibit_id, toExhibitId: row.to_exhibit_id,
|
||||||
label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style,
|
label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style,
|
||||||
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
|
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
|
||||||
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
|
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
|
||||||
timelineRange: timelineResult.rows[0] ? { start: timelineResult.rows[0].range_start, end: timelineResult.rows[0].range_end } : undefined,
|
views, revision: Number(level.revision),
|
||||||
brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
|
brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
|
||||||
sourceTemplateVersionId: level.source_template_version_id || undefined }
|
sourceTemplateVersionId: level.source_template_version_id || undefined }
|
||||||
|
return filterLevelVisibility(fullState, flagsResult.rows.map(row => row.flag_key), seenResult.rows.map(row => row.document_exhibit_id), authorMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function templateSummary(slug: string): Promise<TemplateSummary | null> {
|
async function templateSummary(slug: string): Promise<TemplateSummary | null> {
|
||||||
@@ -178,27 +277,15 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function replaceBoard(client: PoolClient, level: LevelRow, state: CaseState) {
|
async function replaceBoard(client: PoolClient, level: LevelRow, state: CaseState) {
|
||||||
const documentIds = new Set(state.documents.map(document => requireUuid(document.id, 'Document id')))
|
if (!Array.isArray(state.exhibits) || !Array.isArray(state.views)) throw new Error('Level state must contain exhibits and views')
|
||||||
const evidenceIds = new Set(state.evidence.map(exhibit => requireUuid(exhibit.id, 'Exhibit id')))
|
const documents = state.exhibits.filter(isDocumentExhibit)
|
||||||
const allIds = [...documentIds, ...evidenceIds]
|
const evidence = state.exhibits.filter((exhibit): exhibit is Evidence => !isDocumentExhibit(exhibit))
|
||||||
if (new Set(allIds).size !== allIds.length) throw new Error('An id cannot identify both a document and another 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 relationList = state.relations || state.evidence.flatMap(exhibit => (exhibit.containedDocumentIds || []).map((documentId, index) => ({
|
const allIds = state.exhibits.map(exhibit => exhibit.id)
|
||||||
id: `contains:${exhibit.id}:${documentId}`, fromWidgetId: exhibit.id, toWidgetId: documentId, type: 'contains', sortOrder: index,
|
if (new Set(allIds).size !== allIds.length) throw new Error('Exhibit ids must be unique within a board')
|
||||||
})))
|
|
||||||
const positions = new Map<string, { x: number; y: number }>()
|
|
||||||
for (const relation of relationList.filter(item => item.type === 'contains')) {
|
|
||||||
positions.set(relation.toWidgetId, { x: Number(relation.config?.x ?? 100), y: Number(relation.config?.y ?? 100) })
|
|
||||||
}
|
|
||||||
const existing = await client.query<{ id: string; xpos: number; ypos: number }>('SELECT id, xpos, ypos FROM osint.exhibits WHERE board_id = $1', [level.board_id])
|
|
||||||
for (const row of existing.rows) if (!positions.has(row.id)) positions.set(row.id, { x: row.xpos, y: row.ypos })
|
|
||||||
const expectedConceptKinds = new Map((await client.query<{ id: string; expected_party_kind: PartyKind | null }>(
|
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]))
|
'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]))
|
||||||
const existingTimelineResult = await client.query<{ range_start: string; range_end: string }>(
|
|
||||||
'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id])
|
|
||||||
const existingTimeline = existingTimelineResult.rows[0]
|
|
||||||
? { start: existingTimelineResult.rows[0].range_start, end: existingTimelineResult.rows[0].range_end }
|
|
||||||
: null
|
|
||||||
|
|
||||||
await client.query(`UPDATE osint.levels SET title=$2, subtitle=$3, viewport_x=$4, viewport_y=$5, viewport_zoom=$6,
|
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])
|
updated_at=NOW() WHERE id=$1`, [level.id, state.title, state.subtitle, state.viewport.x, state.viewport.y, state.viewport.zoom])
|
||||||
@@ -208,7 +295,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
|||||||
await client.query('DELETE FROM osint.event_evidence 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_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.party_relationships WHERE board_id=$1', [level.board_id])
|
||||||
await client.query('DELETE FROM osint.board_timeline_settings 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.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.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.exhibit_sources WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)', [level.board_id])
|
||||||
@@ -219,37 +306,35 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
|||||||
if (allIds.length) await client.query('DELETE FROM osint.exhibits WHERE board_id=$1 AND NOT (id = ANY($2::uuid[]))', [level.board_id, allIds])
|
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])
|
else await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [level.board_id])
|
||||||
|
|
||||||
for (const [index, document] of state.documents.entries()) {
|
for (const exhibit of state.exhibits) {
|
||||||
const position = positions.get(document.id) || { x: 100, y: 100 }
|
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,rotation,z_index,hidden)
|
||||||
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||||
VALUES ($1,$2,'document',$3,$4,174,145,$5,FALSE)
|
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()`,
|
||||||
ON CONFLICT (id) DO UPDATE SET exhibit_type_id='document',xpos=$3,ypos=$4,width=174,height=145,z_index=$5,hidden=FALSE,updated_at=NOW()`,
|
[exhibit.id, level.board_id, exhibit.type, exhibit.x, exhibit.y, exhibit.width, exhibit.height, exhibit.rotation, exhibit.zIndex, exhibit.hidden])
|
||||||
[document.id, level.board_id, position.x, position.y, index])
|
}
|
||||||
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at)
|
for (const document of documents) {
|
||||||
VALUES ($1,$2,$3,$4,$5)`, [document.id, documentType(document), document.assetId || null, document.title, timestamp(document.publishedAt || document.date)])
|
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])
|
if (document.fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [document.id])
|
||||||
|
for (const flag of new Set((document.requiredFlags || []).map(value => value.trim()).filter(Boolean).map(requireFlagKey))) await client.query(
|
||||||
|
'INSERT INTO osint.document_flag_requirements (board_id,document_exhibit_id,flag_key) VALUES ($1,$2,$3)', [level.board_id, document.id, flag])
|
||||||
for (const [sortOrder, content] of document.body.entries()) await client.query(
|
for (const [sortOrder, content] of document.body.entries()) await client.query(
|
||||||
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,$3,$4)', [randomUUID(), document.id, sortOrder, content])
|
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,$3,$4)', [randomUUID(), document.id, sortOrder, content])
|
||||||
for (const [sortOrder, region] of document.regions.entries()) await client.query(
|
for (const [sortOrder, region] of document.regions.entries()) await client.query(
|
||||||
`INSERT INTO osint.document_regions (id,document_exhibit_id,region_key,label,excerpt,occurred_at,sort_order)
|
`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])
|
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [randomUUID(), document.id, region.id, region.label, region.excerpt, timestamp(region.date), sortOrder])
|
||||||
}
|
}
|
||||||
for (const [index, exhibit] of state.evidence.entries()) {
|
for (const exhibit of evidence) {
|
||||||
const type = exhibit.type === 'evidence' ? 'folder' : exhibit.type
|
if (isFolderExhibit(exhibit)) await client.query(
|
||||||
const canonicalType = type === 'folder' || type === 'note' || type === 'event' || type === 'party' ? type : 'note'
|
|
||||||
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
|
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,160,$7,FALSE)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=160,z_index=$7,hidden=FALSE,updated_at=NOW()`,
|
|
||||||
[exhibit.id, level.board_id, canonicalType, exhibit.x, exhibit.y, exhibit.width, state.documents.length + index])
|
|
||||||
if (canonicalType === 'folder') await client.query(
|
|
||||||
'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)',
|
'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)',
|
||||||
[exhibit.id, exhibit.title, exhibit.content, Boolean(exhibit.config?.open)])
|
[exhibit.id, exhibit.title, exhibit.content, exhibit.isOpen])
|
||||||
if (canonicalType === '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 (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 (canonicalType === 'event') await client.query(
|
if (isEventExhibit(exhibit)) await client.query(
|
||||||
'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)',
|
'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)])
|
[exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate)])
|
||||||
if (canonicalType === 'party') {
|
if (isPartyExhibit(exhibit)) {
|
||||||
const partyKind: PartyKind = exhibit.partyKind === 'organization' ? 'organization' : 'person'
|
const partyKind = exhibit.partyKind
|
||||||
await client.query('INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)',
|
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])
|
[exhibit.id, partyKind, exhibit.title, exhibit.content])
|
||||||
if (partyKind === 'person') await client.query('INSERT INTO osint.person_parties (exhibit_id) VALUES ($1)', [exhibit.id])
|
if (partyKind === 'person') await client.query('INSERT INTO osint.person_parties (exhibit_id) VALUES ($1)', [exhibit.id])
|
||||||
@@ -260,51 +345,57 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const relation of relationList.filter(item => item.type === 'contains')) {
|
for (const relation of state.relations) {
|
||||||
if (!evidenceIds.has(relation.fromWidgetId) || !allIds.includes(relation.toWidgetId)) throw new Error('Folder membership references an unknown exhibit')
|
if (!allIds.includes(relation.fromExhibitId) || !allIds.includes(relation.toExhibitId) || relation.fromExhibitId === relation.toExhibitId) throw new Error('Relation references an unknown or identical exhibit')
|
||||||
await client.query(`INSERT INTO osint.folder_memberships (board_id,folder_exhibit_id,child_exhibit_id,sort_order)
|
if (relation.type === 'contains') await client.query(`INSERT INTO osint.folder_memberships
|
||||||
VALUES ($1,$2,$3,$4)`, [level.board_id, relation.fromWidgetId, relation.toWidgetId, relation.sortOrder || 0])
|
(board_id,folder_exhibit_id,child_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
|
||||||
}
|
[level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder])
|
||||||
for (const event of state.evidence.filter(item => item.type === 'event')) {
|
if (relation.type === 'supports') await client.query(`INSERT INTO osint.event_evidence
|
||||||
for (const [sortOrder, evidenceId] of (event.supportingEvidenceIds || []).entries()) {
|
(board_id,event_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
|
||||||
if (evidenceId === event.id || !allIds.includes(evidenceId)) throw new Error('Event evidence references an unknown or identical exhibit')
|
[level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder, relation.note || null])
|
||||||
await client.query(`INSERT INTO osint.event_evidence
|
if (relation.type === 'concerns') await client.query(`INSERT INTO osint.party_evidence
|
||||||
(board_id,event_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
|
(board_id,party_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
|
||||||
[level.board_id, event.id, evidenceId, sortOrder])
|
[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')
|
||||||
for (const party of state.evidence.filter(item => item.type === 'party')) {
|
let regionId: string | null = null
|
||||||
for (const [sortOrder, evidenceId] of (party.relatedEvidenceIds || []).entries()) {
|
if (relation.sourceRegionId) {
|
||||||
if (evidenceId === party.id || !allIds.includes(evidenceId)) throw new Error('Party evidence references an unknown or identical exhibit')
|
const region = await client.query<{ id: string }>(
|
||||||
await client.query(`INSERT INTO osint.party_evidence
|
'SELECT id FROM osint.document_regions WHERE document_exhibit_id=$1 AND region_key=$2', [relation.toExhibitId, relation.sourceRegionId])
|
||||||
(board_id,party_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
|
regionId = region.rows[0]?.id || null
|
||||||
[level.board_id, party.id, evidenceId, sortOrder])
|
}
|
||||||
|
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) {
|
for (const connection of state.connections) {
|
||||||
requireUuid(connection.id, 'Connection id')
|
requireUuid(connection.id, 'Connection id')
|
||||||
if (!allIds.includes(connection.fromEvidenceId) || !allIds.includes(connection.toEvidenceId) || connection.fromEvidenceId === connection.toEvidenceId) throw new Error('Connection references an unknown or identical exhibit')
|
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 tightness = Math.max(0, Math.min(100, Math.round(Number(connection.tightness ?? 65))))
|
||||||
const tagStyle = connection.tagStyle === 'compact' ? 'compact' : 'luggage'
|
const tagStyle = connection.tagStyle === 'compact' ? 'compact' : 'luggage'
|
||||||
const tagPosition = Math.max(0, Math.min(100, Math.round(Number(connection.tagPosition ?? 50))))
|
const tagPosition = Math.max(0, Math.min(100, Math.round(Number(connection.tagPosition ?? 50))))
|
||||||
const lateralLimit = Math.round(10 + (100 - tightness) * .6)
|
const lateralLimit = Math.round(10 + (100 - tightness) * .6)
|
||||||
const tagOffset = Math.max(-lateralLimit, Math.min(lateralLimit, Math.round(Number(connection.tagOffset ?? 0))))
|
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)
|
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.fromEvidenceId, connection.toEvidenceId, connection.label?.trim() || null, tightness, tagStyle, tagPosition, tagOffset])
|
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 exhibit of state.evidence.filter(item => item.sourceDocumentId)) {
|
for (const view of state.views) {
|
||||||
if (!documentIds.has(exhibit.sourceDocumentId!)) throw new Error('Exhibit source references an unknown document')
|
requireUuid(view.id, 'Board view id')
|
||||||
let regionId: string | null = null
|
const placement = view.placement
|
||||||
if (exhibit.sourceRegionId) {
|
const docked = placement.mode === 'docked'
|
||||||
const region = await client.query<{ id: string }>(
|
await client.query(`INSERT INTO osint.board_views
|
||||||
'SELECT id FROM osint.document_regions WHERE document_exhibit_id=$1 AND region_key=$2', [exhibit.sourceDocumentId, exhibit.sourceRegionId])
|
(id,board_id,view_type_id,placement_mode,dock_edge,xpos,ypos,width,height,z_index,visible)
|
||||||
regionId = region.rows[0]?.id || null
|
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])
|
||||||
}
|
}
|
||||||
await client.query('INSERT INTO osint.exhibit_sources (exhibit_id,source_document_exhibit_id,source_region_id) VALUES ($1,$2,$3)',
|
|
||||||
[exhibit.id, exhibit.sourceDocumentId, regionId])
|
|
||||||
}
|
}
|
||||||
const fields = new Map<string, string>()
|
const fields = new Map<string, string>()
|
||||||
for (const document of state.documents) for (const key of Object.keys(document.metadata || {})) {
|
for (const document of documents) for (const key of Object.keys(document.metadata || {})) {
|
||||||
if (!fields.has(key)) {
|
if (!fields.has(key)) {
|
||||||
const fieldId = randomUUID(); fields.set(key, fieldId)
|
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.metadata_fields (id,board_id,field_key,label,value_type) VALUES ($1,$2,$3,$3,'text')`, [fieldId, level.board_id, key])
|
||||||
@@ -313,14 +404,6 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
|||||||
[document.id, fields.get(key), document.metadata[key]])
|
[document.id, fields.get(key), document.metadata[key]])
|
||||||
}
|
}
|
||||||
const brief = state.brief || { body: '', concepts: [] }
|
const brief = state.brief || { body: '', concepts: [] }
|
||||||
const savedTimeline = state.timelineRange === undefined ? existingTimeline : state.timelineRange
|
|
||||||
if (savedTimeline) {
|
|
||||||
const start = timestamp(savedTimeline.start)
|
|
||||||
const end = timestamp(savedTimeline.end)
|
|
||||||
if (!start || !end || Date.parse(end) <= Date.parse(start)) throw new Error('Timeline end must be after timeline start')
|
|
||||||
await client.query('INSERT INTO osint.board_timeline_settings (board_id,range_start,range_end) VALUES ($1,$2,$3)',
|
|
||||||
[level.board_id, savedTimeline.start, savedTimeline.end])
|
|
||||||
}
|
|
||||||
await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [level.board_id, brief.body || ''])
|
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()) {
|
for (const [sortOrder, concept] of brief.concepts.entries()) {
|
||||||
requireUuid(concept.id, 'Brief concept id')
|
requireUuid(concept.id, 'Brief concept id')
|
||||||
@@ -346,6 +429,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
|||||||
await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'level')`, [boardId])
|
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)`,
|
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])
|
[levelId, input.id, boardId, input.title, input.subtitle])
|
||||||
|
await createDefaultBoardViews(client, boardId)
|
||||||
await client.query('COMMIT')
|
await client.query('COMMIT')
|
||||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
return (await assembleLevel(input.id))!
|
return (await assembleLevel(input.id))!
|
||||||
@@ -412,13 +496,19 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
|||||||
return templateSummary(input.slug)
|
return templateSummary(input.slug)
|
||||||
},
|
},
|
||||||
getLevel(levelId, authorMode = false) { return assembleLevel(levelId, authorMode) },
|
getLevel(levelId, authorMode = false) { return assembleLevel(levelId, authorMode) },
|
||||||
async saveLevel(state) {
|
async saveLevel(state, authorMode) {
|
||||||
|
let persistedState = state
|
||||||
|
if (!authorMode) {
|
||||||
|
const [full, visible] = await Promise.all([assembleLevel(state.id, true), assembleLevel(state.id, false)])
|
||||||
|
if (!full || !visible) throw new Error('Level not found')
|
||||||
|
persistedState = mergePlayerStateForPersistence(full, visible, state)
|
||||||
|
}
|
||||||
const client = await pool.connect()
|
const client = await pool.connect()
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN')
|
await client.query('BEGIN')
|
||||||
const level = await findLevel(client, state.id, true)
|
const level = await findLevel(client, state.id, true)
|
||||||
if (!level) throw new Error('Level not found')
|
if (!level) throw new Error('Level not found')
|
||||||
await replaceBoard(client, level, state)
|
await replaceBoard(client, level, persistedState)
|
||||||
await client.query('COMMIT')
|
await client.query('COMMIT')
|
||||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
},
|
},
|
||||||
@@ -444,10 +534,18 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
|||||||
},
|
},
|
||||||
async getAsset(assetId) {
|
async getAsset(assetId) {
|
||||||
if (!uuidPattern.test(assetId)) return null
|
if (!uuidPattern.test(assetId)) return null
|
||||||
const result = await pool.query<AssetRecord>('SELECT original_name,mime_type,byte_size,content FROM osint.assets WHERE id=$1', [assetId])
|
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])
|
||||||
return result.rows[0] || null
|
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) {
|
async uploadDocument(levelId, file, extraction, placement) {
|
||||||
const client = await pool.connect()
|
const client = await pool.connect()
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN')
|
await client.query('BEGIN')
|
||||||
@@ -455,22 +553,161 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
|||||||
if (!level) { await client.query('ROLLBACK'); return null }
|
if (!level) { await client.query('ROLLBACK'); return null }
|
||||||
const candidateAssetId = randomUUID(); const exhibitId = randomUUID()
|
const candidateAssetId = randomUUID(); const exhibitId = randomUUID()
|
||||||
const checksum = createHash('sha256').update(file.buffer).digest('hex')
|
const checksum = createHash('sha256').update(file.buffer).digest('hex')
|
||||||
const asset = await client.query<{ id: string }>(`INSERT INTO osint.assets
|
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
|
||||||
(id,original_name,mime_type,byte_size,content,checksum_sha256) VALUES ($1,$2,$3,$4,$5,$6)
|
if (!assetId) {
|
||||||
ON CONFLICT (checksum_sha256,byte_size) DO UPDATE SET checksum_sha256=EXCLUDED.checksum_sha256 RETURNING id`,
|
const objectKey = `assets/${checksum.slice(0,2)}/${checksum}`
|
||||||
[candidateAssetId, file.originalname, file.mimetype || 'application/octet-stream', file.size, file.buffer, 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'
|
const fileType: SourceFileType = file.mimetype.startsWith('image/') ? 'image' : file.mimetype === 'application/pdf' ? 'pdf' : file.mimetype.startsWith('text/') ? 'text' : 'file'
|
||||||
|
const xpos = Number.isFinite(placement?.x) ? Math.max(0, Math.min(10_000, Number(placement?.x))) : 100
|
||||||
|
const ypos = Number.isFinite(placement?.y) ? Math.max(0, Math.min(10_000, Number(placement?.y))) : 100
|
||||||
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
|
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
|
||||||
VALUES ($1,$2,'document',100,100,174,145,(SELECT COUNT(*) FROM osint.exhibits WHERE board_id=$2),FALSE)`, [exhibitId, level.board_id])
|
VALUES ($1,$2,'document',$3,$4,174,145,(SELECT COUNT(*) FROM osint.exhibits WHERE board_id=$2),FALSE)`, [exhibitId, level.board_id, xpos, ypos])
|
||||||
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)`,
|
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)`,
|
||||||
[exhibitId, fileType, asset.rows[0].id, file.originalname])
|
[exhibitId, fileType, assetId, file.originalname])
|
||||||
if (fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [exhibitId])
|
if (fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [exhibitId])
|
||||||
|
if (extraction.status === 'succeeded' && extraction.text.trim()) await client.query(
|
||||||
|
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,0,$3)', [randomUUID(), exhibitId, extraction.text.trim()])
|
||||||
|
|
||||||
|
const extractionResult = await client.query<{ id: string }>(`INSERT INTO osint.asset_text_extractions
|
||||||
|
(id,asset_id,extractor,extractor_version,language,status,extracted_text,error_message)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||||
|
ON CONFLICT (asset_id,extractor,extractor_version,language) DO UPDATE SET
|
||||||
|
status=EXCLUDED.status,extracted_text=EXCLUDED.extracted_text,error_message=EXCLUDED.error_message,updated_at=NOW()
|
||||||
|
RETURNING id`, [randomUUID(), assetId, extraction.extractor, extraction.extractorVersion, extraction.language,
|
||||||
|
extraction.status, extraction.text, extraction.error?.slice(0, 2_000) || null])
|
||||||
|
const extractionId = extractionResult.rows[0].id
|
||||||
|
const ruleDefinitions = extraction.status === 'succeeded' && extraction.text.trim()
|
||||||
|
? await evidenceMatchRules(client, level.board_id)
|
||||||
|
: []
|
||||||
|
const evaluations = evaluateEvidenceRules(extraction.text, ruleDefinitions as EvidenceMatchRule[])
|
||||||
|
const matchedFlags: string[] = []
|
||||||
|
const awardedFlags: string[] = []
|
||||||
|
for (const evaluation of evaluations) {
|
||||||
|
const evaluationId = randomUUID()
|
||||||
|
await client.query(`INSERT INTO osint.evidence_match_evaluations
|
||||||
|
(id,level_id,board_id,document_exhibit_id,extraction_id,rule_id,matched,matched_anchor_count,score)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [evaluationId, level.id, level.board_id, exhibitId, extractionId,
|
||||||
|
evaluation.ruleId, evaluation.matched, evaluation.matchedAnchorCount, evaluation.score])
|
||||||
|
for (const anchor of evaluation.anchors) await client.query(`INSERT INTO osint.evidence_match_anchor_evaluations
|
||||||
|
(evaluation_id,anchor_id,similarity,matched,matched_text) VALUES ($1,$2,$3,$4,$5)`,
|
||||||
|
[evaluationId, anchor.anchorId, anchor.similarity, anchor.matched, anchor.matchedText])
|
||||||
|
if (!evaluation.matched) continue
|
||||||
|
matchedFlags.push(evaluation.flagKey)
|
||||||
|
const awarded = await client.query(`INSERT INTO osint.level_flags (level_id,board_id,flag_key,awarded_by_evidence_match_id)
|
||||||
|
VALUES ($1,$2,$3,$4) ON CONFLICT (level_id,flag_key) DO NOTHING RETURNING flag_key`,
|
||||||
|
[level.id, level.board_id, evaluation.flagKey, evaluationId])
|
||||||
|
if (awarded.rowCount) awardedFlags.push(evaluation.flagKey)
|
||||||
|
}
|
||||||
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
||||||
await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
|
await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
|
||||||
await client.query('COMMIT')
|
await client.query('COMMIT')
|
||||||
return { id: exhibitId, title: file.originalname, kind: documentKind(fileType), fileType, metadata: {}, date: '', body: [], regions: [],
|
return { id: exhibitId,type:'document',title:file.originalname,x:xpos,y:ypos,width:174,height:145,rotation:0,zIndex:0,hidden:false,
|
||||||
assetId: asset.rows[0].id, fileName: file.originalname, mimeType: file.mimetype, fileSize: file.size }
|
fileType,metadata:{},body:extraction.status === 'succeeded' && extraction.text.trim() ? [extraction.text.trim()] : [],regions:[],assetId,
|
||||||
|
fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size,
|
||||||
|
analysis:{ extractionStatus:extraction.status, matchedFlags:[...new Set(matchedFlags)], awardedFlags:[...new Set(awardedFlags)] } }
|
||||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
},
|
},
|
||||||
|
async listFlags(levelId) {
|
||||||
|
const level = await findLevel(pool, levelId)
|
||||||
|
if (!level) return null
|
||||||
|
const result = await pool.query<{ flag_key: string; earned_at: Date | null; gated_document_count: number }>(`
|
||||||
|
WITH keys AS (
|
||||||
|
SELECT flag_key FROM osint.level_flags WHERE level_id=$1
|
||||||
|
UNION
|
||||||
|
SELECT flag_key FROM osint.document_flag_requirements WHERE board_id=$2
|
||||||
|
UNION
|
||||||
|
SELECT flag_key FROM osint.evidence_match_rules WHERE board_id=$2
|
||||||
|
)
|
||||||
|
SELECT keys.flag_key,flags.earned_at,COUNT(requirements.document_exhibit_id)::int AS gated_document_count
|
||||||
|
FROM keys
|
||||||
|
LEFT JOIN osint.level_flags flags ON flags.level_id=$1 AND flags.flag_key=keys.flag_key
|
||||||
|
LEFT JOIN osint.document_flag_requirements requirements ON requirements.board_id=$2 AND requirements.flag_key=keys.flag_key
|
||||||
|
GROUP BY keys.flag_key,flags.earned_at ORDER BY keys.flag_key`, [level.id, level.board_id])
|
||||||
|
return result.rows.map(row => ({ key: row.flag_key, earnedAt: row.earned_at?.toISOString(), gatedDocumentCount: row.gated_document_count }))
|
||||||
|
},
|
||||||
|
async setFlag(levelId, rawKey, earned) {
|
||||||
|
const key = requireFlagKey(rawKey.trim())
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
const level = await findLevel(client, levelId, true)
|
||||||
|
if (!level) { await client.query('ROLLBACK'); return false }
|
||||||
|
if (earned) await client.query(`INSERT INTO osint.level_flags (level_id,board_id,flag_key) VALUES ($1,$2,$3)
|
||||||
|
ON CONFLICT (level_id,flag_key) DO NOTHING`, [level.id, level.board_id, key])
|
||||||
|
else {
|
||||||
|
await client.query('DELETE FROM osint.level_flags WHERE level_id=$1 AND flag_key=$2', [level.id, key])
|
||||||
|
await client.query(`DELETE FROM osint.level_seen_documents seen USING osint.document_flag_requirements requirement
|
||||||
|
WHERE seen.level_id=$1 AND seen.document_exhibit_id=requirement.document_exhibit_id AND requirement.board_id=$2 AND requirement.flag_key=$3`,
|
||||||
|
[level.id, level.board_id, key])
|
||||||
|
}
|
||||||
|
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
||||||
|
await client.query('COMMIT')
|
||||||
|
return true
|
||||||
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
|
},
|
||||||
|
async listEvidenceMatchRules(levelId) {
|
||||||
|
const level = await findLevel(pool, levelId)
|
||||||
|
return level ? evidenceMatchRules(pool, level.board_id, true) : null
|
||||||
|
},
|
||||||
|
async createEvidenceMatchRule(levelId, input) {
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
const level = await findLevel(client, levelId, true)
|
||||||
|
if (!level) { await client.query('ROLLBACK'); return null }
|
||||||
|
const rule = await writeEvidenceMatchRule(client, level, randomUUID(), input, false)
|
||||||
|
await client.query('COMMIT')
|
||||||
|
return rule
|
||||||
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
|
},
|
||||||
|
async updateEvidenceMatchRule(levelId, ruleId, input) {
|
||||||
|
if (!uuidPattern.test(ruleId)) return null
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
const level = await findLevel(client, levelId, true)
|
||||||
|
if (!level) { await client.query('ROLLBACK'); return null }
|
||||||
|
const rule = await writeEvidenceMatchRule(client, level, ruleId, input, true)
|
||||||
|
await client.query('COMMIT')
|
||||||
|
return rule
|
||||||
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
|
},
|
||||||
|
async deleteEvidenceMatchRule(levelId, ruleId) {
|
||||||
|
if (!uuidPattern.test(ruleId)) return false
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
const level = await findLevel(client, levelId, true)
|
||||||
|
if (!level) { await client.query('ROLLBACK'); return null }
|
||||||
|
const removed = await client.query('DELETE FROM osint.evidence_match_rules WHERE id=$1 AND board_id=$2', [ruleId, level.board_id])
|
||||||
|
if (removed.rowCount) await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
||||||
|
await client.query('COMMIT')
|
||||||
|
return Boolean(removed.rowCount)
|
||||||
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
|
},
|
||||||
|
async acknowledgeRevealedDocuments(levelId, rawDocumentIds) {
|
||||||
|
const documentIds = [...new Set(rawDocumentIds.filter(id => uuidPattern.test(id)))]
|
||||||
|
const level = await findLevel(pool, levelId)
|
||||||
|
if (!level) return null
|
||||||
|
if (!documentIds.length) return 0
|
||||||
|
const result = await pool.query(`INSERT INTO osint.level_seen_documents (level_id,board_id,document_exhibit_id)
|
||||||
|
SELECT $1,$2,e.id FROM osint.exhibits e
|
||||||
|
WHERE e.board_id=$2 AND e.id=ANY($3::uuid[]) AND e.exhibit_type_id='document' AND NOT e.hidden
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM osint.document_flag_requirements requirement
|
||||||
|
WHERE requirement.document_exhibit_id=e.id AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM osint.level_flags flag WHERE flag.level_id=$1 AND flag.flag_key=requirement.flag_key
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ON CONFLICT (level_id,document_exhibit_id) DO NOTHING`, [level.id, level.board_id, documentIds])
|
||||||
|
return result.rowCount || 0
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { CaseState, DocumentExhibit, NoteExhibit } from '../src/types.js'
|
||||||
|
import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVisibility.js'
|
||||||
|
|
||||||
|
const placed = { x: 10, y: 20, width: 174, height: 145, rotation: 0, zIndex: 1, hidden: false }
|
||||||
|
const open: DocumentExhibit = { id: 'open', type: 'document', title: 'Open', body: [], regions: [], fileType: 'image', metadata: {}, ...placed }
|
||||||
|
const gated: DocumentExhibit = { id: 'gated', type: 'document', title: 'Gated', body: [], regions: [], fileType: 'image', metadata: {}, requiredFlags: ['tip.received'], ...placed }
|
||||||
|
const note: NoteExhibit = { id: 'note', type: 'note', title: 'Note', content: '', ...placed }
|
||||||
|
const state: CaseState = {
|
||||||
|
id: 'demo', title: 'Demo', subtitle: '', exhibits: [open, gated, note], viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
|
relations: [{ id: 'source', type: 'source', fromExhibitId: note.id, toExhibitId: gated.id, sortOrder: 0 }],
|
||||||
|
connections: [{ id: 'thread', fromExhibitId: open.id, toExhibitId: gated.id }],
|
||||||
|
views: [], brief: { body: '', concepts: [] }, revision: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('level flag visibility', () => {
|
||||||
|
it('hides gated documents and every edge touching them', () => {
|
||||||
|
const visible = filterLevelVisibility(state, [], [], false)
|
||||||
|
expect(visible.exhibits.map(item => item.id)).toEqual(['open', 'note'])
|
||||||
|
expect(visible.relations).toEqual([])
|
||||||
|
expect(visible.connections).toEqual([])
|
||||||
|
expect(visible.newlyVisibleDocumentIds).toEqual(['open'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reveals earned documents once without leaking their gate definition', () => {
|
||||||
|
const visible = filterLevelVisibility(state, ['tip.received'], ['open'], false)
|
||||||
|
expect(visible.exhibits.map(item => item.id)).toEqual(['open', 'gated', 'note'])
|
||||||
|
expect((visible.exhibits[1] as DocumentExhibit).requiredFlags).toBeUndefined()
|
||||||
|
expect(visible.newlyVisibleDocumentIds).toEqual(['gated'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns all documents and requirements to author mode', () => {
|
||||||
|
const authored = filterLevelVisibility(state, [], [], true)
|
||||||
|
expect((authored.exhibits[1] as DocumentExhibit).requiredFlags).toEqual(['tip.received'])
|
||||||
|
expect(authored.newlyVisibleDocumentIds).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves unrevealed documents and their edges during a play-mode save', () => {
|
||||||
|
const visible = filterLevelVisibility(state, [], ['open'], false)
|
||||||
|
const submitted = { ...visible, exhibits: visible.exhibits.map(item => item.id === 'open' ? { ...item, x: 99 } : item) }
|
||||||
|
const merged = mergePlayerStateForPersistence(state, visible, submitted)
|
||||||
|
expect(merged.exhibits.find(item => item.id === 'open')?.x).toBe(99)
|
||||||
|
expect(merged.exhibits.find(item => item.id === 'gated')).toMatchObject({ requiredFlags: ['tip.received'] })
|
||||||
|
expect(merged.relations).toHaveLength(1)
|
||||||
|
expect(merged.connections).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { CaseState, DocumentExhibit, ExhibitRelation, Connection } from '../src/types.js'
|
||||||
|
|
||||||
|
function requirementsMet(document: DocumentExhibit, earnedFlags: ReadonlySet<string>) {
|
||||||
|
return (document.requiredFlags || []).every(flag => earnedFlags.has(flag))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterLevelVisibility(full: CaseState, earnedFlags: Iterable<string>, seenDocumentIds: Iterable<string>, authorMode: boolean): CaseState {
|
||||||
|
if (authorMode) return { ...full, newlyVisibleDocumentIds: [] }
|
||||||
|
const earned = new Set(earnedFlags)
|
||||||
|
const seen = new Set(seenDocumentIds)
|
||||||
|
const visibleExhibits = full.exhibits.filter(exhibit => !exhibit.hidden && (exhibit.type !== 'document' || requirementsMet(exhibit, earned)))
|
||||||
|
const visibleIds = new Set(visibleExhibits.map(exhibit => exhibit.id))
|
||||||
|
const sanitize = (exhibit: typeof visibleExhibits[number]) => {
|
||||||
|
if (exhibit.type !== 'document') return exhibit
|
||||||
|
const { requiredFlags: _requirements, ...document } = exhibit
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...full,
|
||||||
|
exhibits: visibleExhibits.map(sanitize),
|
||||||
|
relations: full.relations.filter(relation => visibleIds.has(relation.fromExhibitId) && visibleIds.has(relation.toExhibitId)),
|
||||||
|
connections: full.connections.filter(connection => visibleIds.has(connection.fromExhibitId) && visibleIds.has(connection.toExhibitId)),
|
||||||
|
newlyVisibleDocumentIds: visibleExhibits.flatMap(exhibit => exhibit.type === 'document' && !seen.has(exhibit.id) ? [exhibit.id] : []),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMissingById<T extends { id: string }>(submitted: T[], preserved: T[]) {
|
||||||
|
const ids = new Set(submitted.map(item => item.id))
|
||||||
|
return [...submitted, ...preserved.filter(item => !ids.has(item.id))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preserve server-hidden objects during the legacy whole-board PUT used by play mode. */
|
||||||
|
export function mergePlayerStateForPersistence(full: CaseState, visible: CaseState, submitted: CaseState): CaseState {
|
||||||
|
const visibleIds = new Set(visible.exhibits.map(exhibit => exhibit.id))
|
||||||
|
const unavailableIds = new Set(full.exhibits.filter(exhibit => !visibleIds.has(exhibit.id)).map(exhibit => exhibit.id))
|
||||||
|
const fullDocuments = new Map(full.exhibits.flatMap(exhibit => exhibit.type === 'document' ? [[exhibit.id, exhibit] as const] : []))
|
||||||
|
const submittedExhibits = submitted.exhibits.map(exhibit => exhibit.type === 'document'
|
||||||
|
? { ...exhibit, requiredFlags: fullDocuments.get(exhibit.id)?.requiredFlags || exhibit.requiredFlags || [] }
|
||||||
|
: exhibit)
|
||||||
|
const preservedExhibits = full.exhibits.filter(exhibit => unavailableIds.has(exhibit.id))
|
||||||
|
const touchesUnavailable = (item: ExhibitRelation | Connection) => unavailableIds.has(item.fromExhibitId) || unavailableIds.has(item.toExhibitId)
|
||||||
|
return {
|
||||||
|
...submitted,
|
||||||
|
exhibits: appendMissingById(submittedExhibits, preservedExhibits),
|
||||||
|
relations: appendMissingById(submitted.relations, full.relations.filter(touchesUnavailable)),
|
||||||
|
connections: appendMissingById(submitted.connections, full.connections.filter(touchesUnavailable)),
|
||||||
|
newlyVisibleDocumentIds: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import pg from 'pg'
|
import pg from 'pg'
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
@@ -31,9 +32,10 @@ suite('PostgreSQL migrations', () => {
|
|||||||
|
|
||||||
it('applies every migration transactionally and is idempotent', async () => {
|
it('applies every migration transactionally and is idempotent', async () => {
|
||||||
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
||||||
|
const migrationCount = (await fs.readdir(migrationsDir)).filter(name => /^\d+.*\.sql$/.test(name)).length
|
||||||
const firstRun: string[] = []
|
const firstRun: string[] = []
|
||||||
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
|
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
|
||||||
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(12)
|
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(migrationCount)
|
||||||
|
|
||||||
const client = new Client({ connectionString: testDatabaseUrl })
|
const client = new Client({ connectionString: testDatabaseUrl })
|
||||||
await client.connect()
|
await client.connect()
|
||||||
@@ -43,11 +45,16 @@ suite('PostgreSQL migrations', () => {
|
|||||||
'boards', 'levels', 'level_templates', 'level_template_versions', 'exhibits', 'folder_exhibits',
|
'boards', 'levels', 'level_templates', 'level_template_versions', 'exhibits', 'folder_exhibits',
|
||||||
'document_exhibits', 'folder_memberships', 'exhibit_connections', 'metadata_fields', 'assets', 'schema_migrations',
|
'document_exhibits', 'folder_memberships', 'exhibit_connections', 'metadata_fields', 'assets', 'schema_migrations',
|
||||||
'party_exhibits', 'person_parties', 'organization_parties', 'brief_concepts', 'level_briefs',
|
'party_exhibits', 'person_parties', 'organization_parties', 'brief_concepts', 'level_briefs',
|
||||||
'board_timeline_settings',
|
'board_views', 'timeline_views',
|
||||||
|
'mysteries', 'npcs', 'npc_poses', 'playthroughs',
|
||||||
|
'story_nodes', 'story_node_terminals', 'utterances',
|
||||||
|
'level_flags', 'document_flag_requirements', 'level_seen_documents', 'achievements',
|
||||||
|
'asset_text_extractions', 'evidence_match_rules', 'evidence_match_anchors',
|
||||||
|
'evidence_match_evaluations', 'evidence_match_anchor_evaluations',
|
||||||
]))
|
]))
|
||||||
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'playthroughs']))
|
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue']))
|
||||||
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
|
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
|
||||||
expect(ledger.rows[0].count).toBe('12')
|
expect(ledger.rows[0].count).toBe(String(migrationCount))
|
||||||
const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`)
|
const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`)
|
||||||
expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset']))
|
expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset']))
|
||||||
const eventOccurrence = await client.query<{ is_nullable: string }>(`SELECT is_nullable FROM information_schema.columns WHERE table_schema='osint' AND table_name='event_exhibits' AND column_name='occurred_at'`)
|
const eventOccurrence = await client.query<{ is_nullable: string }>(`SELECT is_nullable FROM information_schema.columns WHERE table_schema='osint' AND table_name='event_exhibits' AND column_name='occurred_at'`)
|
||||||
@@ -56,7 +63,7 @@ suite('PostgreSQL migrations', () => {
|
|||||||
|
|
||||||
const secondRun: string[] = []
|
const secondRun: string[] = []
|
||||||
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
|
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
|
||||||
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(12)
|
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(migrationCount)
|
||||||
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
|
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
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 AssetDto = { id: string; originalName: string; mimeType: string; byteSize: number; url: string }
|
||||||
|
export type PoseDto = { poseKey: string; assetId: string; url: string }
|
||||||
|
export type NpcDto = { id: string; key: string; name: string; role: string; defaultPose: string | null; poses: PoseDto[]; inUse: boolean }
|
||||||
|
export type 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; musicUrl?: string | null; musicVolume?: number
|
||||||
|
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 }>
|
||||||
|
resolveDialogue(nodeId: string): Promise<{ utterances: RuntimeUtterance[]; rootId: string | null }>
|
||||||
|
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 }>
|
||||||
|
listAchievements(playthroughId: string): Promise<string[] | null>
|
||||||
|
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }>
|
||||||
|
listMysteries(): Promise<MysterySummary[]>
|
||||||
|
deleteMystery(id: string): Promise<boolean>
|
||||||
|
uploadAsset(file: UploadedFile): Promise<AssetDto>
|
||||||
|
listAssets(): Promise<AssetDto[]>
|
||||||
|
deleteAsset(id: string): Promise<'deleted' | 'in_use' | 'not_found'>
|
||||||
|
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; music_asset_id: string | null; music_volume: number }
|
||||||
|
|
||||||
|
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,music_asset_id,music_volume FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0]
|
||||||
|
if (!node) return null
|
||||||
|
const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null
|
||||||
|
const musicVolume = node.music_volume / 100
|
||||||
|
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key, musicUrl, musicVolume }
|
||||||
|
if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug, musicUrl, musicVolume }
|
||||||
|
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, musicVolume, ...(await resolveDialogueGraph(node.id)) }
|
||||||
|
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 }
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveDialogue(nodeId) { return resolveDialogueGraph(nodeId) },
|
||||||
|
|
||||||
|
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 listAchievements(playthroughId) {
|
||||||
|
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
|
||||||
|
const rows = (await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1 ORDER BY flag_key', [playthroughId])).rows
|
||||||
|
return rows.map(row => row.flag_key)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Grant an achievement (idempotent). `earned` is true only on the first grant.
|
||||||
|
// The eventual server-side rule engine calls this same operation.
|
||||||
|
async awardAchievement(playthroughId, rawKey, nodeId) {
|
||||||
|
const key = rawKey.trim()
|
||||||
|
if (!/^[a-z][a-z0-9_.-]{0,63}$/.test(key)) return { ok: false, earned: false, error: 'Invalid achievement key' }
|
||||||
|
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return { ok: false, earned: false, error: 'Playthrough not found' }
|
||||||
|
const result = await pool.query(
|
||||||
|
'INSERT INTO osint.achievements (playthrough_id,flag_key,awarded_by_node_id) VALUES ($1,$2,$3) ON CONFLICT (playthrough_id,flag_key) DO NOTHING',
|
||||||
|
[playthroughId, key, nodeId || null])
|
||||||
|
return { ok: true, earned: (result.rowCount || 0) > 0 }
|
||||||
|
},
|
||||||
|
|
||||||
|
async advancePlaythrough(userId, playthroughId, terminalKey) {
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
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 deleteMystery(id) {
|
||||||
|
const result = await pool.query('DELETE FROM osint.mysteries WHERE id=$1', [id])
|
||||||
|
return (result.rowCount ?? 0) > 0
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadAsset(file) {
|
||||||
|
const id = await storeAsset(file)
|
||||||
|
const row = (await pool.query<{ original_name: string; mime_type: string; byte_size: string }>(
|
||||||
|
'SELECT original_name,mime_type,byte_size FROM osint.assets WHERE id=$1', [id])).rows[0]
|
||||||
|
return { id, originalName: row.original_name, mimeType: row.mime_type, byteSize: Number(row.byte_size), url: `/api/assets/${id}` }
|
||||||
|
},
|
||||||
|
|
||||||
|
async listAssets() {
|
||||||
|
const result = await pool.query<{ id: string; original_name: string; mime_type: string; byte_size: string }>(
|
||||||
|
'SELECT id,original_name,mime_type,byte_size FROM osint.assets ORDER BY created_at DESC')
|
||||||
|
return result.rows.map(row => ({ id: row.id, originalName: row.original_name, mimeType: row.mime_type, byteSize: Number(row.byte_size), url: `/api/assets/${row.id}` }))
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAsset(id) {
|
||||||
|
// document_exhibits.asset_id is ON DELETE RESTRICT, so an in-use asset raises a
|
||||||
|
// foreign-key violation (23503) rather than deleting.
|
||||||
|
try {
|
||||||
|
const result = await pool.query('DELETE FROM osint.assets WHERE id=$1', [id])
|
||||||
|
return (result.rowCount ?? 0) > 0 ? 'deleted' : 'not_found'
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { code?: string }).code === '23503') return 'in_use'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
|
||||||
|
export type TextExtractionResult = {
|
||||||
|
extractor: string
|
||||||
|
extractorVersion: string
|
||||||
|
language: string
|
||||||
|
status: 'succeeded' | 'unsupported' | 'failed'
|
||||||
|
text: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextExtractor {
|
||||||
|
provider: string
|
||||||
|
extract(file: { buffer: Buffer; mimetype: string }): Promise<TextExtractionResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainText(file: { buffer: Buffer; mimetype: string }, maximumCharacters: number): TextExtractionResult | null {
|
||||||
|
if (!file.mimetype.startsWith('text/')) return null
|
||||||
|
return { extractor: 'plain-text', extractorVersion: '1', language: 'und', status: 'succeeded', text: file.buffer.toString('utf8').slice(0, maximumCharacters) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTesseract(command: string, buffer: Buffer, languages: string, pageSegmentationMode: string, timeoutMs: number) {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const child = spawn(command, ['stdin', 'stdout', '-l', languages, '--psm', pageSegmentationMode], { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||||
|
const stdout: Buffer[] = []
|
||||||
|
const stderr: Buffer[] = []
|
||||||
|
let settled = false
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
child.kill('SIGKILL')
|
||||||
|
reject(new Error(`OCR timed out after ${timeoutMs}ms`))
|
||||||
|
}, timeoutMs)
|
||||||
|
child.stdout.on('data', chunk => stdout.push(Buffer.from(chunk)))
|
||||||
|
child.stderr.on('data', chunk => stderr.push(Buffer.from(chunk)))
|
||||||
|
child.once('error', error => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
child.once('close', code => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (code === 0) resolve(Buffer.concat(stdout).toString('utf8').trim())
|
||||||
|
else reject(new Error(Buffer.concat(stderr).toString('utf8').trim() || `OCR exited with status ${code}`))
|
||||||
|
})
|
||||||
|
child.stdin.end(buffer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTextExtractorFromEnv(): TextExtractor {
|
||||||
|
const enabled = process.env.OCR_ENABLED !== 'false'
|
||||||
|
const command = process.env.OCR_COMMAND || 'tesseract'
|
||||||
|
const languages = process.env.OCR_LANGUAGES || 'nor+eng'
|
||||||
|
const extractorVersion = process.env.OCR_ENGINE_VERSION || 'tesseract-cli-5'
|
||||||
|
const pageSegmentationMode = process.env.OCR_PAGE_SEGMENTATION_MODE || '3'
|
||||||
|
const timeoutMs = Math.max(1_000, Number(process.env.OCR_TIMEOUT_MS || 20_000))
|
||||||
|
const maximumBytes = Math.max(1, Number(process.env.MAX_OCR_BYTES || 15 * 1024 * 1024))
|
||||||
|
const maximumCharacters = Math.max(1_000, Number(process.env.MAX_EXTRACTED_TEXT_CHARACTERS || 200_000))
|
||||||
|
return {
|
||||||
|
provider: enabled ? 'tesseract' : 'disabled',
|
||||||
|
async extract(file) {
|
||||||
|
const direct = plainText(file, maximumCharacters)
|
||||||
|
if (direct) return direct
|
||||||
|
if (!file.mimetype.startsWith('image/')) return { extractor: 'none', extractorVersion: '1', language: 'und', status: 'unsupported', text: '' }
|
||||||
|
if (!enabled) return { extractor: 'tesseract', extractorVersion, language: languages, status: 'unsupported', text: '' }
|
||||||
|
if (file.buffer.byteLength > maximumBytes) return { extractor: 'tesseract', extractorVersion, language: languages, status: 'failed', text: '', error: `Image exceeds the ${maximumBytes}-byte OCR limit` }
|
||||||
|
try {
|
||||||
|
const text = (await runTesseract(command, file.buffer, languages, pageSegmentationMode, timeoutMs)).slice(0, maximumCharacters)
|
||||||
|
return { extractor: 'tesseract', extractorVersion, language: languages, status: 'succeeded', text }
|
||||||
|
} catch (error) {
|
||||||
|
return { extractor: 'tesseract', extractorVersion, language: languages, status: 'failed', text: '', error: error instanceof Error ? error.message : 'OCR failed' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
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; musicAssetId: string | null; musicVolume: number
|
||||||
|
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; musicAssetId: string | null; musicVolume: number }>): 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; music_asset_id: string | null; music_volume: number }>(
|
||||||
|
'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key,music_asset_id,music_volume FROM osint.story_nodes WHERE mystery_id=$1 ORDER BY created_at', [mysteryId]),
|
||||||
|
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, musicAssetId: row.music_asset_id, musicVolume: row.music_volume,
|
||||||
|
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 (input.musicAssetId !== undefined) set('music_asset_id', input.musicAssetId || null)
|
||||||
|
if (input.musicVolume !== undefined) set('music_volume', Math.max(0, Math.min(100, Math.round(input.musicVolume))))
|
||||||
|
if (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,
|
||||||
|
}
|
||||||
|
}
|
||||||
+404
-194
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||||
import type { BriefConcept, CaseDocument, CaseState, Connection, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types'
|
import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||||||
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, relationPosition, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
import { AdminPanel } from './admin'
|
||||||
import { documentWidget, exhibitWidget } from './exhibitRegistry'
|
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
||||||
|
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
||||||
|
|
||||||
const BOARD_W = 2400
|
const BOARD_W = 2400
|
||||||
const BOARD_H = 1500
|
const BOARD_H = 1500
|
||||||
@@ -11,17 +12,31 @@ const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
|
|||||||
].map(value => ({ value: value as SourceFileType, label: documentWidget(value as SourceFileType).label }))
|
].map(value => ({ value: value as SourceFileType, label: documentWidget(value as SourceFileType).label }))
|
||||||
|
|
||||||
function uid(_prefix: string) { return crypto.randomUUID() }
|
function uid(_prefix: string) { return crypto.randomUUID() }
|
||||||
|
function screenshotFile(file: File, index: number) {
|
||||||
|
const extension = file.type === 'image/jpeg' ? 'jpg' : file.type === 'image/webp' ? 'webp' : 'png'
|
||||||
|
const timestamp = new Date().toISOString().replace('T', ' ').replace(/:/g, '.').slice(0, 19)
|
||||||
|
return new File([file], `Screenshot ${timestamp}${index ? ` ${index + 1}` : ''}.${extension}`, { type: file.type || 'image/png', lastModified: Date.now() })
|
||||||
|
}
|
||||||
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
|
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
|
||||||
function documentSearchText(document: CaseDocument) {
|
function documentSearchText(document: CaseDocument) {
|
||||||
return [document.title, document.kind, document.date, document.publishedAt, document.fileName, document.mimeType,
|
return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.fileName, document.mimeType,
|
||||||
...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]),
|
...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]),
|
||||||
...Object.entries(document.metadata).flatMap(([key, value]) => [key, value])].filter(Boolean).join('\n').toLocaleLowerCase()
|
...Object.entries(document.metadata).flatMap(([key, value]) => [key, value])].filter(Boolean).join('\n').toLocaleLowerCase()
|
||||||
}
|
}
|
||||||
function connectionPoint(item: Evidence) {
|
function connectionPoint(item: Exhibit) {
|
||||||
return exhibitWidget(item.type).connectionPoint(item)
|
return exhibitWidget(item.type).connectionPorts(item)[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
type TemporalItem = { id: string; sourceTemporalId: string; date: string; label: string; kind: 'document' | 'widget'; evidenceId?: string; documentId?: string }
|
type TemporalItem = { id: string; sourceTemporalId: string; date: string; label: string; kind: 'document' | 'widget'; exhibitId: string }
|
||||||
|
|
||||||
|
const placement = (x: number, y: number, width: number, height: number) => ({ x, y, width, height, rotation: 0, zIndex: 1, hidden: false })
|
||||||
|
function replaceDirectedRelations(relations: ExhibitRelation[], type: 'supports' | 'concerns', fromExhibitId: string, targets: string[]) {
|
||||||
|
const retained = relations.filter(relation => relation.type !== type || relation.fromExhibitId !== fromExhibitId)
|
||||||
|
return [...retained, ...targets.map((toExhibitId, sortOrder): ExhibitRelation => ({
|
||||||
|
id: relations.find(relation => relation.type === type && relation.fromExhibitId === fromExhibitId && relation.toExhibitId === toExhibitId)?.id || uid(type),
|
||||||
|
type, fromExhibitId, toExhibitId, sortOrder,
|
||||||
|
}))]
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [caseState, setCaseState] = useState<CaseState | null>(null)
|
const [caseState, setCaseState] = useState<CaseState | null>(null)
|
||||||
@@ -42,47 +57,74 @@ export function App() {
|
|||||||
const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
|
const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
|
||||||
const [editingFileId, setEditingFileId] = useState<string | null>(null)
|
const [editingFileId, setEditingFileId] = useState<string | null>(null)
|
||||||
const [editingEventId, setEditingEventId] = useState<string | null>(null)
|
const [editingEventId, setEditingEventId] = useState<string | null>(null)
|
||||||
const [newEventDraft, setNewEventDraft] = useState<Evidence | null>(null)
|
const [newEventDraft, setNewEventDraft] = useState<EventExhibit | null>(null)
|
||||||
const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
|
const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
|
||||||
const [newPartyDraft, setNewPartyDraft] = useState<Evidence | null>(null)
|
const [newPartyDraft, setNewPartyDraft] = useState<PartyExhibit | null>(null)
|
||||||
const [briefOpen, setBriefOpen] = useState(false)
|
const [briefOpen, setBriefOpen] = useState(false)
|
||||||
const [editingBrief, setEditingBrief] = useState(false)
|
const [editingBrief, setEditingBrief] = useState(false)
|
||||||
const [editingTimeline, setEditingTimeline] = useState(false)
|
const [editingTimeline, setEditingTimeline] = useState(false)
|
||||||
const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
|
const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
|
||||||
const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null)
|
const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null)
|
||||||
const [threadDraft, setThreadDraft] = useState<Connection | null>(null)
|
const [threadDraft, setThreadDraft] = useState<Connection | null>(null)
|
||||||
|
const [flagsOpen, setFlagsOpen] = useState(false)
|
||||||
|
const [matchRulesOpen, setMatchRulesOpen] = useState(false)
|
||||||
|
const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
|
||||||
const saveTimer = useRef<number | undefined>(undefined)
|
const saveTimer = useRef<number | undefined>(undefined)
|
||||||
const boardRef = useRef<HTMLDivElement>(null)
|
const boardRef = useRef<HTMLDivElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const adminMenuRef = useRef<HTMLDivElement>(null)
|
const adminMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const requestedEditMode = new URLSearchParams(window.location.search).get('edit') === '1'
|
const requestedEditMode = new URLSearchParams(window.location.search).get('edit') === '1'
|
||||||
|
const adminRoute = window.location.pathname === '/admin'
|
||||||
|
|
||||||
|
const loadLevelBySlug = useCallback(async (slug: string, editQuery = '') => {
|
||||||
|
const response = await fetch(`/api/levels/${encodeURIComponent(slug)}${editQuery}`)
|
||||||
|
if (!response.ok) throw new Error('Level unavailable')
|
||||||
|
const data = normalizeCase(await response.json())
|
||||||
|
setCaseState(data)
|
||||||
|
const arrivals = data.newlyVisibleDocumentIds || []
|
||||||
|
if (arrivals.length) {
|
||||||
|
setArrivingExhibitIds(arrivals)
|
||||||
|
void fetch(`/api/levels/${encodeURIComponent(data.id)}/reveals/seen`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: arrivals }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (adminRoute) return
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
|
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
|
||||||
fetch('/api/levels').then(r => {
|
|
||||||
if (!r.ok) throw new Error('Server unavailable')
|
const deepLinkLevel = params.get('level')
|
||||||
return r.json()
|
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
|
||||||
}).then(async (levels: { id: string }[]) => {
|
const openLevel = async (slug: string) => {
|
||||||
const levelId = params.get('level') || levels[0]?.id
|
const data = await loadLevelBySlug(slug, editQuery)
|
||||||
if (!levelId) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return }
|
|
||||||
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
|
|
||||||
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}${editQuery}`)
|
|
||||||
if (!response.ok) throw new Error('Level unavailable')
|
|
||||||
const data = normalizeCase(await response.json())
|
|
||||||
setCaseState(data)
|
|
||||||
if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
|
if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
|
||||||
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
|
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
|
||||||
})
|
}
|
||||||
.catch(() => {
|
const boot = async () => {
|
||||||
|
if (deepLinkLevel) { await openLevel(deepLinkLevel); return }
|
||||||
|
const levels = await (await fetch('/api/levels')).json() as { id: string }[]
|
||||||
|
if (!levels[0]?.id) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return }
|
||||||
|
await openLevel(levels[0].id)
|
||||||
|
}
|
||||||
|
boot().catch(async () => {
|
||||||
|
try {
|
||||||
|
const levels = await (await fetch('/api/levels')).json() as { id: string }[]
|
||||||
|
if (!levels[0]?.id) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return }
|
||||||
|
await openLevel(levels[0].id)
|
||||||
|
} catch {
|
||||||
const cached = localStorage.getItem('gupi-osint-board:last')
|
const cached = localStorage.getItem('gupi-osint-board:last')
|
||||||
if (cached) setCaseState(normalizeCase(JSON.parse(cached)))
|
if (cached) setCaseState(normalizeCase(JSON.parse(cached)))
|
||||||
setStatus(cached ? 'OFFLINE · LOCAL COPY' : 'SERVER UNAVAILABLE')
|
setStatus(cached ? 'OFFLINE · LOCAL COPY' : 'SERVER UNAVAILABLE')
|
||||||
})
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const tick = () => setClock(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))
|
const tick = () => setClock(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))
|
||||||
tick(); const timer = window.setInterval(tick, 30000)
|
tick(); const timer = window.setInterval(tick, 30000)
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [])
|
}, [loadLevelBySlug, adminRoute])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!adminMenuOpen) return
|
if (!adminMenuOpen) return
|
||||||
@@ -103,6 +145,12 @@ export function App() {
|
|||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [recentlyCreatedConnectionId])
|
}, [recentlyCreatedConnectionId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!arrivingExhibitIds.length) return
|
||||||
|
const timer = window.setTimeout(() => setArrivingExhibitIds([]), 1800)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [arrivingExhibitIds])
|
||||||
|
|
||||||
const update = useCallback((fn: (state: CaseState) => CaseState) => {
|
const update = useCallback((fn: (state: CaseState) => CaseState) => {
|
||||||
setCaseState(current => {
|
setCaseState(current => {
|
||||||
if (!current) return current
|
if (!current) return current
|
||||||
@@ -121,7 +169,7 @@ export function App() {
|
|||||||
|
|
||||||
const focusEvidence = (id: string) => {
|
const focusEvidence = (id: string) => {
|
||||||
if (!caseState) return
|
if (!caseState) return
|
||||||
const ev = caseState.evidence.find(e => e.id === id)
|
const ev = caseState.exhibits.find(e => e.id === id)
|
||||||
if (!ev) return
|
if (!ev) return
|
||||||
setSelected(id)
|
setSelected(id)
|
||||||
update(s => ({ ...s, viewport: { ...s.viewport, x: 500 - ev.x * s.viewport.zoom, y: 260 - ev.y * s.viewport.zoom } }))
|
update(s => ({ ...s, viewport: { ...s.viewport, x: 500 - ev.x * s.viewport.zoom, y: 260 - ev.y * s.viewport.zoom } }))
|
||||||
@@ -130,14 +178,16 @@ export function App() {
|
|||||||
const extract = (doc: CaseDocument, regionId: string) => {
|
const extract = (doc: CaseDocument, regionId: string) => {
|
||||||
if (!caseState) return
|
if (!caseState) return
|
||||||
const region = doc.regions.find(r => r.id === regionId)!
|
const region = doc.regions.find(r => r.id === regionId)!
|
||||||
const existing = caseState.evidence.find(e => e.sourceDocumentId === doc.id && e.sourceRegionId === regionId)
|
const existingSource = caseState.relations.find(relation => relation.type === 'source' && relation.toExhibitId === doc.id && relation.sourceRegionId === regionId)
|
||||||
if (existing) { setOpenDoc(null); focusEvidence(existing.id); return }
|
if (existingSource) { setOpenDoc(null); focusEvidence(existingSource.fromExhibitId); return }
|
||||||
const ev: Evidence = {
|
const ev: FolderExhibit = {
|
||||||
id: uid('folder'), type: 'folder', title: `${doc.kind} EVIDENCE`, content: region.excerpt, config: { open: false },
|
id: uid('folder'), type: 'folder', title: `${documentWidget(doc.fileType).label.toUpperCase()} EVIDENCE`, content: region.excerpt, isOpen: false,
|
||||||
sourceDocumentId: doc.id, sourceRegionId: region.id, containedDocumentIds: [doc.id],
|
...placement(850 + Math.random() * 220, 390 + Math.random() * 250, 260, 166),
|
||||||
x: 850 + Math.random() * 220, y: 390 + Math.random() * 250, width: 260,
|
|
||||||
}
|
}
|
||||||
update(s => ({ ...s, evidence: [...s.evidence, ev], relations: [...s.relations, { id: `contains:${ev.id}:${doc.id}`, fromWidgetId: ev.id, toWidgetId: doc.id, type: 'contains', sortOrder: 0 }] }))
|
update(s => ({ ...s, exhibits: [...s.exhibits, ev], relations: [...s.relations,
|
||||||
|
{ id: uid('contains'), fromExhibitId: ev.id, toExhibitId: doc.id, type: 'contains', sortOrder: 0 },
|
||||||
|
{ id: uid('source'), fromExhibitId: ev.id, toExhibitId: doc.id, type: 'source', sortOrder: 0, sourceRegionId: region.id },
|
||||||
|
] }))
|
||||||
setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
|
setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,27 +195,27 @@ export function App() {
|
|||||||
const content = window.prompt('What do you think this evidence means?')?.trim()
|
const content = window.prompt('What do you think this evidence means?')?.trim()
|
||||||
if (!content || !caseState) return
|
if (!content || !caseState) return
|
||||||
const { viewport } = caseState
|
const { viewport } = caseState
|
||||||
const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
|
const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
|
||||||
const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...position, width: 108 }
|
const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...placement(position.x, position.y, 108, 154) }
|
||||||
update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id)
|
update(s => ({ ...s, exhibits: [...s.exhibits, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addEvent = () => {
|
const addEvent = () => {
|
||||||
if (!caseState) return
|
if (!caseState) return
|
||||||
const { viewport } = caseState
|
const { viewport } = caseState
|
||||||
const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (620 - viewport.x) / viewport.zoom), y: Math.max(100, (290 - viewport.y) / viewport.zoom) }, { width: 270 })
|
const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (620 - viewport.x) / viewport.zoom), y: Math.max(100, (290 - viewport.y) / viewport.zoom) }, { width: 270 })
|
||||||
const event: Evidence = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.',
|
const event: EventExhibit = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.',
|
||||||
supportingEvidenceIds: [], ...position, width: 270 }
|
...placement(position.x, position.y, 270, 174) }
|
||||||
setNewEventDraft(event)
|
setNewEventDraft(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addParty = () => {
|
const addParty = () => {
|
||||||
if (!caseState) return
|
if (!caseState) return
|
||||||
const { viewport } = caseState
|
const { viewport } = caseState
|
||||||
const position = nextOpenBoardPosition(caseState.evidence, {
|
const position = nextOpenBoardPosition(caseState.exhibits, {
|
||||||
x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom),
|
x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom),
|
||||||
}, { width: 280 })
|
}, { width: 280 })
|
||||||
setNewPartyDraft({ id: uid('party'), type: 'party', partyKind: 'person', title: '', content: '', aliases: [], relatedEvidenceIds: [], ...position, width: 280 })
|
setNewPartyDraft({ id: uid('party'), type: 'party', partyKind: 'person', title: '', content: '', aliases: [], ...placement(position.x, position.y, 280, 190) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const classifyConcept = (conceptId: string, partyKind: PartyKind) => {
|
const classifyConcept = (conceptId: string, partyKind: PartyKind) => {
|
||||||
@@ -175,15 +225,15 @@ export function App() {
|
|||||||
const existingId = concept.resolvedPartyExhibitId
|
const existingId = concept.resolvedPartyExhibitId
|
||||||
const partyId = existingId || uid('party')
|
const partyId = existingId || uid('party')
|
||||||
const { viewport } = caseState
|
const { viewport } = caseState
|
||||||
const existingParty = caseState.evidence.find(item => item.id === existingId)
|
const existingParty = caseState.exhibits.find((item): item is PartyExhibit => item.id === existingId && item.type === 'party')
|
||||||
const position = existingParty ? { x: existingParty.x, y: existingParty.y } : nextOpenBoardPosition(caseState.evidence, {
|
const position = existingParty ? { x: existingParty.x, y: existingParty.y } : nextOpenBoardPosition(caseState.exhibits, {
|
||||||
x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom),
|
x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom),
|
||||||
}, { width: 280 })
|
}, { width: 280 })
|
||||||
const party: Evidence = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined,
|
const party: PartyExhibit = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined,
|
||||||
title: concept.label, content: concept.context, aliases: [], relatedEvidenceIds: [],
|
title: concept.label, content: concept.context, aliases: [],
|
||||||
...position, width: 280 }
|
...placement(position.x, position.y, 280, 190) }
|
||||||
update(state => ({ ...state,
|
update(state => ({ ...state,
|
||||||
evidence: existingId ? state.evidence.map(item => item.id === existingId ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.evidence, party],
|
exhibits: existingId ? state.exhibits.map(item => item.id === existingId && item.type === 'party' ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.exhibits, party],
|
||||||
brief: { ...state.brief, concepts: state.brief.concepts.map(item => item.id === conceptId ? { ...item, resolvedPartyExhibitId: partyId } : item) },
|
brief: { ...state.brief, concepts: state.brief.concepts.map(item => item.id === conceptId ? { ...item, resolvedPartyExhibitId: partyId } : item) },
|
||||||
}))
|
}))
|
||||||
setSelected(partyId)
|
setSelected(partyId)
|
||||||
@@ -203,14 +253,14 @@ export function App() {
|
|||||||
|
|
||||||
const completeThread = (targetId: string) => {
|
const completeThread = (targetId: string) => {
|
||||||
if (!linkFrom || !caseState || linkFrom === targetId) return
|
if (!linkFrom || !caseState || linkFrom === targetId) return
|
||||||
const existing = caseState.connections.find(connection => (connection.fromEvidenceId === linkFrom && connection.toEvidenceId === targetId) || (connection.fromEvidenceId === targetId && connection.toEvidenceId === linkFrom))
|
const existing = caseState.connections.find(connection => (connection.fromExhibitId === linkFrom && connection.toExhibitId === targetId) || (connection.fromExhibitId === targetId && connection.toExhibitId === linkFrom))
|
||||||
if (existing) {
|
if (existing) {
|
||||||
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
|
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setThreadDraft({ id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: targetId, tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
|
setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId, tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
|
||||||
setLinkFrom(null)
|
setLinkFrom(null)
|
||||||
if (caseState.evidence.some(item => item.id === targetId)) setSelected(targetId)
|
if (caseState.exhibits.some(item => item.id === targetId)) setSelected(targetId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveThread = (connection: Connection) => {
|
const saveThread = (connection: Connection) => {
|
||||||
@@ -294,50 +344,87 @@ export function App() {
|
|||||||
window.location.assign(`${window.location.pathname}?${params.toString()}`)
|
window.location.assign(`${window.location.pathname}?${params.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadFiles = async (files: FileList | File[]) => {
|
const uploadFiles = useCallback(async (files: FileList | File[], source: 'file' | 'clipboard' = 'file') => {
|
||||||
if (!caseState || !requestedEditMode || !caseState.editingAllowed) return
|
if (!caseState) return
|
||||||
const queue = Array.from(files)
|
const queue = Array.from(files)
|
||||||
setUploading(queue.length)
|
setUploading(queue.length)
|
||||||
setDraggingFiles(false)
|
setDraggingFiles(false)
|
||||||
for (const file of queue) {
|
for (const [queueIndex, file] of queue.entries()) {
|
||||||
|
const position = nextOpenBoardPosition(caseState.exhibits, {
|
||||||
|
x: Math.max(100, (520 - caseState.viewport.x) / caseState.viewport.zoom) + queueIndex * 24,
|
||||||
|
y: Math.max(100, (310 - caseState.viewport.y) / caseState.viewport.zoom) + queueIndex * 24,
|
||||||
|
}, { width: 174, height: 145 })
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
|
form.append('x', String(position.x))
|
||||||
|
form.append('y', String(position.y))
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents?edit=1`, { method: 'POST', body: form })
|
const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents`, { method: 'POST', body: form })
|
||||||
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
|
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
|
||||||
const document: CaseDocument = await response.json()
|
const uploaded: UploadedCaseDocument = await response.json()
|
||||||
update(s => ({ ...s, documents: [...s.documents, document] }))
|
const { analysis, ...document } = uploaded
|
||||||
setStatus(`IMPORTED · ${file.name.toUpperCase()}`)
|
update(s => {
|
||||||
|
return { ...s, exhibits: [...s.exhibits, { ...document, ...position }] }
|
||||||
|
})
|
||||||
|
setSelected(document.id)
|
||||||
|
setArrivingExhibitIds(current => [...new Set([...current, document.id])])
|
||||||
|
void fetch(`/api/levels/${encodeURIComponent(caseState.id)}/reveals/seen`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: [document.id] }),
|
||||||
|
})
|
||||||
|
if (analysis.awardedFlags.length) {
|
||||||
|
window.clearTimeout(saveTimer.current)
|
||||||
|
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
|
||||||
|
await loadLevelBySlug(caseState.id, editQuery)
|
||||||
|
setSelected(document.id)
|
||||||
|
setStatus(`EVIDENCE MATCHED · ${analysis.awardedFlags.join(', ').toUpperCase()} · NEW MATERIAL UNLOCKED`)
|
||||||
|
} else if (analysis.matchedFlags.length) setStatus('EVIDENCE MATCHED · ACHIEVEMENT ALREADY RECORDED')
|
||||||
|
else if (source === 'clipboard' && analysis.extractionStatus === 'succeeded') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
|
||||||
|
else if (source === 'clipboard' && analysis.extractionStatus === 'failed') setStatus('SCREENSHOT SAVED · TEXT ANALYSIS UNAVAILABLE')
|
||||||
|
else setStatus(source === 'clipboard' ? 'SCREENSHOT PASTED · NEW IMAGE DOCUMENT' : `IMPORTED · ${file.name.toUpperCase()}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED')
|
setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED')
|
||||||
} finally { setUploading(count => count - 1) }
|
} finally { setUploading(count => count - 1) }
|
||||||
}
|
}
|
||||||
}
|
}, [caseState, loadLevelBySlug, requestedEditMode, update])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!caseState) return
|
||||||
|
const handlePaste = (event: ClipboardEvent) => {
|
||||||
|
const images = Array.from(event.clipboardData?.items || []).flatMap(item => {
|
||||||
|
const file = item.kind === 'file' && item.type.startsWith('image/') ? item.getAsFile() : null
|
||||||
|
return file ? [file] : []
|
||||||
|
})
|
||||||
|
if (!images.length) return
|
||||||
|
event.preventDefault()
|
||||||
|
void uploadFiles(images.map(screenshotFile), 'clipboard')
|
||||||
|
}
|
||||||
|
window.addEventListener('paste', handlePaste)
|
||||||
|
return () => window.removeEventListener('paste', handlePaste)
|
||||||
|
}, [caseState, uploadFiles])
|
||||||
|
|
||||||
|
if (adminRoute) return <AdminPanel />
|
||||||
if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} />
|
if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} />
|
||||||
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
|
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
|
||||||
|
|
||||||
const documentById = new Map(caseState.documents.map(document => [document.id, document]))
|
const documents = documentExhibits(caseState.exhibits)
|
||||||
|
const evidence = evidenceExhibits(caseState.exhibits)
|
||||||
|
const documentById = new Map(documents.map(document => [document.id, document]))
|
||||||
const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase()
|
const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase()
|
||||||
const filteredDocuments = normalizedDocumentQuery ? caseState.documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : caseState.documents
|
const filteredDocuments = normalizedDocumentQuery ? documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : documents
|
||||||
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||||
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
|
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
|
||||||
const containedDocumentIds = new Set(caseState.relations.filter(relation => relation.type === 'contains').map(relation => relation.toWidgetId))
|
const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => exhibitWidget(exhibit.type).temporalFacts(exhibit).map(fact => {
|
||||||
const temporalItems: TemporalItem[] = [
|
const membership = exhibit.type === 'document' ? caseState.relations.find(relation => relation.type === 'contains' && relation.toExhibitId === exhibit.id) : undefined
|
||||||
...caseState.evidence.flatMap(folder => folder.type !== 'folder' ? [] : containedIds(caseState, folder.id).flatMap(documentId => {
|
const folder = membership ? caseState.exhibits.find(candidate => candidate.id === membership.fromExhibitId && candidate.type === 'folder') as FolderExhibit | undefined : undefined
|
||||||
const document = documentById.get(documentId)
|
const sourceTemporalId = folder && !folder.isOpen ? `widget:${folder.id}` : `widget:${exhibit.id}`
|
||||||
const date = document?.publishedAt || document?.date
|
return { id: fact.id, sourceTemporalId, date: fact.start, label: fact.label, kind: exhibit.type === 'document' ? 'document' as const : 'widget' as const, exhibitId: exhibit.id }
|
||||||
const relation = caseState.relations.find(candidate => candidate.type === 'contains' && candidate.fromWidgetId === folder.id && candidate.toWidgetId === documentId)
|
})).sort((a, b) => dateValue(a.date) - dateValue(b.date))
|
||||||
return document && date && relation ? [{ id: `folder:${folder.id}:document:${document.id}`, sourceTemporalId: folderIsOpen(folder) ? `file:${relation.id}` : `widget:${folder.id}`, date, label: document.title, kind: 'document' as const, evidenceId: folder.id, documentId: document.id }] : []
|
const storyEvents = evidence.filter((item): item is EventExhibit => item.type === 'event').sort((a, b) => {
|
||||||
})),
|
|
||||||
...caseState.documents.filter(document => !containedDocumentIds.has(document.id) && (document.publishedAt || document.date)).map(document => ({ id: `document:${document.id}`, sourceTemporalId: `document:${document.id}`, date: document.publishedAt || document.date, label: document.title, kind: 'document' as const, documentId: document.id })),
|
|
||||||
...caseState.evidence.filter(widget => widget.type === 'event' && widget.eventDate).map(widget => ({ id: `widget:${widget.id}`, sourceTemporalId: `widget:${widget.id}`, date: widget.eventDate!, label: widget.content, kind: 'widget' as const, evidenceId: widget.id })),
|
|
||||||
].sort((a, b) => dateValue(a.date) - dateValue(b.date))
|
|
||||||
const storyEvents = caseState.evidence.filter(item => item.type === 'event').sort((a, b) => {
|
|
||||||
if (!a.eventDate) return b.eventDate ? 1 : 0
|
if (!a.eventDate) return b.eventDate ? 1 : 0
|
||||||
if (!b.eventDate) return -1
|
if (!b.eventDate) return -1
|
||||||
return dateValue(a.eventDate) - dateValue(b.eventDate)
|
return dateValue(a.eventDate) - dateValue(b.eventDate)
|
||||||
})
|
})
|
||||||
|
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
|
||||||
return <main className="desktop">
|
return <main className="desktop">
|
||||||
<header className="menubar">
|
<header className="menubar">
|
||||||
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
||||||
@@ -349,8 +436,10 @@ export function App() {
|
|||||||
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
||||||
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
|
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
|
||||||
{adminMenuOpen && <div className="admin-menu-items" role="menu">
|
{adminMenuOpen && <div className="admin-menu-items" role="menu">
|
||||||
|
<button role="menuitem" onClick={() => { setFlagsOpen(true); setAdminMenuOpen(false) }}>LEVEL FLAGS</button>
|
||||||
{!canAuthor ? <button role="menuitem" onClick={enterLevelEditor}>ENTER LEVEL EDITOR</button> : <>
|
{!canAuthor ? <button role="menuitem" onClick={enterLevelEditor}>ENTER LEVEL EDITOR</button> : <>
|
||||||
<button role="menuitem" onClick={() => { setEditingBrief(true); setAdminMenuOpen(false) }}>EDIT BRIEF & CONCEPTS</button>
|
<button role="menuitem" onClick={() => { setEditingBrief(true); setAdminMenuOpen(false) }}>EDIT BRIEF & CONCEPTS</button>
|
||||||
|
<button role="menuitem" onClick={() => { setMatchRulesOpen(true); setAdminMenuOpen(false) }}>EVIDENCE MATCHING</button>
|
||||||
<button role="menuitem" onClick={() => { fileInputRef.current?.click(); setAdminMenuOpen(false) }}>IMPORT DOCUMENTS</button>
|
<button role="menuitem" onClick={() => { fileInputRef.current?.click(); setAdminMenuOpen(false) }}>IMPORT DOCUMENTS</button>
|
||||||
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void saveAsTemplate() }}>SAVE AS TEMPLATE</button>
|
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void saveAsTemplate() }}>SAVE AS TEMPLATE</button>
|
||||||
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void instantiateTemplate() }}>NEW FROM TEMPLATE</button>
|
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void instantiateTemplate() }}>NEW FROM TEMPLATE</button>
|
||||||
@@ -363,25 +452,25 @@ export function App() {
|
|||||||
|
|
||||||
<section className="workspace">
|
<section className="workspace">
|
||||||
<aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}>
|
<aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}>
|
||||||
<div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{normalizedDocumentQuery ? `${filteredDocuments.length}/${caseState.documents.length}` : caseState.documents.length}</sup></h2></div><button aria-label="Close documents" onClick={() => setDocsOpen(false)}><X size={17}/></button></div>
|
<div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{normalizedDocumentQuery ? `${filteredDocuments.length}/${documents.length}` : documents.length}</sup></h2></div><button aria-label="Close documents" onClick={() => setDocsOpen(false)}><X size={17}/></button></div>
|
||||||
<label className="search"><Search size={15}/><input type="search" aria-label="Search inside documents" placeholder="Search inside documents…" value={documentQuery} onChange={event => setDocumentQuery(event.target.value)}/>{documentQuery && <button type="button" aria-label="Clear document search" onClick={() => setDocumentQuery('')}><X size={13}/></button>}</label>
|
<label className="search"><Search size={15}/><input type="search" aria-label="Search inside documents" placeholder="Search inside documents…" value={documentQuery} onChange={event => setDocumentQuery(event.target.value)}/>{documentQuery && <button type="button" aria-label="Clear document search" onClick={() => setDocumentQuery('')}><X size={13}/></button>}</label>
|
||||||
{canAuthor && <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}…` : 'IMPORT DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} /></>}
|
<><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}…` : 'ADD DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) void uploadFiles(e.target.files); e.target.value = '' }} /></>
|
||||||
<div className="doc-list">
|
<div className="doc-list">
|
||||||
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
|
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''} ${arrivingExhibitIds.includes(doc.id) ? 'arriving' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
|
||||||
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.kind.slice(0, 3)}</b></div>
|
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.fileType.slice(0, 3)}</b></div>
|
||||||
<div><strong>{doc.title}</strong><span>{doc.kind} · {doc.date}</span></div><ChevronRight size={16}/>
|
<div><strong>{doc.title}</strong><span>{documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
|
||||||
</button>)}
|
</button>)}
|
||||||
{normalizedDocumentQuery && filteredDocuments.length === 0 && <div className="no-document-results"><Search size={20}/><b>NO MATCHING DOCUMENTS</b><span>Searches titles, contents, extracts, and metadata.</span></div>}
|
{normalizedDocumentQuery && filteredDocuments.length === 0 && <div className="no-document-results"><Search size={20}/><b>NO MATCHING DOCUMENTS</b><span>Searches titles, contents, extracts, and metadata.</span></div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-foot"><FolderOpen size={15}/> ARCHIVE MOUNTED <span>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING' : 'READ ONLY'}</span></div>
|
<div className="panel-foot"><FolderOpen size={15}/> ARCHIVE MOUNTED <span>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING' : 'READ ONLY'}</span></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && canAuthor) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (canAuthor) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
|
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) void uploadFiles(e.dataTransfer.files) }}>
|
||||||
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
||||||
<Board state={caseState} selected={selected} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, documents: state.documents.map(document => document.id === id ? { ...document, metadata: { ...document.metadata, memory_cue: cue } } : document) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} arrivingExhibitIds={arrivingExhibitIds} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
||||||
{briefOpen && <BriefPanel
|
{briefOpen && <BriefPanel
|
||||||
brief={caseState.brief}
|
brief={caseState.brief}
|
||||||
parties={caseState.evidence.filter(item => item.type === 'party')}
|
parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
|
||||||
recentlyCreatedExhibitId={recentlyCreatedExhibitId}
|
recentlyCreatedExhibitId={recentlyCreatedExhibitId}
|
||||||
canEdit={canAuthor}
|
canEdit={canAuthor}
|
||||||
onClose={closeBrief}
|
onClose={closeBrief}
|
||||||
@@ -392,7 +481,7 @@ export function App() {
|
|||||||
onEditParty={setEditingPartyId}
|
onEditParty={setEditingPartyId}
|
||||||
/>}
|
/>}
|
||||||
{storyEvents.length > 0 && <aside className="story-strip"><small>RECONSTRUCTED STORY</small>{storyEvents.map((event, index) => <button key={event.id} className={selected === event.id ? 'selected' : ''} onClick={() => focusEvidence(event.id)}><time>{event.eventDate?.slice(0, 10) || 'UNDATED'}</time><b>{index + 1}. {event.title}</b><span>{event.content}</span></button>)}</aside>}
|
{storyEvents.length > 0 && <aside className="story-strip"><small>RECONSTRUCTED STORY</small>{storyEvents.map((event, index) => <button key={event.id} className={selected === event.id ? 'selected' : ''} onClick={() => focusEvidence(event.id)}><time>{event.eventDate?.slice(0, 10) || 'UNDATED'}</time><b>{index + 1}. {event.title}</b><span>{event.content}</span></button>)}</aside>}
|
||||||
{!docsOpen && <button className="open-files" onClick={() => setDocsOpen(true)}><FolderOpen size={18}/> CASE MATERIALS <b>{caseState.documents.length}</b></button>}
|
{!docsOpen && <button className="open-files" onClick={() => setDocsOpen(true)}><FolderOpen size={18}/> CASE MATERIALS <b>{documents.length}</b></button>}
|
||||||
<div className="board-actions">
|
<div className="board-actions">
|
||||||
<button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button>
|
<button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button>
|
||||||
<button className={boardTool === 'hand' ? 'active' : ''} title="Pan board (middle mouse always works)" onClick={() => setBoardTool('hand')}><Hand size={17}/> HAND</button>
|
<button className={boardTool === 'hand' ? 'active' : ''} title="Pan board (middle mouse always works)" onClick={() => setBoardTool('hand')}><Hand size={17}/> HAND</button>
|
||||||
@@ -411,51 +500,49 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<DocumentLocatorBeam documentId={caseState.documents.some(document => document.id === selected) ? selected : null} layoutKey={`${docsOpen}:${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${caseState.evidence.map(item => `${item.id}:${item.x}:${item.y}:${String(item.config?.open)}`).join('|')}:${caseState.relations.map(item => `${item.id}:${String(item.config?.x)}:${String(item.config?.y)}`).join('|')}`} />
|
<DocumentLocatorBeam documentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} layoutKey={`${docsOpen}:${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${caseState.exhibits.map(item => `${item.id}:${item.x}:${item.y}:${item.type === 'folder' ? item.isOpen : ''}`).join('|')}`} />
|
||||||
<TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.evidence.map(e => `${e.id}:${e.x}:${e.y}:${String(e.config?.open)}`).join('|')}:${caseState.relations.map(r => `${r.id}:${String(r.config?.x)}:${String(r.config?.y)}`).join('|')}`}/>
|
<TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.exhibits.map(e => `${e.id}:${e.x}:${e.y}:${e.type === 'folder' ? e.isOpen : ''}`).join('|')}`}/>
|
||||||
<Timeline items={temporalItems} range={caseState.timelineRange} selected={selected} onEdit={() => setEditingTimeline(true)} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/>
|
{timelineView?.visible !== false && <Timeline items={temporalItems} range={timelineView?.rangeMode === 'fixed' ? timelineView.range : undefined} selected={selected} onEdit={() => setEditingTimeline(true)} onSelect={item => { const exhibit = caseState.exhibits.find(candidate => candidate.id === item.exhibitId); if (exhibit?.type === 'document') setOpenDoc(exhibit); else focusEvidence(item.exhibitId) }}/>
|
||||||
{openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />}
|
}
|
||||||
|
{openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.relations.flatMap(relation => relation.type === 'source' && relation.toExhibitId === openDoc.id ? [relation.sourceRegionId] : [])} />}
|
||||||
{editingFolderId && <FolderEditor
|
{editingFolderId && <FolderEditor
|
||||||
key={editingFolderId}
|
key={editingFolderId}
|
||||||
folder={caseState.evidence.find(widget => widget.id === editingFolderId)!}
|
folder={caseState.exhibits.find((widget): widget is FolderExhibit => widget.id === editingFolderId && widget.type === 'folder')!}
|
||||||
memberIds={containedIds(caseState, editingFolderId)}
|
memberIds={containedIds(caseState, editingFolderId)}
|
||||||
documents={caseState.documents}
|
documents={documents}
|
||||||
canManageContents={canAuthor}
|
canManageContents={canAuthor}
|
||||||
onClose={() => setEditingFolderId(null)}
|
onClose={() => setEditingFolderId(null)}
|
||||||
onSave={(folder, members) => {
|
onSave={(folder, members) => {
|
||||||
update(state => ({
|
update(state => ({
|
||||||
...state,
|
...state,
|
||||||
evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget),
|
exhibits: state.exhibits.map(widget => widget.id === folder.id ? folder : widget),
|
||||||
relations: [
|
relations: [
|
||||||
...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id),
|
...state.relations.filter(relation => relation.type !== 'contains' || relation.fromExhibitId !== folder.id),
|
||||||
...members.map((documentId, index) => {
|
...members.map((documentId, index): ExhibitRelation => ({ id: state.relations.find(relation => relation.type === 'contains' && relation.fromExhibitId === folder.id && relation.toExhibitId === documentId)?.id || uid('contains'), fromExhibitId: folder.id, toExhibitId: documentId, type: 'contains', sortOrder: index })),
|
||||||
const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId)
|
|
||||||
const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index })
|
|
||||||
return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position }
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
setEditingFolderId(null)
|
setEditingFolderId(null)
|
||||||
setStatus('FOLDER UPDATED')
|
setStatus('FOLDER UPDATED')
|
||||||
}}
|
}}
|
||||||
/>}
|
/>}
|
||||||
{editingFileId && <FileEditor key={editingFileId} document={caseState.documents.find(document => document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, documents: state.documents.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>}
|
{editingFileId && <FileEditor key={editingFileId} document={documents.find(document => document.id === editingFileId)!} canEditGates={canAuthor} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>
|
||||||
|
}
|
||||||
{editingEventId && <EventEditor
|
{editingEventId && <EventEditor
|
||||||
key={editingEventId}
|
key={editingEventId}
|
||||||
event={caseState.evidence.find(item => item.id === editingEventId)!}
|
event={caseState.exhibits.find((item): item is EventExhibit => item.id === editingEventId && item.type === 'event')!}
|
||||||
evidence={caseState.evidence}
|
exhibits={caseState.exhibits}
|
||||||
documents={caseState.documents}
|
relations={caseState.relations}
|
||||||
onClose={() => setEditingEventId(null)}
|
onClose={() => setEditingEventId(null)}
|
||||||
onSave={event => { update(state => ({ ...state, evidence: state.evidence.map(item => item.id === event.id ? event : item) })); setEditingEventId(null); setStatus('EVENT NARRATIVE UPDATED') }}
|
onSave={(event, supports) => { update(state => ({ ...state, exhibits: state.exhibits.map(item => item.id === event.id ? event : item), relations: replaceDirectedRelations(state.relations, 'supports', event.id, supports) })); setEditingEventId(null); setStatus('EVENT NARRATIVE UPDATED') }}
|
||||||
/>}
|
/>}
|
||||||
{newEventDraft && <EventEditor
|
{newEventDraft && <EventEditor
|
||||||
key={newEventDraft.id}
|
key={newEventDraft.id}
|
||||||
event={newEventDraft}
|
event={newEventDraft}
|
||||||
evidence={caseState.evidence}
|
exhibits={caseState.exhibits}
|
||||||
documents={caseState.documents}
|
relations={caseState.relations}
|
||||||
onClose={() => setNewEventDraft(null)}
|
onClose={() => setNewEventDraft(null)}
|
||||||
onSave={event => {
|
onSave={(event, supports) => {
|
||||||
update(state => ({ ...state, evidence: [...state.evidence, event] }))
|
update(state => ({ ...state, exhibits: [...state.exhibits, event], relations: replaceDirectedRelations(state.relations, 'supports', event.id, supports) }))
|
||||||
setNewEventDraft(null)
|
setNewEventDraft(null)
|
||||||
setSelected(event.id)
|
setSelected(event.id)
|
||||||
setRecentlyCreatedExhibitId(event.id)
|
setRecentlyCreatedExhibitId(event.id)
|
||||||
@@ -464,12 +551,12 @@ export function App() {
|
|||||||
/>}
|
/>}
|
||||||
{editingPartyId && <PartyEditor
|
{editingPartyId && <PartyEditor
|
||||||
key={editingPartyId}
|
key={editingPartyId}
|
||||||
party={caseState.evidence.find(item => item.id === editingPartyId)!}
|
party={caseState.exhibits.find((item): item is PartyExhibit => item.id === editingPartyId && item.type === 'party')!}
|
||||||
evidence={caseState.evidence}
|
exhibits={caseState.exhibits}
|
||||||
documents={caseState.documents}
|
relations={caseState.relations}
|
||||||
onClose={() => setEditingPartyId(null)}
|
onClose={() => setEditingPartyId(null)}
|
||||||
onSave={party => {
|
onSave={(party, related) => {
|
||||||
update(state => ({ ...state, evidence: state.evidence.map(item => item.id === party.id ? party : item) }))
|
update(state => ({ ...state, exhibits: state.exhibits.map(item => item.id === party.id ? party : item), relations: replaceDirectedRelations(state.relations, 'concerns', party.id, related) }))
|
||||||
setEditingPartyId(null)
|
setEditingPartyId(null)
|
||||||
setStatus('PARTY DOSSIER UPDATED')
|
setStatus('PARTY DOSSIER UPDATED')
|
||||||
}}
|
}}
|
||||||
@@ -477,12 +564,12 @@ export function App() {
|
|||||||
{newPartyDraft && <PartyEditor
|
{newPartyDraft && <PartyEditor
|
||||||
key={newPartyDraft.id}
|
key={newPartyDraft.id}
|
||||||
party={newPartyDraft}
|
party={newPartyDraft}
|
||||||
evidence={caseState.evidence}
|
exhibits={caseState.exhibits}
|
||||||
documents={caseState.documents}
|
relations={caseState.relations}
|
||||||
creating
|
creating
|
||||||
onClose={() => setNewPartyDraft(null)}
|
onClose={() => setNewPartyDraft(null)}
|
||||||
onSave={party => {
|
onSave={(party, related) => {
|
||||||
update(state => ({ ...state, evidence: [...state.evidence, party] }))
|
update(state => ({ ...state, exhibits: [...state.exhibits, party], relations: replaceDirectedRelations(state.relations, 'concerns', party.id, related) }))
|
||||||
setNewPartyDraft(null)
|
setNewPartyDraft(null)
|
||||||
setSelected(party.id)
|
setSelected(party.id)
|
||||||
setRecentlyCreatedExhibitId(party.id)
|
setRecentlyCreatedExhibitId(party.id)
|
||||||
@@ -499,11 +586,11 @@ export function App() {
|
|||||||
}}
|
}}
|
||||||
/>}
|
/>}
|
||||||
{editingTimeline && <TimelineRangeEditor
|
{editingTimeline && <TimelineRangeEditor
|
||||||
range={caseState.timelineRange}
|
range={timelineView?.rangeMode === 'fixed' ? timelineView.range : undefined}
|
||||||
dates={temporalItems.map(item => item.date)}
|
dates={temporalItems.map(item => item.date)}
|
||||||
onClose={() => setEditingTimeline(false)}
|
onClose={() => setEditingTimeline(false)}
|
||||||
onSave={timelineRange => {
|
onSave={timelineRange => {
|
||||||
update(state => ({ ...state, timelineRange }))
|
update(state => ({ ...state, views: state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: timelineRange ? 'fixed' : 'auto', range: timelineRange || undefined } : view) }))
|
||||||
setEditingTimeline(false)
|
setEditingTimeline(false)
|
||||||
setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC')
|
setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC')
|
||||||
}}
|
}}
|
||||||
@@ -511,14 +598,16 @@ export function App() {
|
|||||||
{threadDraft && <ThreadEditor
|
{threadDraft && <ThreadEditor
|
||||||
key={threadDraft.id}
|
key={threadDraft.id}
|
||||||
connection={threadDraft}
|
connection={threadDraft}
|
||||||
sourceName={caseState.evidence.find(item => item.id === threadDraft.fromEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.fromEvidenceId)?.title || 'Exhibit'}
|
sourceName={caseState.exhibits.find(item => item.id === threadDraft.fromExhibitId)?.title || 'Exhibit'}
|
||||||
targetName={caseState.evidence.find(item => item.id === threadDraft.toEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.toEvidenceId)?.title || 'Exhibit'}
|
targetName={caseState.exhibits.find(item => item.id === threadDraft.toExhibitId)?.title || 'Exhibit'}
|
||||||
isNew={!caseState.connections.some(item => item.id === threadDraft.id)}
|
isNew={!caseState.connections.some(item => item.id === threadDraft.id)}
|
||||||
onClose={() => setThreadDraft(null)}
|
onClose={() => setThreadDraft(null)}
|
||||||
onSave={saveThread}
|
onSave={saveThread}
|
||||||
onRemove={() => removeThread(threadDraft.id)}
|
onRemove={() => removeThread(threadDraft.id)}
|
||||||
/>}
|
/>}
|
||||||
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
|
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
|
||||||
|
{flagsOpen && <LevelFlagsEditor levelId={caseState.id} onClose={() => setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />}
|
||||||
|
{matchRulesOpen && <EvidenceMatchRulesEditor levelId={caseState.id} onClose={() => setMatchRulesOpen(false)}/>}
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,11 +626,12 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le
|
|||||||
return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main>
|
return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main>
|
||||||
}
|
}
|
||||||
|
|
||||||
function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onConnectionTarget, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
|
function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedExhibitId, arrivingExhibitIds, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; locatorDocumentId: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; arrivingExhibitIds: string[]; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
|
||||||
const drag = useRef<{ kind: 'pan' | 'widget' | 'relation' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
|
const drag = useRef<{ kind: 'pan' | 'widget' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
|
||||||
const suppressClick = useRef(false)
|
const suppressClick = useRef(false)
|
||||||
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
|
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
|
||||||
const pinchDistance = useRef<number | null>(null)
|
const pinchDistance = useRef<number | null>(null)
|
||||||
|
const folderLongPress = useRef<{ pointerId: number; id: string; startX: number; startY: number; timer: number } | null>(null)
|
||||||
const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null)
|
const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null)
|
||||||
const [expandedThreadTagId, setExpandedThreadTagId] = useState<string | null>(null)
|
const [expandedThreadTagId, setExpandedThreadTagId] = useState<string | null>(null)
|
||||||
const [draggingThreadTagId, setDraggingThreadTagId] = useState<string | null>(null)
|
const [draggingThreadTagId, setDraggingThreadTagId] = useState<string | null>(null)
|
||||||
@@ -549,16 +639,14 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
|||||||
const [trashActive, setTrashActive] = useState(false)
|
const [trashActive, setTrashActive] = useState(false)
|
||||||
const trashRef = useRef<HTMLDivElement>(null)
|
const trashRef = useRef<HTMLDivElement>(null)
|
||||||
const trashTarget = useRef(false)
|
const trashTarget = useRef(false)
|
||||||
const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence])
|
const byId = useMemo(() => new Map(state.exhibits.map(exhibit => [exhibit.id, exhibit])), [state.exhibits])
|
||||||
const containmentRelations = state.relations.filter(relation => relation.type === 'contains')
|
const containmentRelations = state.relations.filter(relation => relation.type === 'contains')
|
||||||
const pointForId = (id: string) => {
|
const pointForId = (id: string) => {
|
||||||
const evidence = byId.get(id)
|
const exhibit = byId.get(id)
|
||||||
if (evidence) return connectionPoint(evidence)
|
if (!exhibit) return undefined
|
||||||
const relation = containmentRelations.find(item => item.toWidgetId === id)
|
const membership = exhibit.type === 'document' ? containmentRelations.find(item => item.toExhibitId === id) : undefined
|
||||||
const folder = relation ? byId.get(relation.fromWidgetId) : undefined
|
const folder = membership ? byId.get(membership.fromExhibitId) : undefined
|
||||||
if (!relation || !folder) return undefined
|
return folder?.type === 'folder' && !folder.isOpen ? connectionPoint(folder) : connectionPoint(exhibit)
|
||||||
const position = relationPosition(state, relation)
|
|
||||||
return folderIsOpen(folder) ? { x: position.x + 87, y: position.y + 72 } : connectionPoint(folder)
|
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const board = boardRef.current
|
const board = boardRef.current
|
||||||
@@ -574,11 +662,13 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
|||||||
board.addEventListener('wheel', handleWheelZoom, { passive: false })
|
board.addEventListener('wheel', handleWheelZoom, { passive: false })
|
||||||
return () => board.removeEventListener('wheel', handleWheelZoom)
|
return () => board.removeEventListener('wheel', handleWheelZoom)
|
||||||
}, [boardRef, update])
|
}, [boardRef, update])
|
||||||
const pointerDown = (event: React.PointerEvent, target?: { kind: 'widget' | 'relation'; id: string }) => {
|
const pointerDown = (event: React.PointerEvent, target?: { kind: 'widget'; id: string }) => {
|
||||||
if ((event.target as HTMLElement).closest('button')) return
|
if ((event.target as HTMLElement).closest('button')) return
|
||||||
if (event.pointerType === 'touch') {
|
if (event.pointerType === 'touch') {
|
||||||
touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
||||||
if (touchPoints.current.size >= 2) {
|
if (touchPoints.current.size >= 2) {
|
||||||
|
if (folderLongPress.current) window.clearTimeout(folderLongPress.current.timer)
|
||||||
|
folderLongPress.current = null
|
||||||
const points = [...touchPoints.current.values()]
|
const points = [...touchPoints.current.values()]
|
||||||
pinchDistance.current = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y)
|
pinchDistance.current = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y)
|
||||||
drag.current = null
|
drag.current = null
|
||||||
@@ -587,9 +677,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const widget = target?.kind === 'widget' ? byId.get(target.id) : undefined
|
const widget = target?.kind === 'widget' ? byId.get(target.id) : undefined
|
||||||
const relation = target?.kind === 'relation' ? state.relations.find(candidate => candidate.id === target.id) : undefined
|
drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? state.viewport.x, originY: widget?.y ?? state.viewport.y }
|
||||||
const position = relation ? relationPosition(state, relation) : undefined
|
|
||||||
drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? position?.x ?? state.viewport.x, originY: widget?.y ?? position?.y ?? state.viewport.y }
|
|
||||||
setDraggingWidget(target?.kind === 'widget')
|
setDraggingWidget(target?.kind === 'widget')
|
||||||
try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ }
|
try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ }
|
||||||
}
|
}
|
||||||
@@ -609,6 +697,11 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
|||||||
}
|
}
|
||||||
const pointerMove = (event: React.PointerEvent) => {
|
const pointerMove = (event: React.PointerEvent) => {
|
||||||
trackThreadPointer(event)
|
trackThreadPointer(event)
|
||||||
|
const pendingFolderPress = folderLongPress.current
|
||||||
|
if (pendingFolderPress?.pointerId === event.pointerId && Math.hypot(event.clientX - pendingFolderPress.startX, event.clientY - pendingFolderPress.startY) > 8) {
|
||||||
|
window.clearTimeout(pendingFolderPress.timer)
|
||||||
|
folderLongPress.current = null
|
||||||
|
}
|
||||||
if (event.pointerType === 'touch' && touchPoints.current.has(event.pointerId)) {
|
if (event.pointerType === 'touch' && touchPoints.current.has(event.pointerId)) {
|
||||||
touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
||||||
if (touchPoints.current.size >= 2) {
|
if (touchPoints.current.size >= 2) {
|
||||||
@@ -638,19 +731,22 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
|||||||
if (drag.current.kind === 'thread-tag' && boardRef.current) {
|
if (drag.current.kind === 'thread-tag' && boardRef.current) {
|
||||||
if (drag.current.moved) setExpandedThreadTagId(null)
|
if (drag.current.moved) setExpandedThreadTagId(null)
|
||||||
const connection = state.connections.find(item => item.id === drag.current!.id)
|
const connection = state.connections.find(item => item.id === drag.current!.id)
|
||||||
const from = connection ? pointForId(connection.fromEvidenceId) : undefined
|
const from = connection ? pointForId(connection.fromExhibitId) : undefined
|
||||||
const to = connection ? pointForId(connection.toEvidenceId) : undefined
|
const to = connection ? pointForId(connection.toExhibitId) : undefined
|
||||||
if (connection && from && to) {
|
if (connection && from && to) {
|
||||||
const bounds = boardRef.current.getBoundingClientRect()
|
const bounds = boardRef.current.getBoundingClientRect()
|
||||||
const pointer = { x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom }
|
const pointer = { x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom }
|
||||||
const placement = projectThreadTag(from, to, connection.tightness ?? 65, pointer)
|
const placement = projectThreadTag(from, to, connection.tightness ?? 65, pointer)
|
||||||
update(s => ({ ...s, connections: s.connections.map(item => item.id === connection.id ? { ...item, tagPosition: placement.positionPercent, tagOffset: placement.lateralOffset } : item) }))
|
update(s => ({ ...s, connections: s.connections.map(item => item.id === connection.id ? { ...item, tagPosition: placement.positionPercent, tagOffset: placement.lateralOffset } : item) }))
|
||||||
}
|
}
|
||||||
} else if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, ...next } : e) } })
|
} else if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === drag.current!.id ? { ...exhibit, ...next } : exhibit) } })
|
||||||
else if (drag.current.kind === 'relation') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), ...next } } : relation) } })
|
|
||||||
else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) }))
|
else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) }))
|
||||||
}
|
}
|
||||||
const finishDrag = (event: React.PointerEvent) => {
|
const finishDrag = (event: React.PointerEvent) => {
|
||||||
|
if (folderLongPress.current?.pointerId === event.pointerId) {
|
||||||
|
window.clearTimeout(folderLongPress.current.timer)
|
||||||
|
folderLongPress.current = null
|
||||||
|
}
|
||||||
if (event.pointerType === 'touch') {
|
if (event.pointerType === 'touch') {
|
||||||
touchPoints.current.delete(event.pointerId)
|
touchPoints.current.delete(event.pointerId)
|
||||||
if (touchPoints.current.size < 2) pinchDistance.current = null
|
if (touchPoints.current.size < 2) pinchDistance.current = null
|
||||||
@@ -664,7 +760,34 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
|||||||
if (completedDrag?.kind === 'widget' && completedDrag.id && completedDrag.moved && trashTarget.current) onDiscardExhibit(completedDrag.id)
|
if (completedDrag?.kind === 'widget' && completedDrag.id && completedDrag.moved && trashTarget.current) onDiscardExhibit(completedDrag.id)
|
||||||
trashTarget.current = false
|
trashTarget.current = false
|
||||||
}
|
}
|
||||||
const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) }))
|
const toggleFolder = (id: string) => update(s => ({ ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'folder' ? { ...exhibit, isOpen: !exhibit.isOpen } : exhibit) }))
|
||||||
|
const startFolderLongPress = (event: React.PointerEvent, id: string) => {
|
||||||
|
if (event.pointerType !== 'touch' || tool !== 'move' || linkFrom || (event.target as HTMLElement).closest('button')) return
|
||||||
|
if (folderLongPress.current) window.clearTimeout(folderLongPress.current.timer)
|
||||||
|
const pointerId = event.pointerId
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
if (touchPoints.current.size !== 1 || folderLongPress.current?.pointerId !== pointerId) return
|
||||||
|
folderLongPress.current = null
|
||||||
|
drag.current = null
|
||||||
|
trashTarget.current = false
|
||||||
|
setDraggingWidget(false)
|
||||||
|
setTrashActive(false)
|
||||||
|
suppressClick.current = true
|
||||||
|
toggleFolder(id)
|
||||||
|
}, 520)
|
||||||
|
folderLongPress.current = { pointerId, id, startX: event.clientX, startY: event.clientY, timer }
|
||||||
|
}
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (folderLongPress.current) window.clearTimeout(folderLongPress.current.timer)
|
||||||
|
}, [])
|
||||||
|
const widgetContext: ExhibitWidgetContext = { exhibits: state.exhibits, relations: state.relations, dispatch: (command: WidgetCommand) => {
|
||||||
|
if (command.type === 'open-document') onOpenSource(command.documentId)
|
||||||
|
else if (command.type === 'edit-folder') onEditFolder(command.folderId)
|
||||||
|
else if (command.type === 'edit-event') onEditEvent(command.eventId)
|
||||||
|
else if (command.type === 'edit-party') onEditParty(command.partyId)
|
||||||
|
else if (command.type === 'edit-document') onEditFile(command.documentId)
|
||||||
|
else if (command.type === 'update-memory-cue') onUpdateDocumentCue(command.documentId, command.cue)
|
||||||
|
} }
|
||||||
const previewOrigin = linkFrom ? pointForId(linkFrom) : undefined
|
const previewOrigin = linkFrom ? pointForId(linkFrom) : undefined
|
||||||
return <div className={`board-viewport tool-${tool} ${linkFrom ? 'threading' : ''}`} ref={boardRef}
|
return <div className={`board-viewport tool-${tool} ${linkFrom ? 'threading' : ''}`} ref={boardRef}
|
||||||
onPointerDown={e => {
|
onPointerDown={e => {
|
||||||
@@ -678,54 +801,32 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
|||||||
<div className="board" style={{ width: BOARD_W, height: BOARD_H, transform: `translate(${state.viewport.x}px, ${state.viewport.y}px) scale(${state.viewport.zoom})` }}>
|
<div className="board" style={{ width: BOARD_W, height: BOARD_H, transform: `translate(${state.viewport.x}px, ${state.viewport.y}px) scale(${state.viewport.zoom})` }}>
|
||||||
<div className="board-stamp">AUTHORIZED CITIZEN SCIENTIST WORKSTATION <span>GU-NET / 04</span></div>
|
<div className="board-stamp">AUTHORIZED CITIZEN SCIENTIST WORKSTATION <span>GU-NET / 04</span></div>
|
||||||
<svg className="connections" width={BOARD_W} height={BOARD_H}>
|
<svg className="connections" width={BOARD_W} height={BOARD_H}>
|
||||||
{state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; return <g className={recentlyCreatedConnectionId === connection.id ? 'tightening' : ''} key={connection.id}><path d={threadCurve(p1, p2, connection.tightness).path}/><circle cx={p1.x} cy={p1.y} r="4"/><circle cx={p2.x} cy={p2.y} r="4"/></g> })}
|
{state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; return <g className={recentlyCreatedConnectionId === connection.id ? 'tightening' : ''} key={connection.id}><path d={threadCurve(p1, p2, connection.tightness).path}/><circle cx={p1.x} cy={p1.y} r="4"/><circle cx={p2.x} cy={p2.y} r="4"/></g> })}
|
||||||
{previewOrigin && threadPointer && <g className="thread-preview"><path d={threadCurve(previewOrigin, threadPointer, 35).path}/><circle cx={previewOrigin.x} cy={previewOrigin.y} r="4"/><circle cx={threadPointer.x} cy={threadPointer.y} r="3"/></g>}
|
{previewOrigin && threadPointer && <g className="thread-preview"><path d={threadCurve(previewOrigin, threadPointer, 35).path}/><circle cx={previewOrigin.x} cy={previewOrigin.y} r="4"/><circle cx={threadPointer.x} cy={threadPointer.y} r="3"/></g>}
|
||||||
</svg>
|
</svg>
|
||||||
{state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; const placement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, connection.tagOffset); const compact = connection.tagStyle === 'compact'; const expanded = !compact && expandedThreadTagId === connection.id; const dragging = draggingThreadTagId === connection.id; return <button key={`tag:${connection.id}`} aria-expanded={connection.label && !compact ? expanded : undefined} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? `labelled ${compact ? 'compact' : 'luggage luggage-tag'}` : 'untagged'} ${expanded ? 'expanded' : ''} ${dragging ? 'dragging' : ''}`} style={{ left: placement.x, top: placement.y }} title={connection.label ? dragging ? `Position ${Math.round(placement.positionPercent)}%` : compact ? 'Drag to position · click to edit' : expanded ? 'Click again to edit this thread' : 'Drag along thread · click to rotate' : 'Edit thread tag and tightness'} onPointerDown={event => connection.label ? threadTagPointerDown(event, connection.id) : event.stopPropagation()} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!connection.label || compact || expanded) { setExpandedThreadTagId(null); onEditConnection(connection) } else setExpandedThreadTagId(connection.id) }}><i/>{dragging && <output className="thread-tag-position">{Math.round(placement.positionPercent)}%</output>}{connection.label && (compact ? <span className="thread-tag-compact-label">{connection.label}</span> : <span className="thread-tag-content"><small>RELATION TAG</small><b>{connection.label}</b>{expanded && <em>CLICK AGAIN TO EDIT THREAD</em>}</span>)}</button> })}
|
{state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; const tagPlacement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, connection.tagOffset); const compact = connection.tagStyle === 'compact'; const expanded = !compact && expandedThreadTagId === connection.id; const dragging = draggingThreadTagId === connection.id; return <button key={`tag:${connection.id}`} aria-expanded={connection.label && !compact ? expanded : undefined} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? `labelled ${compact ? 'compact' : 'luggage luggage-tag'}` : 'untagged'} ${expanded ? 'expanded' : ''} ${dragging ? 'dragging' : ''}`} style={{ left: tagPlacement.x, top: tagPlacement.y }} title={connection.label ? dragging ? `Position ${Math.round(tagPlacement.positionPercent)}%` : compact ? 'Drag to position · click to edit' : expanded ? 'Click again to edit this thread' : 'Drag along thread · click to rotate' : 'Edit thread tag and tightness'} onPointerDown={event => connection.label ? threadTagPointerDown(event, connection.id) : event.stopPropagation()} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!connection.label || compact || expanded) { setExpandedThreadTagId(null); onEditConnection(connection) } else setExpandedThreadTagId(connection.id) }}><i/>{dragging && <output className="thread-tag-position">{Math.round(tagPlacement.positionPercent)}%</output>}{connection.label && (compact ? <span className="thread-tag-compact-label">{connection.label}</span> : <span className="thread-tag-content"><small>RELATION TAG</small><b>{connection.label}</b>{expanded && <em>CLICK AGAIN TO EDIT THREAD</em>}</span>)}</button> })}
|
||||||
<svg className="event-support-lines" width={BOARD_W} height={BOARD_H}>
|
<svg className="event-support-lines" width={BOARD_W} height={BOARD_H}>
|
||||||
{state.evidence.filter(event => event.type === 'event').flatMap(event => (event.supportingEvidenceIds || []).flatMap(evidenceId => {
|
{state.relations.filter(relation => relation.type === 'supports').map(relation => { const origin = pointForId(relation.fromExhibitId), target = pointForId(relation.toExhibitId); return origin && target ? <line key={relation.id} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/> : null })}
|
||||||
const evidence = byId.get(evidenceId)
|
|
||||||
let target = evidence ? connectionPoint(evidence) : undefined
|
|
||||||
if (!target) {
|
|
||||||
const relation = containmentRelations.find(item => item.toWidgetId === evidenceId)
|
|
||||||
const folder = relation ? byId.get(relation.fromWidgetId) : undefined
|
|
||||||
if (relation && folder) target = folderIsOpen(folder) ? { x: relationPosition(state, relation).x + 87, y: relationPosition(state, relation).y + 72 } : connectionPoint(folder)
|
|
||||||
}
|
|
||||||
if (!target) return []
|
|
||||||
const origin = connectionPoint(event)
|
|
||||||
return [<line key={`${event.id}:${evidenceId}`} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/>]
|
|
||||||
}))}
|
|
||||||
</svg>
|
</svg>
|
||||||
<svg className="party-association-lines" width={BOARD_W} height={BOARD_H}>
|
<svg className="party-association-lines" width={BOARD_W} height={BOARD_H}>
|
||||||
{state.evidence.filter(party => party.type === 'party').flatMap(party => (party.relatedEvidenceIds || []).flatMap(evidenceId => {
|
{state.relations.filter(relation => relation.type === 'concerns').map(relation => { const origin = pointForId(relation.fromExhibitId), target = pointForId(relation.toExhibitId); return origin && target ? <line key={relation.id} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/> : null })}
|
||||||
const evidence = byId.get(evidenceId)
|
|
||||||
let target = evidence ? connectionPoint(evidence) : undefined
|
|
||||||
if (!target) {
|
|
||||||
const relation = containmentRelations.find(item => item.toWidgetId === evidenceId)
|
|
||||||
const folder = relation ? byId.get(relation.fromWidgetId) : undefined
|
|
||||||
if (relation && folder) target = folderIsOpen(folder) ? { x: relationPosition(state, relation).x + 87, y: relationPosition(state, relation).y + 72 } : connectionPoint(folder)
|
|
||||||
}
|
|
||||||
if (!target) return []
|
|
||||||
const origin = connectionPoint(party)
|
|
||||||
return [<line key={`${party.id}:${evidenceId}`} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/>]
|
|
||||||
}))}
|
|
||||||
</svg>
|
</svg>
|
||||||
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
|
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
|
||||||
{containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), position = relationPosition(state, relation), origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={open ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={open ? position.x + 87 : origin.x} y2={open ? position.y + 72 : origin.y}/> })}
|
{containmentRelations.map(relation => { const folder = byId.get(relation.fromExhibitId), document = byId.get(relation.toExhibitId); if (folder?.type !== 'folder' || document?.type !== 'document') return null; const origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={folder.isOpen ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={folder.isOpen ? document.x + document.width / 2 : origin.x} y2={folder.isOpen ? document.y + document.height / 2 : origin.y}/> })}
|
||||||
</svg>
|
</svg>
|
||||||
{state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !folderIsOpen(ev) && containedDocuments.some(document => document.id === selected) ? selected : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, rotate: `${(i % 3 - 1) * .45}deg` }}
|
{evidenceExhibits(state.exhibits).filter(exhibit => !exhibit.hidden).map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = byId.get(id); return document?.type === 'document' ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !ev.isOpen && containedDocuments.some(document => document.id === locatorDocumentId) ? locatorDocumentId : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; const containsArrival = ev.type === 'folder' && containedDocuments.some(document => arrivingExhibitIds.includes(document.id)); return <article key={ev.id} tabIndex={ev.type === 'folder' ? 0 : undefined} aria-expanded={ev.type === 'folder' ? ev.isOpen : undefined} title={ev.type === 'folder' ? 'Double-click or hold to open or close this folder' : undefined} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id || arrivingExhibitIds.includes(ev.id) || containsArrival ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }}
|
||||||
onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
|
onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (ev.type === 'folder') startFolderLongPress(e, ev.id); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
|
||||||
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
|
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
|
||||||
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}>
|
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (ev.type === 'folder' && e.detail > 1) return; if (tool === 'move') onCardClick(ev.id) }} onDoubleClick={e => { e.stopPropagation(); if (ev.type === 'folder' && tool === 'move' && !linkFrom && !(e.target as HTMLElement).closest('button')) toggleFolder(ev.id) }} onKeyDown={e => { if (ev.type === 'folder' && e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); toggleFolder(ev.id) } }}>
|
||||||
<header><span>{definition.heading(ev, containedDocuments)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
|
<header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
|
||||||
<Widget exhibit={ev} documents={containedDocuments} onOpenSource={onOpenSource} onToggleFolder={toggleFolder} onEditFolder={onEditFolder} onEditEvent={onEditEvent} onEditParty={onEditParty}/>
|
<Widget exhibit={ev} context={widgetContext}/>
|
||||||
</article>})}
|
</article>})}
|
||||||
{containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), located = open && selected === document.id, target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={relation.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`file:${relation.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''}`} style={{ left, top }}
|
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
|
||||||
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'relation', id: relation.id }) }}
|
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
|
||||||
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (linkFrom) onConnectionTarget(document.id); else if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
|
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
|
||||||
<header><span>{definition.label.toUpperCase()}</span><i>{String((relation.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
<header><span>{definition.label.toUpperCase()}</span><i>{String((membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
||||||
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
|
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
|
||||||
<strong>{document.title}</strong><time>{(document.publishedAt || document.date)?.slice(0, 10) || 'UNDATED'}</time>
|
<strong>{document.title}</strong><time>{document.publishedAt?.slice(0, 10) || 'UNDATED'}</time>
|
||||||
<div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div>
|
<div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div>
|
||||||
</article> })}
|
</article> })}
|
||||||
</div>
|
</div>
|
||||||
@@ -817,7 +918,7 @@ function Timeline({ items, range, selected, onSelect, onEdit }: { items: Tempora
|
|||||||
const ticks = range
|
const ticks = range
|
||||||
? Array.from({ length: 5 }, (_, index) => { const value = start + (end - start) * index / 4; return { value, label: new Date(value).toISOString().slice(5, 10) } })
|
? Array.from({ length: 5 }, (_, index) => { const value = start + (end - start) * index / 4; return { value, label: new Date(value).toISOString().slice(5, 10) } })
|
||||||
: Array.from({ length: endYear - startYear + 1 }, (_, index) => { const year = startYear + index; return { value: Date.parse(`${year}-01-01T00:00:00.000Z`), label: String(year) } })
|
: Array.from({ length: endYear - startYear + 1 }, (_, index) => { const year = startYear + index; return { value: Date.parse(`${year}-01-01T00:00:00.000Z`), label: String(year) } })
|
||||||
return <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><button onClick={onEdit}>{range ? `${range.start} — ${range.end}` : `${items.length} DATED ITEMS · AUTO`}</button></div><div className="timeline-track"><div className="axis"/>{ticks.map((tick, index) => <span className="year" key={`${tick.value}:${index}`} style={{ left: `${timelinePositionPercent(new Date(tick.value).toISOString(), { start, end })}%` }}>{tick.label}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.evidenceId === selected || item.documentId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)} — ${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer>
|
return <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><button onClick={onEdit}>{range ? `${range.start} — ${range.end}` : `${items.length} DATED ITEMS · AUTO`}</button></div><div className="timeline-track"><div className="axis"/>{ticks.map((tick, index) => <span className="year" key={`${tick.value}:${index}`} style={{ left: `${timelinePositionPercent(new Date(tick.value).toISOString(), { start, end })}%` }}>{tick.label}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.exhibitId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)} — ${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer>
|
||||||
}
|
}
|
||||||
|
|
||||||
function localDateTime(value?: string) {
|
function localDateTime(value?: string) {
|
||||||
@@ -866,7 +967,7 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
|
|||||||
</form></div>
|
</form></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; parties: Evidence[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
|
function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; parties: PartyExhibit[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
|
||||||
const [minimized, setMinimized] = useState(false)
|
const [minimized, setMinimized] = useState(false)
|
||||||
const partyById = new Map(parties.map(party => [party.id, party]))
|
const partyById = new Map(parties.map(party => [party.id, party]))
|
||||||
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||||
@@ -895,16 +996,16 @@ function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: (
|
|||||||
</form></div>
|
</form></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function PartyEditor({ party, evidence, documents, creating = false, onClose, onSave }: { party: Evidence; evidence: Evidence[]; documents: CaseDocument[]; creating?: boolean; onClose: () => void; onSave: (party: Evidence) => void }) {
|
function PartyEditor({ party, exhibits, relations, creating = false, onClose, onSave }: { party: PartyExhibit; exhibits: Exhibit[]; relations: ExhibitRelation[]; creating?: boolean; onClose: () => void; onSave: (party: PartyExhibit, related: string[]) => void }) {
|
||||||
const [name, setName] = useState(party.title)
|
const [name, setName] = useState(party.title)
|
||||||
const [summary, setSummary] = useState(party.content)
|
const [summary, setSummary] = useState(party.content)
|
||||||
const [partyKind, setPartyKind] = useState<PartyKind>(party.partyKind || 'person')
|
const [partyKind, setPartyKind] = useState<PartyKind>(party.partyKind)
|
||||||
const [organizationKind, setOrganizationKind] = useState<OrganizationKind>(party.organizationKind || 'business')
|
const [organizationKind, setOrganizationKind] = useState<OrganizationKind>(party.organizationKind || 'business')
|
||||||
const [aliases, setAliases] = useState((party.aliases || []).join('\n'))
|
const [aliases, setAliases] = useState(party.aliases.join('\n'))
|
||||||
const [related, setRelated] = useState(party.relatedEvidenceIds || [])
|
const [related, setRelated] = useState(relations.filter(relation => relation.type === 'concerns' && relation.fromExhibitId === party.id).map(relation => relation.toExhibitId))
|
||||||
const candidates = [...evidence.filter(item => item.id !== party.id && item.type !== 'party').map(item => ({ id: item.id, title: item.title, kind: item.type.toUpperCase() })), ...documents.map(item => ({ id: item.id, title: item.title, kind: item.fileType.toUpperCase() }))]
|
const candidates = exhibits.filter(item => item.id !== party.id && item.type !== 'party').map(item => ({ id: item.id, title: item.title, kind: item.type === 'document' ? item.fileType.toUpperCase() : item.type.toUpperCase() }))
|
||||||
const toggle = (id: string) => setRelated(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
|
const toggle = (id: string) => setRelated(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
|
||||||
return <div className="modal-shade"><form className="window folder-editor party-editor" onSubmit={submit => { submit.preventDefault(); onSave({ ...party, partyKind, title: name.trim(), content: summary.trim(), organizationKind: partyKind === 'organization' ? organizationKind : undefined, aliases: aliases.split('\n').map(item => item.trim()).filter(Boolean), relatedEvidenceIds: related }) }}>
|
return <div className="modal-shade"><form className="window folder-editor party-editor" onSubmit={submit => { submit.preventDefault(); onSave({ ...party, partyKind, title: name.trim(), content: summary.trim(), organizationKind: partyKind === 'organization' ? organizationKind : undefined, aliases: aliases.split('\n').map(item => item.trim()).filter(Boolean) }, related) }}>
|
||||||
<header>{partyKind === 'person' ? <UserRound size={16}/> : <Building2 size={16}/>}<b>{creating ? 'Create party dossier' : `Edit ${partyKind} dossier`}</b><span/><button type="button" aria-label="Close party editor" onClick={onClose}><X size={14}/></button></header>
|
<header>{partyKind === 'person' ? <UserRound size={16}/> : <Building2 size={16}/>}<b>{creating ? 'Create party dossier' : `Edit ${partyKind} dossier`}</b><span/><button type="button" aria-label="Close party editor" onClick={onClose}><X size={14}/></button></header>
|
||||||
<div className="folder-editor-body"><small>PARTY EXHIBIT · {partyKind.toUpperCase()}</small>
|
<div className="folder-editor-body"><small>PARTY EXHIBIT · {partyKind.toUpperCase()}</small>
|
||||||
{creating && <label className="field"><span>PARTY TYPE</span><select aria-label="Party type" value={partyKind} onChange={event => setPartyKind(event.target.value as PartyKind)}><option value="person">Person</option><option value="organization">Organization</option></select></label>}
|
{creating && <label className="field"><span>PARTY TYPE</span><select aria-label="Party type" value={partyKind} onChange={event => setPartyKind(event.target.value as PartyKind)}><option value="person">Person</option><option value="organization">Organization</option></select></label>}
|
||||||
@@ -919,14 +1020,14 @@ function PartyEditor({ party, evidence, documents, creating = false, onClose, on
|
|||||||
</form></div>
|
</form></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function FolderEditor({ folder, memberIds, documents, canManageContents, onClose, onSave }: { folder: Evidence; memberIds: string[]; documents: CaseDocument[]; canManageContents: boolean; onClose: () => void; onSave: (folder: Evidence, members: string[]) => void }) {
|
function FolderEditor({ folder, memberIds, documents, canManageContents, onClose, onSave }: { folder: FolderExhibit; memberIds: string[]; documents: CaseDocument[]; canManageContents: boolean; onClose: () => void; onSave: (folder: FolderExhibit, members: string[]) => void }) {
|
||||||
const [title, setTitle] = useState(folder.title)
|
const [title, setTitle] = useState(folder.title)
|
||||||
const [content, setContent] = useState(folder.content)
|
const [content, setContent] = useState(folder.content)
|
||||||
const [members, setMembers] = useState(memberIds)
|
const [members, setMembers] = useState(memberIds)
|
||||||
const toggleMember = (documentId: string) => setMembers(current => current.includes(documentId) ? current.filter(id => id !== documentId) : [...current, documentId])
|
const toggleMember = (documentId: string) => setMembers(current => current.includes(documentId) ? current.filter(id => id !== documentId) : [...current, documentId])
|
||||||
const submit = (event: React.FormEvent) => {
|
const submit = (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
onSave({ ...folder, title: title.trim() || 'UNTITLED EVIDENCE FOLDER', content: content.trim(), containedDocumentIds: members }, members)
|
onSave({ ...folder, title: title.trim() || 'UNTITLED EVIDENCE FOLDER', content: content.trim() }, members)
|
||||||
}
|
}
|
||||||
return <div className="modal-shade"><form className="window folder-editor" onSubmit={submit}>
|
return <div className="modal-shade"><form className="window folder-editor" onSubmit={submit}>
|
||||||
<header><FolderOpen size={16}/><b>Edit evidence folder</b><span/><button type="button" aria-label="Close folder editor" onClick={onClose}><X size={14}/></button></header>
|
<header><FolderOpen size={16}/><b>Edit evidence folder</b><span/><button type="button" aria-label="Close folder editor" onClick={onClose}><X size={14}/></button></header>
|
||||||
@@ -938,7 +1039,7 @@ function FolderEditor({ folder, memberIds, documents, canManageContents, onClose
|
|||||||
<div className="folder-members">
|
<div className="folder-members">
|
||||||
{documents.map(document => { const included = members.includes(document.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={document.id}>
|
{documents.map(document => { const included = members.includes(document.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={document.id}>
|
||||||
<label><input type="checkbox" disabled={!canManageContents} checked={included} onChange={() => toggleMember(document.id)}/><FileText size={16}/><span><b>{document.title}</b><small>{document.fileType.replaceAll('_', ' ').toUpperCase()}</small></span></label>
|
<label><input type="checkbox" disabled={!canManageContents} checked={included} onChange={() => toggleMember(document.id)}/><FileText size={16}/><span><b>{document.title}</b><small>{document.fileType.replaceAll('_', ' ').toUpperCase()}</small></span></label>
|
||||||
<span className="folder-member-date">{(document.publishedAt || document.date)?.slice(0, 10) || 'UNDATED'}</span>
|
<span className="folder-member-date">{document.publishedAt?.slice(0, 10) || 'UNDATED'}</span>
|
||||||
</div> })}
|
</div> })}
|
||||||
</div>
|
</div>
|
||||||
<p className="folder-editor-note">The folder owns this text and its containment relationships. Publication time and other metadata belong to the individual files.</p>
|
<p className="folder-editor-note">The folder owns this text and its containment relationships. Publication time and other metadata belong to the individual files.</p>
|
||||||
@@ -947,20 +1048,17 @@ function FolderEditor({ folder, memberIds, documents, canManageContents, onClose
|
|||||||
</form></div>
|
</form></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventEditor({ event, evidence, documents, onClose, onSave }: { event: Evidence; evidence: Evidence[]; documents: CaseDocument[]; onClose: () => void; onSave: (event: Evidence) => void }) {
|
function EventEditor({ event, exhibits, relations, onClose, onSave }: { event: EventExhibit; exhibits: Exhibit[]; relations: ExhibitRelation[]; onClose: () => void; onSave: (event: EventExhibit, supports: string[]) => void }) {
|
||||||
const [title, setTitle] = useState(event.title)
|
const [title, setTitle] = useState(event.title)
|
||||||
const [narrative, setNarrative] = useState(event.content)
|
const [narrative, setNarrative] = useState(event.content)
|
||||||
const [occurredAt, setOccurredAt] = useState(localDateTime(event.eventDate))
|
const [occurredAt, setOccurredAt] = useState(localDateTime(event.eventDate))
|
||||||
const [supports, setSupports] = useState(event.supportingEvidenceIds || [])
|
const [supports, setSupports] = useState(relations.filter(relation => relation.type === 'supports' && relation.fromExhibitId === event.id).map(relation => relation.toExhibitId))
|
||||||
const candidates = [
|
const candidates = exhibits.filter(item => item.id !== event.id && item.type !== 'event').map(item => ({ id: item.id, title: item.title, kind: item.type === 'document' ? item.fileType.replaceAll('_', ' ').toUpperCase() : item.type.toUpperCase() }))
|
||||||
...evidence.filter(item => item.id !== event.id && item.type !== 'event').map(item => ({ id: item.id, title: item.title, kind: item.type.toUpperCase() })),
|
|
||||||
...documents.map(document => ({ id: document.id, title: document.title, kind: document.fileType.replaceAll('_', ' ').toUpperCase() })),
|
|
||||||
]
|
|
||||||
const toggle = (id: string) => setSupports(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
|
const toggle = (id: string) => setSupports(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
|
||||||
const submit = (submitEvent: React.FormEvent) => {
|
const submit = (submitEvent: React.FormEvent) => {
|
||||||
submitEvent.preventDefault()
|
submitEvent.preventDefault()
|
||||||
const eventDate = occurredAt ? new Date(occurredAt).toISOString() : undefined
|
const eventDate = occurredAt ? new Date(occurredAt).toISOString() : undefined
|
||||||
onSave({ ...event, title: title.trim() || 'UNTITLED EVENT', content: narrative.trim() || 'Something happened.', eventDate, supportingEvidenceIds: supports })
|
onSave({ ...event, title: title.trim() || 'UNTITLED EVENT', content: narrative.trim() || 'Something happened.', eventDate }, supports)
|
||||||
}
|
}
|
||||||
return <div className="modal-shade"><form className="window folder-editor event-editor" onSubmit={submit}>
|
return <div className="modal-shade"><form className="window folder-editor event-editor" onSubmit={submit}>
|
||||||
<header><CalendarClock size={16}/><b>Edit reconstructed event</b><span/><button type="button" aria-label="Close event editor" onClick={onClose}><X size={14}/></button></header>
|
<header><CalendarClock size={16}/><b>Edit reconstructed event</b><span/><button type="button" aria-label="Close event editor" onClick={onClose}><X size={14}/></button></header>
|
||||||
@@ -980,15 +1078,18 @@ function EventEditor({ event, evidence, documents, onClose, onSave }: { event: E
|
|||||||
</form></div>
|
</form></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onClose: () => void; onSave: (document: CaseDocument) => void }) {
|
function FileEditor({ document, canEditGates, onClose, onSave }: { document: CaseDocument; canEditGates: boolean; onClose: () => void; onSave: (document: CaseDocument) => void }) {
|
||||||
const [title, setTitle] = useState(document.title)
|
const [title, setTitle] = useState(document.title)
|
||||||
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
|
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
|
||||||
const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt || document.date))
|
const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt))
|
||||||
|
const [requiredFlags, setRequiredFlags] = useState((document.requiredFlags || []).join(', '))
|
||||||
const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value })))
|
const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value })))
|
||||||
const submit = (event: React.FormEvent) => {
|
const submit = (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const publishedAt = publishedTime ? new Date(publishedTime).toISOString() : undefined
|
const publishedAt = publishedTime ? new Date(publishedTime).toISOString() : undefined
|
||||||
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt, date: publishedAt?.slice(0, 10) || '', metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
|
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt,
|
||||||
|
requiredFlags: canEditGates ? [...new Set(requiredFlags.split(',').map(value => value.trim().toLowerCase()).filter(Boolean))] : document.requiredFlags,
|
||||||
|
metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
|
||||||
}
|
}
|
||||||
return <div className="modal-shade"><form className="window file-editor" onSubmit={submit}>
|
return <div className="modal-shade"><form className="window file-editor" onSubmit={submit}>
|
||||||
<header><ImageIcon size={16}/><b>Edit source-file metadata</b><span/><button type="button" aria-label="Close file editor" onClick={onClose}><X size={14}/></button></header>
|
<header><ImageIcon size={16}/><b>Edit source-file metadata</b><span/><button type="button" aria-label="Close file editor" onClick={onClose}><X size={14}/></button></header>
|
||||||
@@ -999,6 +1100,7 @@ function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onC
|
|||||||
<label className="field"><span>FILE TYPE</span><select value={fileType} onChange={event => setFileType(event.target.value as SourceFileType)}>{SOURCE_FILE_TYPES.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
|
<label className="field"><span>FILE TYPE</span><select value={fileType} onChange={event => setFileType(event.target.value as SourceFileType)}>{SOURCE_FILE_TYPES.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
|
||||||
</div>
|
</div>
|
||||||
<label className="field"><span><CalendarClock size={13}/> PUBLISHED TIME · LOCAL</span><input type="datetime-local" value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
|
<label className="field"><span><CalendarClock size={13}/> PUBLISHED TIME · LOCAL</span><input type="datetime-local" value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
|
||||||
|
{canEditGates && <label className="field gate-field"><span>REVEAL FLAGS · ALL REQUIRED</span><input value={requiredFlags} placeholder="tip.received, archive.unlocked" pattern="[a-z0-9_.\-, ]*" onChange={event => setRequiredFlags(event.target.value)}/><small>Leave blank to show this document when the level first loads.</small></label>}
|
||||||
<div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div>
|
<div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div>
|
||||||
<div className="metadata-rows">{metadata.length === 0 && <p>NO ADDITIONAL METADATA</p>}{metadata.map(row => <div className="metadata-row" key={row.id}><input aria-label="Metadata key" placeholder="FIELD" value={row.key} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, key: event.target.value } : candidate))}/><input aria-label="Metadata value" placeholder="VALUE" value={row.value} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, value: event.target.value } : candidate))}/><button type="button" aria-label="Remove metadata field" onClick={() => setMetadata(rows => rows.filter(candidate => candidate.id !== row.id))}><Trash2 size={13}/></button></div>)}</div>
|
<div className="metadata-rows">{metadata.length === 0 && <p>NO ADDITIONAL METADATA</p>}{metadata.map(row => <div className="metadata-row" key={row.id}><input aria-label="Metadata key" placeholder="FIELD" value={row.key} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, key: event.target.value } : candidate))}/><input aria-label="Metadata value" placeholder="VALUE" value={row.value} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, value: event.target.value } : candidate))}/><button type="button" aria-label="Remove metadata field" onClick={() => setMetadata(rows => rows.filter(candidate => candidate.id !== row.id))}><Trash2 size={13}/></button></div>)}</div>
|
||||||
<p className="folder-editor-note">This metadata belongs to the source file, not to any folder that contains it.</p>
|
<p className="folder-editor-note">This metadata belongs to the source file, not to any folder that contains it.</p>
|
||||||
@@ -1007,6 +1109,114 @@ function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onC
|
|||||||
</form></div>
|
</form></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LevelFlagsEditor({ levelId, onClose, onChanged }: { levelId: string; onClose: () => void; onChanged: () => void | Promise<void> }) {
|
||||||
|
const [flags, setFlags] = useState<LevelFlag[]>([])
|
||||||
|
const [newKey, setNewKey] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/flags`)
|
||||||
|
if (!response.ok) throw new Error('Could not load level flags')
|
||||||
|
setFlags(await response.json())
|
||||||
|
}, [levelId])
|
||||||
|
useEffect(() => { void load().catch(error => setError(error instanceof Error ? error.message : 'Could not load flags')) }, [load])
|
||||||
|
const setEarned = async (key: string, earned: boolean) => {
|
||||||
|
setBusy(true); setError('')
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/flags/${encodeURIComponent(key)}`, { method: earned ? 'PUT' : 'DELETE' })
|
||||||
|
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || 'Could not update flag') }
|
||||||
|
await Promise.all([load(), onChanged()])
|
||||||
|
setNewKey('')
|
||||||
|
} catch (error) { setError(error instanceof Error ? error.message : 'Could not update flag') } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const submit = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const key = newKey.trim().toLowerCase()
|
||||||
|
if (key) void setEarned(key, true)
|
||||||
|
}
|
||||||
|
return <div className="modal-shade"><section className="window flags-editor">
|
||||||
|
<header><Network size={16}/><b>Level flags</b><span/><button type="button" aria-label="Close level flags" onClick={onClose}><X size={14}/></button></header>
|
||||||
|
<div className="flags-editor-body"><small>ACHIEVEMENTS / DOCUMENT REVEALS</small>
|
||||||
|
<p>Documents remain server-hidden until every flag assigned in their metadata has been earned.</p>
|
||||||
|
<div className="flag-list">{flags.length === 0 && <div className="flag-empty">NO FLAGS OR DOCUMENT GATES IN THIS LEVEL</div>}{flags.map(flag => <div className={`flag-row ${flag.earnedAt ? 'earned' : ''}`} key={flag.key}><div><b>{flag.key}</b><small>{flag.gatedDocumentCount} GATED DOCUMENT{flag.gatedDocumentCount === 1 ? '' : 'S'}</small></div><button disabled={busy} onClick={() => void setEarned(flag.key, !flag.earnedAt)}>{flag.earnedAt ? 'REVOKE' : 'AWARD'}</button></div>)}</div>
|
||||||
|
<form className="flag-add" onSubmit={submit}><input aria-label="New flag key" value={newKey} placeholder="tip.received" pattern="[a-z][a-z0-9_.-]{0,63}" onChange={event => setNewKey(event.target.value.toLowerCase())}/><button disabled={busy || !newKey.trim()} type="submit">AWARD FLAG</button></form>
|
||||||
|
{error && <p className="flag-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</section></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchRuleDraft = {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
flagKey: string
|
||||||
|
minimumAnchorMatches: number
|
||||||
|
enabled: boolean
|
||||||
|
anchors: { id: string; phrase: string; minimumSimilarity: number }[]
|
||||||
|
}
|
||||||
|
const emptyMatchRule = (): MatchRuleDraft => ({ name: '', flagKey: '', minimumAnchorMatches: 1, enabled: true,
|
||||||
|
anchors: [{ id: uid('anchor'), phrase: '', minimumSimilarity: 0.72 }] })
|
||||||
|
|
||||||
|
function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClose: () => void }) {
|
||||||
|
const [rules, setRules] = useState<EvidenceMatchRuleDefinition[]>([])
|
||||||
|
const [draft, setDraft] = useState<MatchRuleDraft>(emptyMatchRule)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules`)
|
||||||
|
if (!response.ok) throw new Error('Could not load evidence match rules')
|
||||||
|
setRules(await response.json())
|
||||||
|
}, [levelId])
|
||||||
|
useEffect(() => { void load().catch(error => setError(error instanceof Error ? error.message : 'Could not load rules')) }, [load])
|
||||||
|
const edit = (rule: EvidenceMatchRuleDefinition) => setDraft({ id:rule.id,name:rule.name,flagKey:rule.flagKey,
|
||||||
|
minimumAnchorMatches:rule.minimumAnchorMatches,enabled:rule.enabled,
|
||||||
|
anchors:rule.anchors.map(anchor => ({ id:anchor.id,phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) })
|
||||||
|
const submit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault(); setBusy(true); setError('')
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules${draft.id ? `/${encodeURIComponent(draft.id)}` : ''}`, {
|
||||||
|
method: draft.id ? 'PUT' : 'POST', headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name:draft.name,flagKey:draft.flagKey.toLowerCase(),minimumAnchorMatches:draft.minimumAnchorMatches,
|
||||||
|
enabled:draft.enabled,anchors:draft.anchors.map(anchor => ({ phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) }),
|
||||||
|
})
|
||||||
|
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || 'Could not save evidence rule') }
|
||||||
|
await load(); setDraft(emptyMatchRule())
|
||||||
|
} catch (error) { setError(error instanceof Error ? error.message : 'Could not save evidence rule') } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const remove = async (rule: EvidenceMatchRuleDefinition) => {
|
||||||
|
if (!window.confirm(`Delete evidence match rule “${rule.name}”?`)) return
|
||||||
|
setBusy(true); setError('')
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules/${encodeURIComponent(rule.id)}`, { method:'DELETE' })
|
||||||
|
if (!response.ok) throw new Error('Could not delete evidence rule')
|
||||||
|
await load(); if (draft.id === rule.id) setDraft(emptyMatchRule())
|
||||||
|
} catch (error) { setError(error instanceof Error ? error.message : 'Could not delete evidence rule') } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const validAnchors = draft.anchors.filter(anchor => anchor.phrase.trim().length >= 12)
|
||||||
|
const canSave = draft.name.trim() && /^[a-z][a-z0-9_.-]{0,63}$/.test(draft.flagKey) && validAnchors.length === draft.anchors.length
|
||||||
|
&& draft.minimumAnchorMatches >= 1 && draft.minimumAnchorMatches <= draft.anchors.length
|
||||||
|
return <div className="modal-shade"><section className="window match-rules-editor">
|
||||||
|
<header><Search size={16}/><b>Evidence text matching</b><span/><button type="button" aria-label="Close evidence matching" onClick={onClose}><X size={14}/></button></header>
|
||||||
|
<div className="match-rules-body"><small>OCR / FUZZY PASSAGE RULES</small>
|
||||||
|
<p>When OCR from a player-uploaded source matches enough distinctive passages, the configured flag is awarded. Matching ignores case, punctuation, accents, and ordinary OCR noise.</p>
|
||||||
|
<div className="match-rule-layout"><div className="match-rule-list">
|
||||||
|
{rules.length === 0 && <div className="flag-empty">NO AUTOMATIC EVIDENCE RULES</div>}
|
||||||
|
{rules.map(rule => <div className={`match-rule-row ${rule.enabled ? '' : 'disabled'}`} key={rule.id}><div><b>{rule.name}</b><small>{rule.flagKey} · {rule.minimumAnchorMatches}/{rule.anchors.length} ANCHORS</small></div><button type="button" onClick={() => edit(rule)}>EDIT</button><button type="button" disabled={busy} onClick={() => void remove(rule)}><Trash2 size={12}/></button></div>)}
|
||||||
|
</div>
|
||||||
|
<form className="match-rule-form" onSubmit={event => void submit(event)}>
|
||||||
|
<div className="match-rule-form-heading"><b>{draft.id ? 'EDIT RULE' : 'NEW RULE'}</b>{draft.id && <button type="button" onClick={() => setDraft(emptyMatchRule())}>NEW</button>}</div>
|
||||||
|
<label className="field"><span>RULE NAME</span><input value={draft.name} maxLength={160} onChange={event => setDraft(value => ({ ...value,name:event.target.value }))} placeholder="Contemporary fire report"/></label>
|
||||||
|
<div className="match-rule-fields"><label className="field"><span>AWARD FLAG</span><input value={draft.flagKey} pattern="[a-z][a-z0-9_.-]{0,63}" onChange={event => setDraft(value => ({ ...value,flagKey:event.target.value.toLowerCase() }))} placeholder="source.fire-report"/></label>
|
||||||
|
<label className="field"><span>REQUIRED HITS</span><input type="number" min="1" max={draft.anchors.length} value={draft.minimumAnchorMatches} onChange={event => setDraft(value => ({ ...value,minimumAnchorMatches:Number(event.target.value) }))}/></label></div>
|
||||||
|
<label className="match-rule-enabled"><input type="checkbox" checked={draft.enabled} onChange={event => setDraft(value => ({ ...value,enabled:event.target.checked }))}/> ENABLE THIS RULE</label>
|
||||||
|
<div className="anchor-heading"><b>REFERENCE PASSAGES</b><button type="button" onClick={() => setDraft(value => ({ ...value,anchors:[...value.anchors,{ id:uid('anchor'),phrase:'',minimumSimilarity:.72 }] }))}><Plus size={12}/> ADD PASSAGE</button></div>
|
||||||
|
<div className="anchor-list">{draft.anchors.map((anchor,index) => <div className="anchor-row" key={anchor.id}><div><small>ANCHOR {index + 1}</small><textarea value={anchor.phrase} rows={3} placeholder="Paste a distinctive passage of at least 12 characters…" onChange={event => setDraft(value => ({ ...value,anchors:value.anchors.map(item => item.id === anchor.id ? { ...item,phrase:event.target.value } : item) }))}/></div><label><span>SIMILARITY</span><input type="number" min="0.5" max="1" step="0.01" value={anchor.minimumSimilarity} onChange={event => setDraft(value => ({ ...value,anchors:value.anchors.map(item => item.id === anchor.id ? { ...item,minimumSimilarity:Number(event.target.value) } : item) }))}/></label><button type="button" aria-label="Remove reference passage" disabled={draft.anchors.length === 1} onClick={() => setDraft(value => ({ ...value,minimumAnchorMatches:Math.min(value.minimumAnchorMatches,value.anchors.length - 1),anchors:value.anchors.filter(item => item.id !== anchor.id) }))}><Trash2 size={13}/></button></div>)}</div>
|
||||||
|
{error && <p className="flag-error">{error}</p>}
|
||||||
|
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CLOSE</button><button className="primary" type="submit" disabled={busy || !canSave}>{busy ? 'SAVING…' : 'SAVE RULE'}</button></div>
|
||||||
|
</form></div>
|
||||||
|
</div>
|
||||||
|
</section></div>
|
||||||
|
}
|
||||||
|
|
||||||
function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocument; onClose: () => void; onExtract: (id: string) => void; extracted: (string | undefined)[] }) {
|
function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocument; onClose: () => void; onExtract: (id: string) => void; extracted: (string | undefined)[] }) {
|
||||||
const [pos, setPos] = useState({ x: Math.max(280, window.innerWidth * .34), y: 118 })
|
const [pos, setPos] = useState({ x: Math.max(280, window.innerWidth * .34), y: 118 })
|
||||||
const [minimized, setMinimized] = useState(false)
|
const [minimized, setMinimized] = useState(false)
|
||||||
@@ -1019,8 +1229,8 @@ function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocum
|
|||||||
return <section className={`window document-window ${minimized ? 'minimized' : ''}`} style={{ left: pos.x, top: pos.y }}>
|
return <section className={`window document-window ${minimized ? 'minimized' : ''}`} style={{ left: pos.x, top: pos.y }}>
|
||||||
<header onPointerDown={startDrag} onPointerMove={e => drag.current && setPos({ x: drag.current.px + e.clientX - drag.current.x, y: drag.current.py + e.clientY - drag.current.y })} onPointerUp={() => { drag.current = null }} onDoubleClick={() => setMinimized(v => !v)}><FileText size={15}/><b>{doc.title}</b><span/><button type="button" aria-label={minimized ? 'Restore document' : 'Minimize document'} title={minimized ? 'Restore' : 'Minimize'} onPointerDown={e => e.stopPropagation()} onClick={() => setMinimized(v => !v)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close document" title="Close" onPointerDown={e => e.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
|
<header onPointerDown={startDrag} onPointerMove={e => drag.current && setPos({ x: drag.current.px + e.clientX - drag.current.x, y: drag.current.py + e.clientY - drag.current.y })} onPointerUp={() => { drag.current = null }} onDoubleClick={() => setMinimized(v => !v)}><FileText size={15}/><b>{doc.title}</b><span/><button type="button" aria-label={minimized ? 'Restore document' : 'Minimize document'} title={minimized ? 'Restore' : 'Minimize'} onPointerDown={e => e.stopPropagation()} onClick={() => setMinimized(v => !v)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close document" title="Close" onPointerDown={e => e.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
|
||||||
{!minimized && <><nav>FILE EDIT EVIDENCE VIEW</nav>
|
{!minimized && <><nav>FILE EDIT EVIDENCE VIEW</nav>
|
||||||
<div className={`paper ${doc.assetId ? 'asset-paper' : ''}`}><div className="paper-meta"><span>GLITCH UNIVERSITY ARCHIVE</span><b>{doc.kind}</b></div>{doc.assetId ? <DocumentAsset doc={doc}/> : doc.body.map((line, i) => <p key={i}>{line}</p>)}{doc.regions.length > 0 && <div className="extracts">{doc.regions.map(r => <button key={r.id} className={extracted.includes(r.id) ? 'done' : ''} onClick={() => onExtract(r.id)}><Network size={15}/>{extracted.includes(r.id) ? 'LOCATE ON BOARD' : r.label}</button>)}</div>}</div>
|
<div className={`paper ${doc.assetId ? 'asset-paper' : ''}`}><div className="paper-meta"><span>GLITCH UNIVERSITY ARCHIVE</span><b>{documentWidget(doc.fileType).label}</b></div>{doc.assetId ? <DocumentAsset doc={doc}/> : doc.body.map((line, i) => <p key={i}>{line}</p>)}{doc.regions.length > 0 && <div className="extracts">{doc.regions.map(r => <button key={r.id} className={extracted.includes(r.id) ? 'done' : ''} onClick={() => onExtract(r.id)}><Network size={15}/>{extracted.includes(r.id) ? 'LOCATE ON BOARD' : r.label}</button>)}</div>}</div>
|
||||||
<footer><span>ARCHIVE ITEM · {doc.date}</span><span>PROVENANCE LOCKED</span></footer></>}
|
<footer><span>ARCHIVE ITEM · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span><span>PROVENANCE LOCKED</span></footer></>}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+239
@@ -0,0 +1,239 @@
|
|||||||
|
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' | 'assets'>('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))
|
||||||
|
}, [])
|
||||||
|
const reloadMysteries = useCallback(() => json<Mystery[]>('/api/admin/mysteries').then(setMysteries).catch(() => {}), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin) return
|
||||||
|
reloadNpcs().catch(error => setStatus(String(error.message || error)))
|
||||||
|
void reloadMysteries()
|
||||||
|
}, [isAdmin, reloadNpcs, reloadMysteries])
|
||||||
|
|
||||||
|
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>
|
||||||
|
<button className={tab === 'assets' ? 'active' : ''} onClick={() => setTab('assets')}>ASSETS</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); void reloadMysteries() }} setStatus={setStatus} />
|
||||||
|
: <div className="admin-body">
|
||||||
|
<div className="mystery-list">
|
||||||
|
<div className="mystery-list-head"><span>MYSTERIES</span><button onClick={() => newMystery(setStatus, reloadMysteries, setEditingMystery)}>+ NEW</button></div>
|
||||||
|
{mysteries.length === 0 && <p className="admin-empty">No mysteries yet — create one to begin.</p>}
|
||||||
|
{mysteries.map(mystery => <div key={mystery.id} className="mystery-row" onClick={() => setEditingMystery({ id: mystery.id, title: mystery.title })}>
|
||||||
|
<div className="mystery-row-main"><strong>{mystery.title}</strong><span>{mystery.slug} · {mystery.nodes} node{mystery.nodes === 1 ? '' : 's'} · edit graph →</span></div>
|
||||||
|
<button className="mystery-del" title="Delete mystery" onClick={event => { event.stopPropagation(); void deleteMystery(mystery, setStatus, reloadMysteries) }}>×</button>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{tab === 'assets' && <AssetStore setStatus={setStatus} />}
|
||||||
|
|
||||||
|
<footer className="admin-foot">{status || 'READY'}</footer>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value: string) { return value.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') }
|
||||||
|
function formatSize(bytes: number) { return bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${(bytes / 1024).toFixed(0)} KB` : `${(bytes / 1048576).toFixed(1)} MB` }
|
||||||
|
|
||||||
|
async function newMystery(setStatus: (m: string) => void, reload: () => Promise<void>, open: (m: { id: string; title: string }) => void) {
|
||||||
|
const title = window.prompt('Mystery title (e.g. The Glass Harbour Diversion)')?.trim()
|
||||||
|
if (!title) return
|
||||||
|
const slug = window.prompt('URL slug', slugify(title))?.trim()
|
||||||
|
if (!slug) return
|
||||||
|
try {
|
||||||
|
await json('/api/mysteries?edit=1', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slug, title }) })
|
||||||
|
await reload()
|
||||||
|
const created = (await json<Mystery[]>('/api/admin/mysteries')).find(m => m.slug === slugify(slug))
|
||||||
|
if (created) open({ id: created.id, title: created.title })
|
||||||
|
setStatus(`Created ${title}`)
|
||||||
|
} catch (error) { setStatus(String((error as Error).message || error)) }
|
||||||
|
}
|
||||||
|
async function deleteMystery(mystery: Mystery, setStatus: (m: string) => void, reload: () => Promise<void>) {
|
||||||
|
if (!window.confirm(`Delete mystery “${mystery.title}”? This removes its whole story graph.`)) return
|
||||||
|
try { await json(`/api/admin/mysteries/${mystery.id}`, { method: 'DELETE' }); await reload(); setStatus(`Deleted ${mystery.title}`) }
|
||||||
|
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
type Asset = { id: string; originalName: string; mimeType: string; byteSize: number; url: string }
|
||||||
|
function AssetStore({ setStatus }: { setStatus: (m: string) => void }) {
|
||||||
|
const [assets, setAssets] = useState<Asset[]>([])
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
const reload = useCallback(() => json<Asset[]>('/api/admin/assets').then(setAssets).catch(error => setStatus(String((error as Error).message || error))), [setStatus])
|
||||||
|
useEffect(() => { void reload() }, [reload])
|
||||||
|
|
||||||
|
const upload = async (files: FileList) => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
for (const file of Array.from(files)) { const form = new FormData(); form.append('file', file); await json('/api/admin/assets', { method: 'POST', body: form }) }
|
||||||
|
if (fileRef.current) fileRef.current.value = ''
|
||||||
|
await reload(); setStatus(`Uploaded ${files.length} file${files.length === 1 ? '' : 's'}`)
|
||||||
|
} catch (error) { setStatus(String((error as Error).message || error)) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const remove = async (asset: Asset) => {
|
||||||
|
if (!window.confirm(`Delete ${asset.originalName}?`)) return
|
||||||
|
try { await json(`/api/admin/assets/${asset.id}`, { method: 'DELETE' }); await reload(); setStatus('Deleted') }
|
||||||
|
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||||
|
}
|
||||||
|
const copy = (text: string) => { void navigator.clipboard?.writeText(text); setStatus(`Copied ${text}`) }
|
||||||
|
|
||||||
|
return <div className="admin-body">
|
||||||
|
<div className="asset-store">
|
||||||
|
<div className="mystery-list-head"><span>ASSET LIBRARY · images · audio · pdf</span>
|
||||||
|
<label className="asset-upload-btn">{busy ? 'UPLOADING…' : '+ UPLOAD'}
|
||||||
|
<input ref={fileRef} type="file" accept="image/*,audio/*,application/pdf" multiple onChange={event => { if (event.target.files?.length) void upload(event.target.files) }} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{assets.length === 0 && <p className="admin-empty">No assets yet. Upload images, audio, or PDFs.</p>}
|
||||||
|
<div className="asset-grid">
|
||||||
|
{assets.map(asset => <figure key={asset.id} className="asset-card">
|
||||||
|
<div className="asset-thumb">{asset.mimeType.startsWith('image/')
|
||||||
|
? <img src={asset.url} alt="" />
|
||||||
|
: <span className="asset-icon">{asset.mimeType.startsWith('audio/') ? '♪' : asset.mimeType === 'application/pdf' ? 'PDF' : 'FILE'}</span>}</div>
|
||||||
|
<figcaption title={asset.originalName}>{asset.originalName}</figcaption>
|
||||||
|
<small>{formatSize(asset.byteSize)}</small>
|
||||||
|
<div className="asset-actions">
|
||||||
|
<button onClick={() => copy(asset.id)} title="Copy asset id">id</button>
|
||||||
|
<button onClick={() => copy(asset.url)} title="Copy URL">url</button>
|
||||||
|
<button className="asset-del" onClick={() => remove(asset)} title="Delete (blocked if in use)">×</button>
|
||||||
|
</div>
|
||||||
|
</figure>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
}
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
// Lightweight game audio: one looping "scene music" track plus synthesized one-shot
|
||||||
|
// SFX. Zero dependencies. Browsers block autoplay until a user gesture, so music/SFX
|
||||||
|
// are primed on the first pointer interaction.
|
||||||
|
type Sfx = 'advance' | 'choice' | 'sting'
|
||||||
|
let baseVolume = 0.5 // authored scene volume (0-1); mute overrides to 0
|
||||||
|
|
||||||
|
let ctx: AudioContext | null = null
|
||||||
|
function audioContext() {
|
||||||
|
if (!ctx) { const AC = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (AC) ctx = new AC() }
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
let noiseBuffer: AudioBuffer | null = null
|
||||||
|
function noise(context: AudioContext) {
|
||||||
|
if (!noiseBuffer) {
|
||||||
|
const length = Math.floor(context.sampleRate * 0.05)
|
||||||
|
noiseBuffer = context.createBuffer(1, length, context.sampleRate)
|
||||||
|
const data = noiseBuffer.getChannelData(0)
|
||||||
|
for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1
|
||||||
|
}
|
||||||
|
return noiseBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
const music = typeof Audio !== 'undefined' ? new Audio() : null
|
||||||
|
if (music) music.loop = true
|
||||||
|
let currentUrl: string | null = null
|
||||||
|
let muted = false
|
||||||
|
let pending = false
|
||||||
|
let fadeTimer: number | undefined
|
||||||
|
|
||||||
|
function fade(target: number, done?: () => void) {
|
||||||
|
if (!music) return
|
||||||
|
window.clearInterval(fadeTimer)
|
||||||
|
const step = (target - music.volume) / 12 || (target > music.volume ? 0.1 : -0.1)
|
||||||
|
fadeTimer = window.setInterval(() => {
|
||||||
|
if (!music) return
|
||||||
|
const next = music.volume + step
|
||||||
|
if ((step >= 0 && next >= target) || (step <= 0 && next <= target)) { music.volume = target; window.clearInterval(fadeTimer); done?.() }
|
||||||
|
else music.volume = Math.max(0, Math.min(1, next))
|
||||||
|
}, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMusic() {
|
||||||
|
if (!music || !currentUrl || muted) return
|
||||||
|
const promise = music.play()
|
||||||
|
if (promise) promise.then(() => { pending = false; fade(baseVolume) }).catch(() => { pending = true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const audio = {
|
||||||
|
// A null url inherits whatever is already playing; a new url crossfades to it.
|
||||||
|
// The same url with a new volume just adjusts the level (no restart).
|
||||||
|
setMusic(url: string | null, volume?: number) {
|
||||||
|
if (!music) return
|
||||||
|
if (volume !== undefined) baseVolume = Math.max(0, Math.min(1, volume))
|
||||||
|
if (url === currentUrl) { if (volume !== undefined && currentUrl && !muted) fade(baseVolume); return }
|
||||||
|
currentUrl = url
|
||||||
|
if (!url) { fade(0, () => music.pause()); pending = false; return }
|
||||||
|
music.src = url
|
||||||
|
music.volume = 0
|
||||||
|
startMusic()
|
||||||
|
},
|
||||||
|
toggleMute() {
|
||||||
|
muted = !muted
|
||||||
|
if (muted) fade(0)
|
||||||
|
else if (currentUrl) { if (music && music.paused) startMusic(); else fade(baseVolume) }
|
||||||
|
return muted
|
||||||
|
},
|
||||||
|
isMuted() { return muted },
|
||||||
|
sfx(kind: Sfx) {
|
||||||
|
if (muted) return
|
||||||
|
const context = audioContext()
|
||||||
|
if (!context) return
|
||||||
|
// Called from a click, so we can unlock the context right here if it's suspended.
|
||||||
|
if (context.state === 'suspended') void context.resume()
|
||||||
|
const osc = context.createOscillator(), gain = context.createGain()
|
||||||
|
osc.type = 'triangle'
|
||||||
|
const now = context.currentTime + 0.01
|
||||||
|
const base = kind === 'choice' ? 500 : kind === 'sting' ? 260 : 420
|
||||||
|
osc.frequency.setValueAtTime(base, now)
|
||||||
|
if (kind === 'choice') osc.frequency.exponentialRampToValueAtTime(base * 1.5, now + 0.09) // a little up-chirp
|
||||||
|
gain.gain.setValueAtTime(0.0001, now)
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.16, now + 0.012)
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.16)
|
||||||
|
osc.connect(gain).connect(context.destination)
|
||||||
|
osc.start(now); osc.stop(now + 0.18)
|
||||||
|
},
|
||||||
|
// A dry typewriter key-clack, for the text-reveal clatter. Short filtered noise
|
||||||
|
// burst with slight pitch jitter so successive keys differ.
|
||||||
|
type() {
|
||||||
|
if (muted) return
|
||||||
|
const context = audioContext()
|
||||||
|
if (!context) return
|
||||||
|
if (context.state === 'suspended') void context.resume()
|
||||||
|
const src = context.createBufferSource(); src.buffer = noise(context)
|
||||||
|
const filter = context.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.value = 1500 + Math.random() * 900; filter.Q.value = 0.9
|
||||||
|
const gain = context.createGain()
|
||||||
|
const now = context.currentTime
|
||||||
|
gain.gain.setValueAtTime(0.06, now)
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.028)
|
||||||
|
src.connect(filter).connect(gain).connect(context.destination)
|
||||||
|
src.start(now); src.stop(now + 0.04)
|
||||||
|
},
|
||||||
|
// Resume the context and retry pending music on a user gesture.
|
||||||
|
resume() {
|
||||||
|
void audioContext()?.resume?.()
|
||||||
|
if (pending) startMusic()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') window.addEventListener('pointerdown', () => audio.resume())
|
||||||
+37
-32
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { CaseState, Evidence, WidgetRelation } from './types'
|
import type { CaseState, ExhibitRelation, FolderExhibit } from './types'
|
||||||
import {
|
import {
|
||||||
MAX_BOARD_ZOOM,
|
MAX_BOARD_ZOOM,
|
||||||
MIN_BOARD_ZOOM,
|
MIN_BOARD_ZOOM,
|
||||||
@@ -49,7 +49,7 @@ describe('red thread geometry', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const folder: Evidence = {
|
const folder: FolderExhibit = {
|
||||||
id: 'folder-1',
|
id: 'folder-1',
|
||||||
type: 'folder',
|
type: 'folder',
|
||||||
title: 'Folder',
|
title: 'Folder',
|
||||||
@@ -57,7 +57,11 @@ const folder: Evidence = {
|
|||||||
x: 200,
|
x: 200,
|
||||||
y: 300,
|
y: 300,
|
||||||
width: 260,
|
width: 260,
|
||||||
config: { open: false },
|
height: 166,
|
||||||
|
rotation: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
hidden: false,
|
||||||
|
isOpen: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
const state: CaseState = {
|
const state: CaseState = {
|
||||||
@@ -65,11 +69,12 @@ const state: CaseState = {
|
|||||||
id: 'test-level',
|
id: 'test-level',
|
||||||
title: 'Test',
|
title: 'Test',
|
||||||
subtitle: '',
|
subtitle: '',
|
||||||
documents: [],
|
exhibits: [folder],
|
||||||
evidence: [folder],
|
|
||||||
relations: [],
|
relations: [],
|
||||||
connections: [],
|
connections: [],
|
||||||
viewport: { x: 10, y: 20, zoom: 0.5 },
|
viewport: { x: 10, y: 20, zoom: 0.5 },
|
||||||
|
views: [],
|
||||||
|
revision: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('board coordinate math', () => {
|
describe('board coordinate math', () => {
|
||||||
@@ -109,10 +114,10 @@ describe('board coordinate math', () => {
|
|||||||
it('places new exhibits in deterministic open slots', () => {
|
it('places new exhibits in deterministic open slots', () => {
|
||||||
const preferred = { x: 500, y: 400 }
|
const preferred = { x: 500, y: 400 }
|
||||||
expect(nextOpenBoardPosition([], preferred, { width: 280 })).toEqual(preferred)
|
expect(nextOpenBoardPosition([], preferred, { width: 280 })).toEqual(preferred)
|
||||||
expect(nextOpenBoardPosition([{ x: 500, y: 400, width: 280 }], preferred, { width: 280 })).toEqual({ x: 826, y: 400 })
|
expect(nextOpenBoardPosition([{ x: 500, y: 400, width: 280, height: 160 }], preferred, { width: 280 })).toEqual({ x: 826, y: 400 })
|
||||||
expect(nextOpenBoardPosition([
|
expect(nextOpenBoardPosition([
|
||||||
{ x: 500, y: 400, width: 280 },
|
{ x: 500, y: 400, width: 280, height: 160 },
|
||||||
{ x: 826, y: 400, width: 280 },
|
{ x: 826, y: 400, width: 280, height: 160 },
|
||||||
], preferred, { width: 280 })).toEqual({ x: 174, y: 400 })
|
], preferred, { width: 280 })).toEqual({ x: 174, y: 400 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -147,25 +152,24 @@ describe('timeline projection', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('folder domain behavior', () => {
|
describe('folder domain behavior', () => {
|
||||||
const relations: WidgetRelation[] = [
|
const relations: ExhibitRelation[] = [
|
||||||
{ id: 'later', fromWidgetId: folder.id, toWidgetId: 'doc-2', type: 'contains', sortOrder: 2 },
|
{ id: 'later', fromExhibitId: folder.id, toExhibitId: 'doc-2', type: 'contains', sortOrder: 2 },
|
||||||
{ id: 'other', fromWidgetId: 'folder-2', toWidgetId: 'doc-x', type: 'contains', sortOrder: 0 },
|
{ id: 'other', fromExhibitId: 'folder-2', toExhibitId: 'doc-x', type: 'contains', sortOrder: 0 },
|
||||||
{ id: 'first', fromWidgetId: folder.id, toWidgetId: 'doc-1', 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', () => {
|
it('orders and scopes contained documents by their normalized relations', () => {
|
||||||
expect(containedIds({ ...state, relations }, folder.id)).toEqual(['doc-1', 'doc-2'])
|
expect(containedIds({ ...state, relations }, folder.id)).toEqual(['doc-1', 'doc-2'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('only treats an explicit boolean true as open', () => {
|
it('stores the open state explicitly on the folder exhibit', () => {
|
||||||
expect(folderIsOpen(folder)).toBe(false)
|
expect(folderIsOpen(folder)).toBe(false)
|
||||||
expect(folderIsOpen({ ...folder, config: { open: true } })).toBe(true)
|
expect(folderIsOpen({ ...folder, isOpen: true })).toBe(true)
|
||||||
expect(folderIsOpen({ ...folder, config: { open: 'true' } })).toBe(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('retains a configured expanded file position', () => {
|
it('retains a configured expanded file position', () => {
|
||||||
const relation = { ...relations[0], config: { x: 720, y: 415 } }
|
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, relations }, relation)).toEqual({ x: 720, y: 415 })
|
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', () => {
|
it('derives a deterministic position when a relation has not been moved', () => {
|
||||||
@@ -175,38 +179,39 @@ describe('folder domain behavior', () => {
|
|||||||
|
|
||||||
it('normalizes legacy containment without changing source ownership', () => {
|
it('normalizes legacy containment without changing source ownership', () => {
|
||||||
const legacy = {
|
const legacy = {
|
||||||
...state,
|
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' }],
|
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 }],
|
evidence: [{ ...folder, type: 'evidence', sourceDocumentId: 'doc-1', containedDocumentIds: ['doc-1'], config: undefined }],
|
||||||
relations: undefined,
|
relations: undefined,
|
||||||
} as unknown as CaseState
|
}
|
||||||
const normalized = normalizeCase(legacy)
|
const normalized = normalizeCase(legacy)
|
||||||
expect(normalized.evidence[0]).toMatchObject({ type: 'folder', config: {}, containedDocumentIds: ['doc-1'] })
|
expect(normalized.exhibits.find(exhibit => exhibit.type === 'folder')).toMatchObject({ type: 'folder', isOpen: false })
|
||||||
expect(normalized.documents[0]).toMatchObject({ fileType: 'image', metadata: {} })
|
expect(normalized.exhibits.find(exhibit => exhibit.type === 'document')).toMatchObject({ fileType: 'image', metadata: {} })
|
||||||
expect(normalized.relations).toHaveLength(1)
|
expect(normalized.relations).toHaveLength(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('exhibit disposal', () => {
|
describe('exhibit disposal', () => {
|
||||||
it('removes an exhibit and every graph reference while retaining source documents', () => {
|
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 note = { ...folder, id: 'note-1', type: 'note' as const, title: 'Working note' }
|
||||||
const event = { ...folder, id: 'event-1', type: 'event' as const, supportingEvidenceIds: [note.id, 'doc-1'] }
|
const event = { ...folder, id: 'event-1', type: 'event' as const, eventDate: undefined }
|
||||||
const party = { ...folder, id: 'party-1', type: 'party' as const, relatedEvidenceIds: [note.id] }
|
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({
|
const discarded = discardExhibit({
|
||||||
...state,
|
...state,
|
||||||
documents: [{ id: 'doc-1', title: 'Source', kind: 'TEXT', date: '', body: [], regions: [], fileType: 'text', metadata: {} }],
|
exhibits: [folder, document, note, event, party],
|
||||||
evidence: [folder, note, event, party],
|
relations: [
|
||||||
relations: [{ id: 'nested', fromWidgetId: folder.id, toWidgetId: note.id, type: 'contains' }],
|
{ id: 'supports', fromExhibitId: event.id, toExhibitId: note.id, type: 'supports', sortOrder: 0 },
|
||||||
connections: [{ id: 'thread', fromEvidenceId: note.id, toEvidenceId: party.id }],
|
{ 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 }] },
|
brief: { body: '', concepts: [{ id: 'concept-1', label: 'Unknown', context: '', resolvedPartyExhibitId: note.id }] },
|
||||||
}, note.id)
|
}, note.id)
|
||||||
|
|
||||||
expect(discarded.documents).toHaveLength(1)
|
expect(discarded.exhibits).toContainEqual(document)
|
||||||
expect(discarded.evidence.map(exhibit => exhibit.id)).not.toContain(note.id)
|
expect(discarded.exhibits.map(exhibit => exhibit.id)).not.toContain(note.id)
|
||||||
expect(discarded.relations).toEqual([])
|
expect(discarded.relations).toEqual([])
|
||||||
expect(discarded.connections).toEqual([])
|
expect(discarded.connections).toEqual([])
|
||||||
expect(discarded.evidence.find(exhibit => exhibit.id === event.id)?.supportingEvidenceIds).toEqual(['doc-1'])
|
|
||||||
expect(discarded.evidence.find(exhibit => exhibit.id === party.id)?.relatedEvidenceIds).toEqual([])
|
|
||||||
expect(discarded.brief.concepts[0].resolvedPartyExhibitId).toBeUndefined()
|
expect(discarded.brief.concepts[0].resolvedPartyExhibitId).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+103
-49
@@ -1,4 +1,4 @@
|
|||||||
import type { CaseState, Evidence, TimelineRange, Viewport, WidgetRelation } from './types'
|
import type { BoardView, CaseState, Connection, Exhibit, ExhibitRelation, FolderExhibit, OrganizationKind, SourceFileType, TimelineRange, Viewport } from './types'
|
||||||
|
|
||||||
export interface BoardPoint { x: number; y: number }
|
export interface BoardPoint { x: number; y: number }
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ export function moveBoardPoint(origin: { x: number; y: number }, screenDelta: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function nextOpenBoardPosition(
|
export function nextOpenBoardPosition(
|
||||||
evidence: Pick<Evidence, 'x' | 'y' | 'width'>[],
|
evidence: Pick<Exhibit, 'x' | 'y' | 'width' | 'height'>[],
|
||||||
preferred: { x: number; y: number },
|
preferred: { x: number; y: number },
|
||||||
size: { width: number; height?: number },
|
size: { width: number; height?: number },
|
||||||
bounds = { width: 2400, height: 1500 },
|
bounds = { width: 2400, height: 1500 },
|
||||||
@@ -108,7 +108,7 @@ export function nextOpenBoardPosition(
|
|||||||
const gap = 28
|
const gap = 28
|
||||||
const overlaps = (x: number, y: number) => evidence.some(item =>
|
const overlaps = (x: number, y: number) => evidence.some(item =>
|
||||||
x < item.x + item.width + gap && x + size.width + gap > item.x &&
|
x < item.x + item.width + gap && x + size.width + gap > item.x &&
|
||||||
y < item.y + 160 + gap && y + height + gap > item.y)
|
y < item.y + item.height + gap && y + height + gap > item.y)
|
||||||
const xStep = size.width + 46
|
const xStep = size.width + 46
|
||||||
const yStep = height + 46
|
const yStep = height + 46
|
||||||
for (let row = 0; row < 7; row += 1) {
|
for (let row = 0; row < 7; row += 1) {
|
||||||
@@ -158,40 +158,33 @@ export function timelinePositionPercent(date: string, range: Pick<ReturnType<typ
|
|||||||
|
|
||||||
export function containedIds(state: CaseState, widgetId: string) {
|
export function containedIds(state: CaseState, widgetId: string) {
|
||||||
return state.relations
|
return state.relations
|
||||||
.filter(relation => relation.type === 'contains' && relation.fromWidgetId === widgetId)
|
.filter(relation => relation.type === 'contains' && relation.fromExhibitId === widgetId)
|
||||||
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
||||||
.map(relation => relation.toWidgetId)
|
.map(relation => relation.toExhibitId)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function folderIsOpen(folder: Evidence) {
|
export function folderIsOpen(folder: FolderExhibit) {
|
||||||
return folder.config?.open === true
|
return folder.isOpen
|
||||||
}
|
}
|
||||||
|
|
||||||
export function relationPosition(state: CaseState, relation: WidgetRelation) {
|
export function relationPosition(state: CaseState, relation: ExhibitRelation) {
|
||||||
const folder = state.evidence.find(widget => widget.id === relation.fromWidgetId)
|
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
|
const order = relation.sortOrder || 0
|
||||||
const configuredX = Number(relation.config?.x)
|
|
||||||
const configuredY = Number(relation.config?.y)
|
|
||||||
return {
|
return {
|
||||||
x: Number.isFinite(configuredX) ? configuredX : (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205,
|
x: (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205,
|
||||||
y: Number.isFinite(configuredY) ? configuredY : (folder?.y || 100) - 30 + Math.floor(order / 3) * 185,
|
y: (folder?.y || 100) - 30 + Math.floor(order / 3) * 185,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function discardExhibit(state: CaseState, exhibitId: string): CaseState {
|
export function discardExhibit(state: CaseState, exhibitId: string): CaseState {
|
||||||
if (!state.evidence.some(exhibit => exhibit.id === exhibitId)) return state
|
if (!state.exhibits.some(exhibit => exhibit.id === exhibitId)) return state
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
evidence: state.evidence
|
exhibits: state.exhibits.filter(exhibit => exhibit.id !== exhibitId),
|
||||||
.filter(exhibit => exhibit.id !== exhibitId)
|
relations: state.relations.filter(relation => relation.fromExhibitId !== exhibitId && relation.toExhibitId !== exhibitId),
|
||||||
.map(exhibit => ({
|
connections: state.connections.filter(connection => connection.fromExhibitId !== exhibitId && connection.toExhibitId !== exhibitId),
|
||||||
...exhibit,
|
|
||||||
containedDocumentIds: exhibit.containedDocumentIds?.filter(id => id !== exhibitId),
|
|
||||||
supportingEvidenceIds: exhibit.supportingEvidenceIds?.filter(id => id !== exhibitId),
|
|
||||||
relatedEvidenceIds: exhibit.relatedEvidenceIds?.filter(id => id !== exhibitId),
|
|
||||||
})),
|
|
||||||
relations: state.relations.filter(relation => relation.fromWidgetId !== exhibitId && relation.toWidgetId !== exhibitId),
|
|
||||||
connections: state.connections.filter(connection => connection.fromEvidenceId !== exhibitId && connection.toEvidenceId !== exhibitId),
|
|
||||||
brief: {
|
brief: {
|
||||||
...state.brief,
|
...state.brief,
|
||||||
concepts: state.brief.concepts.map(concept => concept.resolvedPartyExhibitId === exhibitId
|
concepts: state.brief.concepts.map(concept => concept.resolvedPartyExhibitId === exhibitId
|
||||||
@@ -201,30 +194,91 @@ export function discardExhibit(state: CaseState, exhibitId: string): CaseState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeCase(state: CaseState): CaseState {
|
export function defaultTimelineView(range?: TimelineRange | null): BoardView {
|
||||||
const relations = Array.isArray(state.relations)
|
return { id: crypto.randomUUID(), type: 'timeline', placement: { mode: 'docked', dockEdge: 'bottom', size: 112 }, visible: true, zIndex: 0,
|
||||||
? state.relations
|
rangeMode: range ? 'fixed' : 'auto', range: range || undefined }
|
||||||
: state.evidence.flatMap(widget => (widget.containedDocumentIds || (widget.sourceDocumentId ? [widget.sourceDocumentId] : [])).map((documentId, index) => ({
|
}
|
||||||
id: `contains:${widget.id}:${documentId}`,
|
|
||||||
fromWidgetId: widget.id,
|
type LegacyCaseState = {
|
||||||
toWidgetId: documentId,
|
id: string
|
||||||
type: 'contains',
|
title: string
|
||||||
sortOrder: index,
|
subtitle: string
|
||||||
})))
|
viewport: Viewport
|
||||||
const normalized = { ...state, relations }
|
brief?: CaseState['brief']
|
||||||
return {
|
updatedAt?: string
|
||||||
...normalized,
|
levelStatus?: string
|
||||||
brief: state.brief || { body: '', concepts: [] },
|
sourceTemplateVersionId?: string
|
||||||
documents: state.documents.map(document => ({
|
editingAllowed?: boolean
|
||||||
...document,
|
revision?: number
|
||||||
fileType: document.fileType || (document.mimeType?.startsWith('image/') ? 'image' : document.mimeType === 'application/pdf' ? 'pdf' : 'file'),
|
newlyVisibleDocumentIds?: string[]
|
||||||
metadata: document.metadata || {},
|
exhibits?: Exhibit[]
|
||||||
})),
|
views?: BoardView[]
|
||||||
evidence: state.evidence.map(widget => ({
|
documents?: Array<Record<string, unknown>>
|
||||||
...widget,
|
evidence?: Array<Record<string, unknown>>
|
||||||
type: widget.type === 'evidence' ? 'folder' : widget.type,
|
timelineRange?: TimelineRange | null
|
||||||
config: widget.config || {},
|
relations?: Array<Record<string, unknown>>
|
||||||
containedDocumentIds: containedIds(normalized, widget.id),
|
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,
|
||||||
|
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [] }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { EvidenceType, SourceFileType } from './types'
|
import type { SourceFileType } from './types'
|
||||||
import { documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetRegistry } from './exhibitRegistry'
|
import { documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetRegistry } from './exhibitRegistry'
|
||||||
|
|
||||||
describe('frontend exhibit registry', () => {
|
describe('frontend exhibit registry', () => {
|
||||||
it('registers every API exhibit type and keeps legacy evidence on the folder renderer', () => {
|
it('registers every normalized exhibit type', () => {
|
||||||
const types: EvidenceType[] = ['folder', 'evidence', 'note', 'event', 'party']
|
const types = ['folder', 'document', 'note', 'event', 'party'] as const
|
||||||
expect(Object.keys(exhibitWidgetRegistry).sort()).toEqual(types.sort())
|
expect(Object.keys(exhibitWidgetRegistry).sort()).toEqual([...types].sort())
|
||||||
expect(exhibitWidget('evidence')).toBe(exhibitWidget('folder'))
|
expect(exhibitWidget('event').heading({} as never, { exhibits: [], relations: [], dispatch: () => {} })).toContain('THIS HAPPENED')
|
||||||
expect(exhibitWidget('event').heading({} as never, [])).toContain('THIS HAPPENED')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('registers every normalized document type with an explicit renderer', () => {
|
it('registers every normalized document type with an explicit renderer', () => {
|
||||||
|
|||||||
+92
-81
@@ -1,118 +1,129 @@
|
|||||||
import type { ComponentType } from 'react'
|
import type { ComponentType } from 'react'
|
||||||
import { BookOpen, Building2, CalendarClock, FileText, Folder, FolderOpen, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
|
import { BookOpen, Building2, CalendarClock, FileText, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
|
||||||
import type { CaseDocument, Evidence, EvidenceType, SourceFileType } from './types'
|
import type { CaseDocument, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, SourceFileType, TemporalFact } from './types'
|
||||||
import { folderIsOpen } from './boardDomain'
|
|
||||||
|
|
||||||
export type ExhibitWidgetProps = {
|
export type WidgetCommand =
|
||||||
exhibit: Evidence
|
| { type: 'open-document'; documentId: string }
|
||||||
documents: CaseDocument[]
|
| { type: 'edit-folder'; folderId: string }
|
||||||
onOpenSource: (id: string) => void
|
| { type: 'edit-event'; eventId: string }
|
||||||
onToggleFolder: (id: string) => void
|
| { type: 'edit-party'; partyId: string }
|
||||||
onEditFolder: (id: string) => void
|
| { type: 'edit-document'; documentId: string }
|
||||||
onEditEvent: (id: string) => void
|
| { type: 'update-memory-cue'; documentId: string; cue: string }
|
||||||
onEditParty: (id: string) => void
|
|
||||||
|
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 = {
|
export type ExhibitWidgetDefinition = {
|
||||||
visualType: Exclude<EvidenceType, 'evidence'>
|
modelKind: 'exhibit'
|
||||||
heading: (exhibit: Evidence, documents: CaseDocument[]) => string
|
visualType: ExhibitType
|
||||||
connectionPoint: (exhibit: Evidence) => { x: number; y: number }
|
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>
|
Component: ComponentType<ExhibitWidgetProps>
|
||||||
}
|
}
|
||||||
|
|
||||||
function FolderWidget({ exhibit, documents, onOpenSource, onToggleFolder, onEditFolder }: 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>
|
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
|
||||||
{!folderIsOpen(exhibit) && <div className="folder-documents">{documents.slice(0, 3).map(document => <button key={document.id} onClick={() => onOpenSource(document.id)} title={document.title}><FileText size={12}/><span>{document.title}</span>{(document.publishedAt || document.date) && <time>{(document.publishedAt || document.date).slice(0, 10)}</time>}</button>)}{documents.length > 3 && <small>+ {documents.length - 3} MORE FILES</small>}</div>}
|
<div className="folder-actions"><button onClick={() => context.dispatch({ type:'edit-folder',folderId:exhibit.id })}><Pencil size={15}/> EDIT</button></div>
|
||||||
<div className="folder-actions"><button onClick={() => onToggleFolder(exhibit.id)}>{folderIsOpen(exhibit) ? <Folder size={12}/> : <FolderOpen size={12}/>} {folderIsOpen(exhibit) ? 'CLOSE' : 'OPEN'}</button><button onClick={() => onEditFolder(exhibit.id)}><Pencil size={12}/> EDIT</button></div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function StandardWidget({ exhibit, onOpenSource }: ExhibitWidgetProps) {
|
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>
|
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
|
||||||
{exhibit.eventDate && <time>{exhibit.eventDate.replaceAll('-', ' / ')}</time>}
|
{source && <button onClick={() => context.dispatch({ type:'open-document',documentId:source.toExhibitId })}><BookOpen size={13}/> VIEW SOURCE</button>}
|
||||||
{exhibit.sourceDocumentId && <button onClick={() => onOpenSource(exhibit.sourceDocumentId!)}><BookOpen size={13}/> VIEW SOURCE</button>}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventWidget({ exhibit, onEditEvent }: ExhibitWidgetProps) {
|
function EventWidget({ exhibit, context }: ExhibitWidgetProps) {
|
||||||
const supportCount = exhibit.supportingEvidenceIds?.length || 0
|
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>
|
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
|
||||||
<time>{exhibit.eventDate ? new Date(exhibit.eventDate).toLocaleString() : 'UNDATED'}</time>
|
<time>{exhibit.eventDate ? new Date(exhibit.eventDate).toLocaleString() : 'UNDATED'}</time>
|
||||||
<div className="event-actions"><span>{supportCount} SUPPORTING EXHIBIT{supportCount === 1 ? '' : 'S'}</span><button onClick={() => onEditEvent(exhibit.id)}><CalendarClock size={12}/> EDIT EVENT</button></div>
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function PartyWidget({ exhibit, onEditParty }: ExhibitWidgetProps) {
|
function PartyWidget({ exhibit, context }: ExhibitWidgetProps) {
|
||||||
|
if (exhibit.type !== 'party') return null
|
||||||
const person = exhibit.partyKind === 'person'
|
const person = exhibit.partyKind === 'person'
|
||||||
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>
|
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>
|
<p>{exhibit.content || 'No dossier summary yet.'}</p>
|
||||||
{(exhibit.aliases?.length || 0) > 0 && <div className="party-aliases">AKA · {exhibit.aliases!.join(' · ')}</div>}
|
{exhibit.aliases.length > 0 && <div className="party-aliases">AKA · {exhibit.aliases.join(' · ')}</div>}
|
||||||
<div className="event-actions"><span>{exhibit.relatedEvidenceIds?.length || 0} ASSOCIATED EXHIBITS</span><button onClick={() => onEditParty(exhibit.id)}><Pencil size={12}/> EDIT DOSSIER</button></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>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
const standardPoint = (exhibit: Evidence) => ({ x: exhibit.x + exhibit.width / 2, y: exhibit.y + 68 })
|
function DocumentWidget({ exhibit }: ExhibitWidgetProps) {
|
||||||
const folderDefinition: ExhibitWidgetDefinition = {
|
if (exhibit.type !== 'document') return null
|
||||||
visualType: 'folder', heading: (_exhibit, documents) => `EVIDENCE FOLDER / ${documents.length}`,
|
return <strong>{exhibit.title}</strong>
|
||||||
connectionPoint: standardPoint, Component: FolderWidget,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const exhibitWidgetRegistry: Record<EvidenceType, ExhibitWidgetDefinition> = {
|
const standardPorts = (exhibit: Exhibit) => [{ id:'centre',x:exhibit.x + exhibit.width / 2,y:exhibit.y + exhibit.height / 2 }]
|
||||||
folder: folderDefinition,
|
const notePorts = (exhibit: Exhibit) => [{ id:'knot',x:exhibit.x + exhibit.width / 2,y:exhibit.y + 12 }]
|
||||||
evidence: folderDefinition,
|
const standardCapabilities: WidgetCapabilities = { movable:true,resizable:false,connectable:true,discardable:true,dockable:false }
|
||||||
note: { visualType: 'note', heading: () => 'INVESTIGATOR / NOTE', connectionPoint: exhibit => ({ x: exhibit.x + 54, y: exhibit.y + 12 }), Component: StandardWidget },
|
const searchable = (exhibit: Exhibit) => exhibit.type === 'document'
|
||||||
event: { visualType: 'event', heading: () => 'EVENT / THIS HAPPENED', connectionPoint: standardPoint, Component: EventWidget },
|
? [exhibit.title,...exhibit.body,...Object.values(exhibit.metadata)].join('\n').toLocaleLowerCase()
|
||||||
party: { visualType: 'party', heading: exhibit => exhibit.partyKind === 'person' ? 'PARTY / PERSON DOSSIER' : 'PARTY / ORGANIZATION DOSSIER', connectionPoint: standardPoint, Component: PartyWidget },
|
: [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: EvidenceType) {
|
export function exhibitWidget(type: ExhibitType) { return exhibitWidgetRegistry[type] }
|
||||||
return exhibitWidgetRegistry[type] || exhibitWidgetRegistry.note
|
|
||||||
}
|
|
||||||
|
|
||||||
type DocumentWidgetProps = { document: CaseDocument; source: string; onMemoryCue?: (cue: string) => void }
|
type DocumentWidgetProps = { document: CaseDocument; source: string; onMemoryCue?: (cue: string) => void }
|
||||||
export type DocumentWidgetDefinition = {
|
export type DocumentWidgetDefinition = { label:string; Preview:ComponentType<DocumentWidgetProps>; Asset:ComponentType<DocumentWidgetProps> }
|
||||||
label: string
|
|
||||||
Preview: ComponentType<DocumentWidgetProps>
|
|
||||||
Asset: ComponentType<DocumentWidgetProps>
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImagePreview({ document, source }: DocumentWidgetProps) {
|
function ImagePreview({ document,source }: DocumentWidgetProps) { return document.assetId ? <img draggable={false} src={source} alt=""/> : <GenericPreview document={document} source={source}/> }
|
||||||
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) {
|
||||||
function GenericPreview({ document }: DocumentWidgetProps) {
|
const excerpt = document.body.filter(Boolean).slice(0,2).join(' ')
|
||||||
return <div><ImageIcon size={35}/><small>{document.kind}</small></div>
|
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 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>
|
|
||||||
}
|
}
|
||||||
|
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 })
|
const genericDocument = (label:string):DocumentWidgetDefinition => ({label,Preview:GenericPreview,Asset:GenericAsset})
|
||||||
export const documentWidgetRegistry: Record<SourceFileType, DocumentWidgetDefinition> = {
|
export const documentWidgetRegistry:Record<SourceFileType,DocumentWidgetDefinition> = {
|
||||||
image: { label: 'Image', Preview: ImagePreview, Asset: ImageAsset },
|
image:{label:'Image',Preview:ImagePreview,Asset:ImageAsset},pdf:{label:'PDF',Preview:GenericPreview,Asset:FrameAsset},text:{label:'Text document',Preview:TextPreview,Asset:FrameAsset},
|
||||||
pdf: { label: 'PDF', Preview: GenericPreview, 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'),
|
||||||
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 documentWidget(type: SourceFileType) {
|
export function documentExhibits(exhibits:Exhibit[]):DocumentExhibit[] { return exhibits.filter((exhibit):exhibit is DocumentExhibit => exhibit.type === 'document') }
|
||||||
return documentWidgetRegistry[type] || documentWidgetRegistry.file
|
export function evidenceExhibits(exhibits:Exhibit[]):Evidence[] { return exhibits.filter((exhibit):exhibit is Evidence => exhibit.type !== 'document') }
|
||||||
}
|
|
||||||
|
|||||||
+9
-2
@@ -1,6 +1,13 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode, Suspense, lazy } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { App } from './App'
|
import { App } from './App'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(<StrictMode><App /></StrictMode>)
|
// three.js is lazy-loaded so the board never pays for it up front.
|
||||||
|
const PhonePreview = lazy(() => import('./phone').then(m => ({ default: m.PhonePreview })))
|
||||||
|
|
||||||
|
// Visual spike: /?phone=1 renders the handset standalone, isolated from the board.
|
||||||
|
const root = new URLSearchParams(window.location.search).has('phone')
|
||||||
|
? <Suspense fallback={null}><PhonePreview /></Suspense>
|
||||||
|
: <App />
|
||||||
|
createRoot(document.getElementById('root')!).render(<StrictMode>{root}</StrictMode>)
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { UtteranceCanvas } from './utteranceCanvas'
|
||||||
|
import { CUTSCENE_COMPONENT_KEYS, DialoguePreview } from './narrative'
|
||||||
|
|
||||||
|
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; musicAssetId: string | null; musicVolume: number; terminals: Terminal[] }
|
||||||
|
type AudioAsset = { id: string; originalName: string; mimeType: string }
|
||||||
|
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 [audioAssets, setAudioAssets] = useState<AudioAsset[]>([])
|
||||||
|
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(() => {})
|
||||||
|
api<AudioAsset[]>('/api/admin/assets', 'GET').then(list => setAudioAssets(list.filter(a => a.mimeType.startsWith('audio/')))).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>)}
|
||||||
|
{selected && (selected.nodeType === 'dialogue' || selected.hasUtterances) &&
|
||||||
|
<div className="graph-node-preview" style={{ left: selected.xpos + NODE_W + 28, top: selected.ypos }} onClick={event => event.stopPropagation()}>
|
||||||
|
<DialoguePreview nodeId={selected.id} />
|
||||||
|
</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} audioAssets={audioAssets}
|
||||||
|
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, audioAssets, onPatch, onSetEntry, onDelete, onAddTerminal, onTerminalPatch, onTerminalDelete, onEditUtterances }: {
|
||||||
|
node: StoryNode; graph: Graph; templates: LevelTemplate[]; audioAssets: AudioAsset[]
|
||||||
|
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 [volume, setVolume] = useState(node.musicVolume)
|
||||||
|
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{node.nodeType === 'cutscene' && componentKey && !CUTSCENE_COMPONENT_KEYS.includes(componentKey) && <b className="ins-warn"> · not registered</b>}</span>
|
||||||
|
<input list={node.nodeType === 'cutscene' ? 'cutscene-components' : undefined} 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 })} />
|
||||||
|
{node.nodeType === 'cutscene' && <datalist id="cutscene-components">{CUTSCENE_COMPONENT_KEYS.map(k => <option key={k} value={k} />)}</datalist>}
|
||||||
|
</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>}
|
||||||
|
<label className="ins-field"><span>Scene music</span>
|
||||||
|
<select value={node.musicAssetId || ''} onChange={e => onPatch({ musicAssetId: e.target.value || null })}>
|
||||||
|
<option value="">— none / inherit —</option>
|
||||||
|
{audioAssets.map(asset => <option key={asset.id} value={asset.id}>{asset.originalName}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{node.musicAssetId && <label className="ins-field"><span>Volume {volume}%</span>
|
||||||
|
<input type="range" min={0} max={100} value={volume} onChange={e => setVolume(Number(e.target.value))}
|
||||||
|
onPointerUp={() => volume !== node.musicVolume && onPatch({ musicVolume: volume })} onBlur={() => volume !== node.musicVolume && onPatch({ musicVolume: volume })} />
|
||||||
|
</label>}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, type FC } from 'react'
|
||||||
|
import { audio } from './audio'
|
||||||
|
|
||||||
|
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null }
|
||||||
|
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number; utterances?: RuntimeUtterance[]; rootId?: string | null }
|
||||||
|
export type 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 const CUTSCENE_COMPONENT_KEYS = Object.keys(CUTSCENE_REGISTRY)
|
||||||
|
|
||||||
|
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, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; inline?: boolean; startId?: string | null }) {
|
||||||
|
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
|
||||||
|
const [currentId, setCurrentId] = useState<string | null>(startId ?? node.rootId)
|
||||||
|
// In preview, clicking an utterance card jumps the walk to that line.
|
||||||
|
useEffect(() => { if (startId !== undefined) setCurrentId(startId ?? node.rootId) }, [startId, 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
|
||||||
|
|
||||||
|
// Typewriter clatter as characters are revealed (every other non-space char).
|
||||||
|
useEffect(() => {
|
||||||
|
if (inline || charCount === 0 || charCount > fullText.length) return
|
||||||
|
const ch = fullText[charCount - 1]
|
||||||
|
if (ch && ch !== ' ' && charCount % 2 === 0) audio.type()
|
||||||
|
}, [charCount]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const pick = (choice: RuntimeUtterance) => {
|
||||||
|
if (!inline) audio.sfx('choice')
|
||||||
|
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
|
||||||
|
if (!inline) audio.sfx('advance')
|
||||||
|
setCurrentId(children[0].id) // linear next line
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
if (inline) return // preview advances by click only, so it never steals the editor's keys
|
||||||
|
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, inline])
|
||||||
|
|
||||||
|
if (!current) return null
|
||||||
|
return <div className={`dialogue${inline ? ' inline' : ''}`} 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>
|
||||||
|
}
|
||||||
|
|
||||||
|
// A live, scaled-down mini player for the editor — runs the real DialoguePlayer
|
||||||
|
// against the same server resolver, so it reflects the current authored dialogue.
|
||||||
|
export function DialoguePreview({ nodeId, startId, revision, onClose }: { nodeId: string; startId?: string | null; revision?: number; onClose?: () => void }) {
|
||||||
|
const [tree, setTree] = useState<{ utterances: RuntimeUtterance[]; rootId: string | null } | null>(null)
|
||||||
|
const [playKey, setPlayKey] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/admin/story-nodes/${nodeId}/dialogue`).then(response => response.ok ? response.json() : null).then(setTree).catch(() => setTree(null))
|
||||||
|
}, [nodeId, revision])
|
||||||
|
return <div className="dialogue-preview" onPointerDown={event => event.stopPropagation()}>
|
||||||
|
<div className="dialogue-preview-bar"><span>preview</span>
|
||||||
|
<button title="Restart" onClick={event => { event.stopPropagation(); setPlayKey(key => key + 1) }}>↻</button>
|
||||||
|
{onClose && <button title="Close" onClick={event => { event.stopPropagation(); onClose() }}>×</button>}
|
||||||
|
</div>
|
||||||
|
<div className="dialogue-preview-stage">
|
||||||
|
{tree?.rootId
|
||||||
|
? <div className="dialogue-preview-scale"><DialoguePlayer key={playKey} inline node={tree} startId={startId} onExit={() => setPlayKey(key => key + 1)} /></div>
|
||||||
|
: <div className="dialogue-preview-empty">no utterances yet</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
+365
@@ -0,0 +1,365 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
|
||||||
|
// A diegetic 90s handset rendered as a small three.js scene inside a fixed 1:2
|
||||||
|
// portrait stage. The 3D layer owns the blocky body, the flip-open animation, and
|
||||||
|
// the pressable keys; the screen UI is real DOM positioned in PERCENT of the stage
|
||||||
|
// (the ortho camera is head-on, so the screen face maps to a constant rectangle).
|
||||||
|
//
|
||||||
|
// SPIKE STATUS: the directory + flag checks below are local stubs. In the game the
|
||||||
|
// phone is an always-available surface whose number->node directory and node-enable
|
||||||
|
// flag requirements come from the story graph (see the "mobile" gate discussion).
|
||||||
|
const SCREEN_RECT = { top: 9, left: 25, width: 50, height: 30 } // % of the stage
|
||||||
|
|
||||||
|
const CLOSED_ANGLE = 3.12 // hinge rotation.x when shut (~179°: lid folds over the keypad)
|
||||||
|
const OPEN_ANGLE = 0 // lid stands up, coplanar with the keypad, facing camera
|
||||||
|
|
||||||
|
// ---- placeholder telephony audio (to be replaced by recorded assets) ----------
|
||||||
|
let pctx: AudioContext | null = null
|
||||||
|
function ac() {
|
||||||
|
if (!pctx) { const AC = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (AC) pctx = new AC() }
|
||||||
|
if (pctx?.state === 'suspended') void pctx.resume()
|
||||||
|
return pctx
|
||||||
|
}
|
||||||
|
function tone(freqs: number[], dur: number, when = 0, gain = 0.05) {
|
||||||
|
const ctx = ac(); if (!ctx) return
|
||||||
|
const t = ctx.currentTime + when
|
||||||
|
const g = ctx.createGain()
|
||||||
|
g.gain.setValueAtTime(0.0001, t)
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.01)
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur)
|
||||||
|
g.connect(ctx.destination)
|
||||||
|
for (const f of freqs) { const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = f; o.connect(g); o.start(t); o.stop(t + dur + 0.02) }
|
||||||
|
}
|
||||||
|
const DTMF: Record<string, [number, number]> = {
|
||||||
|
'1': [697, 1209], '2': [697, 1336], '3': [697, 1477], '4': [770, 1209], '5': [770, 1336], '6': [770, 1477],
|
||||||
|
'7': [852, 1209], '8': [852, 1336], '9': [852, 1477], '*': [941, 1209], '0': [941, 1336], '#': [941, 1477],
|
||||||
|
}
|
||||||
|
const sfx = {
|
||||||
|
key(k: string) { const d = DTMF[k]; if (d) tone(d, 0.12, 0, 0.06) },
|
||||||
|
ring() { tone([440, 480], 0.9, 0, 0.04) },
|
||||||
|
unobtainable() { tone([950], 0.28, 0, 0.06); tone([1400], 0.28, 0.33, 0.06); tone([1800], 0.28, 0.66, 0.06) }, // SIT-ish
|
||||||
|
voicemail() { tone([1000], 0.5, 0, 0.05) },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- scene --------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Built = { group: THREE.Group; hinge: THREE.Group; keys: THREE.Mesh[] }
|
||||||
|
|
||||||
|
function buildPhone(): Built {
|
||||||
|
const group = new THREE.Group()
|
||||||
|
const keys: THREE.Mesh[] = []
|
||||||
|
|
||||||
|
const bodyMat = new THREE.MeshStandardMaterial({ color: 0x181a1c, roughness: 0.85, metalness: 0.05, flatShading: true })
|
||||||
|
const trimMat = new THREE.MeshStandardMaterial({ color: 0x101214, roughness: 0.9, flatShading: true })
|
||||||
|
const glassMat = new THREE.MeshStandardMaterial({ color: 0x0a1206, emissive: 0x101d09, emissiveIntensity: 0.6, roughness: 0.4, flatShading: true })
|
||||||
|
const keyMat = new THREE.MeshStandardMaterial({ color: 0x9fb23a, emissive: 0x8fa522, emissiveIntensity: 0.55, roughness: 0.55, flatShading: true })
|
||||||
|
|
||||||
|
const keypad = new THREE.Mesh(new THREE.BoxGeometry(0.66, 0.92, 0.16), bodyMat)
|
||||||
|
keypad.position.set(0, -0.47, 0)
|
||||||
|
keypad.castShadow = true
|
||||||
|
keypad.receiveShadow = true
|
||||||
|
group.add(keypad)
|
||||||
|
|
||||||
|
// Hinge at the top-FRONT edge (ahead of the ~0.145 button tops); the lid child
|
||||||
|
// cancels the hinge z so OPEN (rotation 0) is coplanar and SCREEN_RECT holds.
|
||||||
|
const hinge = new THREE.Group()
|
||||||
|
hinge.position.set(0, 0, 0.10)
|
||||||
|
group.add(hinge)
|
||||||
|
|
||||||
|
const lid = new THREE.Mesh(new THREE.BoxGeometry(0.66, 0.92, 0.10), bodyMat)
|
||||||
|
lid.position.set(0, 0.47, -0.10)
|
||||||
|
lid.castShadow = true
|
||||||
|
lid.receiveShadow = true
|
||||||
|
hinge.add(lid)
|
||||||
|
|
||||||
|
const bezel = new THREE.Mesh(new THREE.BoxGeometry(0.54, 0.66, 0.02), trimMat)
|
||||||
|
bezel.position.set(0, 0.05, 0.052)
|
||||||
|
lid.add(bezel)
|
||||||
|
const glass = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.6, 0.02), glassMat)
|
||||||
|
glass.position.set(0, 0.05, 0.062)
|
||||||
|
lid.add(glass)
|
||||||
|
|
||||||
|
// Chunky protruding keys (LOCAL to the keypad, whose body spans local y [-0.46,0.46]).
|
||||||
|
const key = (w: number, h: number, x: number, y: number, id: string) => {
|
||||||
|
const k = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.09), keyMat)
|
||||||
|
k.position.set(x, y, 0.10)
|
||||||
|
k.userData = { key: id, baseZ: 0.10 }
|
||||||
|
k.castShadow = true
|
||||||
|
keypad.add(k)
|
||||||
|
keys.push(k)
|
||||||
|
}
|
||||||
|
key(0.16, 0.10, -0.20, 0.40, 'call') // left soft key
|
||||||
|
key(0.16, 0.10, 0.20, 0.40, 'end') // right soft key
|
||||||
|
key(0.20, 0.16, 0, 0.22, 'nav') // nav pad
|
||||||
|
const rows = [0.04, -0.10, -0.24, -0.38]
|
||||||
|
const cols = [-0.20, 0, 0.20]
|
||||||
|
const digits = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['*', '0', '#']]
|
||||||
|
rows.forEach((y, r) => cols.forEach((x, c) => key(0.15, 0.10, x, y, digits[r][c])))
|
||||||
|
|
||||||
|
// Hinge barrel: parented to the hinge group at its local origin, so it sits
|
||||||
|
// EXACTLY on the rotation axis (the pivot passes through the cylinder centre).
|
||||||
|
// A cylinder spinning about its own axis is invisible, so it stays put as the
|
||||||
|
// lid swings. Pins stick out the sides to read as the pivot.
|
||||||
|
const hingeMat = new THREE.MeshStandardMaterial({ color: 0x26292c, roughness: 0.5, metalness: 0.35, flatShading: true })
|
||||||
|
const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.055, 0.74, 12), hingeMat)
|
||||||
|
barrel.rotation.z = Math.PI / 2 // lay the cylinder along X (the hinge axis)
|
||||||
|
barrel.castShadow = true
|
||||||
|
hinge.add(barrel)
|
||||||
|
|
||||||
|
// Chubby stub antenna on the top-right of the body, with a rounded cap.
|
||||||
|
const antMat = new THREE.MeshStandardMaterial({ color: 0x2a2d30, roughness: 0.6, metalness: 0.2, flatShading: true })
|
||||||
|
const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.042, 0.052, 0.22, 8), antMat)
|
||||||
|
antenna.position.set(0.25, 0.10, -0.01)
|
||||||
|
antenna.castShadow = true
|
||||||
|
group.add(antenna)
|
||||||
|
const tip = new THREE.Mesh(new THREE.SphereGeometry(0.055, 10, 8), antMat)
|
||||||
|
tip.position.set(0.25, 0.24, -0.01)
|
||||||
|
tip.castShadow = true
|
||||||
|
group.add(tip)
|
||||||
|
|
||||||
|
return { group, hinge, keys }
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhoneDevice({ open, onKey }: { open: boolean; onKey: (k: string) => void }) {
|
||||||
|
const stageRef = useRef<HTMLDivElement>(null)
|
||||||
|
const targetRef = useRef(open ? 1 : 0)
|
||||||
|
const onKeyRef = useRef(onKey)
|
||||||
|
onKeyRef.current = onKey
|
||||||
|
|
||||||
|
// Open at once; on close, hold a beat so the screen fades out before the lid swings.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) { targetRef.current = 1; return }
|
||||||
|
const t = setTimeout(() => { targetRef.current = 0 }, 170)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stage = stageRef.current
|
||||||
|
if (!stage) return
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||||
|
renderer.shadowMap.enabled = true
|
||||||
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap
|
||||||
|
stage.appendChild(renderer.domElement)
|
||||||
|
renderer.domElement.style.cssText = 'position:absolute;inset:0;width:100%;height:100%'
|
||||||
|
|
||||||
|
const scene = new THREE.Scene()
|
||||||
|
const camera = new THREE.OrthographicCamera(-0.5, 0.5, 1, -1, 0.1, 100)
|
||||||
|
camera.position.set(0, 0, 6)
|
||||||
|
camera.lookAt(0, 0, 0)
|
||||||
|
|
||||||
|
scene.add(new THREE.AmbientLight(0x3a4030, 0.75))
|
||||||
|
const keyLight = new THREE.DirectionalLight(0xfff4e0, 1.05)
|
||||||
|
keyLight.position.set(1.4, 2.2, 2.4)
|
||||||
|
keyLight.castShadow = true
|
||||||
|
keyLight.shadow.mapSize.set(1024, 1024)
|
||||||
|
const sc = keyLight.shadow.camera as THREE.OrthographicCamera
|
||||||
|
sc.left = -1; sc.right = 1; sc.top = 1.3; sc.bottom = -1.3; sc.near = 0.1; sc.far = 12
|
||||||
|
scene.add(keyLight)
|
||||||
|
// Cool fill lifts the shadow side; warm rim from behind-top catches the top edges
|
||||||
|
// so the closed clamshell reads as a 3D object rather than a flat slab.
|
||||||
|
const fill = new THREE.DirectionalLight(0x8fa6c0, 0.4)
|
||||||
|
fill.position.set(-1.8, 0.5, 2.0)
|
||||||
|
scene.add(fill)
|
||||||
|
const rim = new THREE.DirectionalLight(0xffd7a0, 0.55)
|
||||||
|
rim.position.set(-0.3, 1.6, -2.6)
|
||||||
|
scene.add(rim)
|
||||||
|
const glow = new THREE.PointLight(0x9fd020, 0.5, 4)
|
||||||
|
glow.position.set(0, -0.5, 0.9)
|
||||||
|
scene.add(glow)
|
||||||
|
|
||||||
|
const { group, hinge, keys } = buildPhone()
|
||||||
|
scene.add(group)
|
||||||
|
|
||||||
|
// Pressable keys via raycasting on the canvas.
|
||||||
|
const ray = new THREE.Raycaster()
|
||||||
|
const ndc = new THREE.Vector2()
|
||||||
|
const pressedAt = new Map<THREE.Mesh, number>()
|
||||||
|
const onPointer = (e: PointerEvent) => {
|
||||||
|
if (targetRef.current < 0.5) return // only when open
|
||||||
|
const r = renderer.domElement.getBoundingClientRect()
|
||||||
|
ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1)
|
||||||
|
ray.setFromCamera(ndc, camera)
|
||||||
|
const hit = ray.intersectObjects(keys, false)[0]
|
||||||
|
if (hit) { const m = hit.object as THREE.Mesh; pressedAt.set(m, performance.now()); onKeyRef.current(m.userData.key) }
|
||||||
|
}
|
||||||
|
renderer.domElement.addEventListener('pointerdown', onPointer)
|
||||||
|
|
||||||
|
const resize = () => { const w = stage.clientWidth, h = stage.clientHeight; if (w && h) renderer.setSize(w, h, false) }
|
||||||
|
resize()
|
||||||
|
const ro = new ResizeObserver(resize)
|
||||||
|
ro.observe(stage)
|
||||||
|
|
||||||
|
const R = 6
|
||||||
|
let raf = 0
|
||||||
|
let progress = 0
|
||||||
|
const tick = () => {
|
||||||
|
progress += (targetRef.current - progress) * 0.08
|
||||||
|
hinge.rotation.x = CLOSED_ANGLE + (OPEN_ANGLE - CLOSED_ANGLE) * progress
|
||||||
|
// Intro orbit: start angled (blocky form + seam visible) and rotate to exactly
|
||||||
|
// head-on. Finishes by 80% open, so the DOM screen only ever appears aligned.
|
||||||
|
const cam = Math.max(0, 1 - progress / 0.8)
|
||||||
|
const az = 0.95 * cam, el = 0.28 * cam
|
||||||
|
camera.position.set(Math.sin(az) * Math.cos(el) * R, Math.sin(el) * R, Math.cos(az) * Math.cos(el) * R)
|
||||||
|
camera.lookAt(0, 0, 0)
|
||||||
|
const now = performance.now()
|
||||||
|
for (const k of keys) {
|
||||||
|
const t0 = pressedAt.get(k)
|
||||||
|
const base = k.userData.baseZ as number
|
||||||
|
k.position.z = t0 ? base - 0.04 * Math.max(0, Math.sin(Math.min(1, (now - t0) / 130) * Math.PI)) : base
|
||||||
|
}
|
||||||
|
renderer.render(scene, camera)
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
ro.disconnect()
|
||||||
|
renderer.domElement.removeEventListener('pointerdown', onPointer)
|
||||||
|
renderer.dispose()
|
||||||
|
renderer.domElement.remove()
|
||||||
|
scene.traverse(obj => { if (obj instanceof THREE.Mesh) { obj.geometry.dispose(); (obj.material as THREE.Material).dispose() } })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <div ref={stageRef} className="phone-canvas" />
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- directory ----------------------------------------------------------------
|
||||||
|
// The number->node directory stays authored config for now; `requires` are the
|
||||||
|
// target node's flag requirements, checked live against the player's ACHIEVEMENTS
|
||||||
|
// (the real playthrough case-state). Wiring numbers to actual dialogue nodes and
|
||||||
|
// moving the directory server-side is the next slice.
|
||||||
|
type Contact = { name: string; requires: string[] }
|
||||||
|
const DIRECTORY: Record<string, Contact> = {
|
||||||
|
'55501': { name: 'Elias Board', requires: ['elias_number_callable'] }, // dev-grant unlocks -> connects
|
||||||
|
'55502': { name: 'Voss Antiquities', requires: ['voss_number_known'] }, // not earned -> voicemail
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mode = 'home' | 'dial' | 'calling' | 'unknown' | 'voicemail' | 'connected'
|
||||||
|
|
||||||
|
// ---- screen -------------------------------------------------------------------
|
||||||
|
function PhoneScreen({ visible, mode, dialed, callee }: { visible: boolean; mode: Mode; dialed: string; callee: string }) {
|
||||||
|
const hhmm = new Date().toTimeString().slice(0, 5)
|
||||||
|
return <div className={`phone-screen${visible ? ' on' : ''}`}
|
||||||
|
style={{ top: `${SCREEN_RECT.top}%`, left: `${SCREEN_RECT.left}%`, width: `${SCREEN_RECT.width}%`, height: `${SCREEN_RECT.height}%` }}>
|
||||||
|
<div className="pscr-status"><span>▮▮▮</span><span>GU-NET</span><span>▚▚</span></div>
|
||||||
|
{mode === 'home' && <>
|
||||||
|
<div className="pscr-clock">{hhmm}</div>
|
||||||
|
<div className="pscr-date">17 AUG</div>
|
||||||
|
<div className="pscr-dir">55501 · 55502 · 55503</div>
|
||||||
|
<div className="pscr-soft"><span>Menu</span><span>Names</span></div>
|
||||||
|
</>}
|
||||||
|
{mode === 'dial' && <>
|
||||||
|
<div className="pscr-num">{dialed || '_'}</div>
|
||||||
|
<div className="pscr-soft"><span>▸ Call</span><span>Clr ◂</span></div>
|
||||||
|
</>}
|
||||||
|
{mode === 'calling' && <><div className="pscr-big">CALLING<span className="pscr-dots" /></div><div className="pscr-num sm">{dialed}</div></>}
|
||||||
|
{mode === 'unknown' && <><div className="pscr-big warn">NUMBER NOT</div><div className="pscr-big warn">IN SERVICE</div><div className="pscr-soft"><span /><span>End ◂</span></div></>}
|
||||||
|
{mode === 'voicemail' && <><div className="pscr-big">VOICEMAIL</div><div className="pscr-callee">{callee}</div><div className="pscr-line">leave a message…</div><div className="pscr-soft"><span /><span>End ◂</span></div></>}
|
||||||
|
{mode === 'connected' && <><div className="pscr-big ok">CONNECTED</div><div className="pscr-callee">{callee}</div><div className="pscr-line">[dialogue plays here]</div><div className="pscr-soft"><span /><span>End ◂</span></div></>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhonePreview() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [screenOn, setScreenOn] = useState(false)
|
||||||
|
const [mode, setMode] = useState<Mode>('home')
|
||||||
|
const [dialed, setDialed] = useState('')
|
||||||
|
const [callee, setCallee] = useState('')
|
||||||
|
const [playthroughId, setPlaythroughId] = useState<string | null>(null)
|
||||||
|
const [achieved, setAchieved] = useState<Set<string>>(new Set())
|
||||||
|
const [called, setCalled] = useState<Set<string>>(new Set())
|
||||||
|
const callTimer = useRef<number | undefined>(undefined)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) { const t = setTimeout(() => setScreenOn(true), 560); return () => clearTimeout(t) }
|
||||||
|
setScreenOn(false); setMode('home'); setDialed('')
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const refreshAchievements = async (id: string) => {
|
||||||
|
const res = await fetch(`/api/playthroughs/${id}/achievements`)
|
||||||
|
if (res.ok) setAchieved(new Set(await res.json() as string[]))
|
||||||
|
}
|
||||||
|
// Attach to the player's live playthrough (or create one) and load its case-state.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
let id: string | null = null
|
||||||
|
const cur = await fetch('/api/playthroughs/current')
|
||||||
|
if (cur.ok && cur.status !== 204) id = (await cur.json())?.playthrough?.id ?? null
|
||||||
|
if (!id) {
|
||||||
|
const made = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: 'glass-harbor' }) })
|
||||||
|
if (made.ok) id = (await made.json())?.playthrough?.id ?? null
|
||||||
|
}
|
||||||
|
if (cancelled || !id) return
|
||||||
|
setPlaythroughId(id)
|
||||||
|
await refreshAchievements(id)
|
||||||
|
})()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const connectable = (num: string) => { const c = DIRECTORY[num]; return c ? c.requires.every(f => achieved.has(f)) : false }
|
||||||
|
// Glow the handset when an enabled, not-yet-called number is waiting.
|
||||||
|
const glow = Object.keys(DIRECTORY).some(num => connectable(num) && !called.has(num))
|
||||||
|
|
||||||
|
const grantElias = async () => {
|
||||||
|
if (!playthroughId) return
|
||||||
|
await fetch(`/api/playthroughs/${playthroughId}/achievements`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ flagKey: 'elias_number_callable' }) })
|
||||||
|
await refreshAchievements(playthroughId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveCall = (num: string) => {
|
||||||
|
sfx.ring()
|
||||||
|
setMode('calling')
|
||||||
|
const contact = DIRECTORY[num]
|
||||||
|
window.clearTimeout(callTimer.current)
|
||||||
|
callTimer.current = window.setTimeout(() => {
|
||||||
|
if (!contact) { sfx.unobtainable(); setMode('unknown'); return }
|
||||||
|
setCallee(contact.name)
|
||||||
|
setCalled(prev => new Set(prev).add(num))
|
||||||
|
if (connectable(num)) setMode('connected')
|
||||||
|
else { sfx.voicemail(); setMode('voicemail') }
|
||||||
|
}, 950)
|
||||||
|
}
|
||||||
|
|
||||||
|
const press = (k: string) => {
|
||||||
|
// In a result screen, any key hangs up back to the dialer.
|
||||||
|
if (mode === 'calling' || mode === 'unknown' || mode === 'voicemail' || mode === 'connected') {
|
||||||
|
window.clearTimeout(callTimer.current); setMode(dialed ? 'dial' : 'home'); if (k === 'end') setDialed(''); return
|
||||||
|
}
|
||||||
|
if (k in DTMF) { sfx.key(k); setDialed(d => (d + k).slice(0, 14)); setMode('dial'); return }
|
||||||
|
if (k === 'call' && dialed) { resolveCall(dialed); return }
|
||||||
|
if (k === 'end') { setDialed(d => d.slice(0, -1)); if (dialed.length <= 1) setMode('home') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hardware keyboard convenience: digits, Enter = call, Backspace = delete.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key >= '0' && e.key <= '9') press(e.key)
|
||||||
|
else if (e.key === '*' || e.key === '#') press(e.key)
|
||||||
|
else if (e.key === 'Enter') press('call')
|
||||||
|
else if (e.key === 'Backspace') { e.preventDefault(); press('end') }
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}) // re-bind each render so `press` closes over fresh state
|
||||||
|
|
||||||
|
return <div className="phone-backdrop">
|
||||||
|
<div className="phone-stage">
|
||||||
|
<PhoneDevice open={open} onKey={press} />
|
||||||
|
<PhoneScreen visible={screenOn} mode={mode} dialed={dialed} callee={callee} />
|
||||||
|
</div>
|
||||||
|
<button className={`phone-open-btn${glow && !open ? ' glow' : ''}`} onClick={() => setOpen(o => !o)}>{open ? 'CLOSE' : 'OPEN'}</button>
|
||||||
|
<div className="phone-dev">
|
||||||
|
<button className="phone-dev-btn" disabled={!playthroughId || achieved.has('elias_number_callable')} onClick={grantElias}>
|
||||||
|
{achieved.has('elias_number_callable') ? '✓ elias_number_callable' : '▸ grant elias_number_callable'}
|
||||||
|
</button>
|
||||||
|
<p className="phone-hint">dial <code>55501</code> Elias (voicemail → connect once granted) · <code>55502</code> Voss (voicemail) · else unobtainable</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
+381
-18
@@ -110,7 +110,10 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
@keyframes document-locator-target { from { outline-color: #cda936; filter: drop-shadow(0 0 5px #f3d75d66) brightness(1.03); } to { outline-color: #fff19a; filter: drop-shadow(0 0 14px #ffe66cbb) brightness(1.12); } }
|
@keyframes document-locator-target { from { outline-color: #cda936; filter: drop-shadow(0 0 5px #f3d75d66) brightness(1.03); } to { outline-color: #fff19a; filter: drop-shadow(0 0 14px #ffe66cbb) brightness(1.12); } }
|
||||||
.evidence-card.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; }
|
.evidence-card.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; }
|
||||||
.evidence-card.thread-target:hover, .source-file-widget.thread-target:hover { outline: 2px dashed #b8443e; outline-offset: 6px; }
|
.evidence-card.thread-target:hover, .source-file-widget.thread-target:hover { outline: 2px dashed #b8443e; outline-offset: 6px; }
|
||||||
.evidence-card.arriving { z-index: 6; animation: exhibit-arrival 1.15s cubic-bezier(.18,.85,.22,1) both; }
|
.evidence-card.arriving, .source-file-widget.arriving { z-index: 6; animation: exhibit-arrival 1.15s cubic-bezier(.18,.85,.22,1) both; }
|
||||||
|
.source-file-widget.arriving::before { content: 'NEW EVIDENCE'; position: absolute; z-index: 4; top: -19px; right: -8px; padding: 4px 6px; border: 1px solid #f0c16f; background: #9a3c2e; color: #fff4d6; box-shadow: 2px 3px #02090799; font: 600 7px IBM Plex Mono; letter-spacing: .08em; }
|
||||||
|
.doc-row.arriving { animation: document-row-arrival 1.15s ease both; }
|
||||||
|
@keyframes document-row-arrival { 0% { background: #a05328; box-shadow: inset 5px 0 #ffd48a; } 100% { background: transparent; box-shadow: inset 0 0 transparent; } }
|
||||||
@keyframes exhibit-arrival { 0% { opacity: 0; scale: .72; translate: 0 -24px; filter: brightness(1.7); box-shadow: 0 0 0 0 #eda85b00; } 45% { opacity: 1; scale: 1.035; translate: 0 2px; box-shadow: 0 0 0 12px #eda85b55, 7px 9px 0 #020b0980; } 100% { opacity: 1; scale: 1; translate: 0 0; filter: brightness(1); box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; } }
|
@keyframes exhibit-arrival { 0% { opacity: 0; scale: .72; translate: 0 -24px; filter: brightness(1.7); box-shadow: 0 0 0 0 #eda85b00; } 45% { opacity: 1; scale: 1.035; translate: 0 2px; box-shadow: 0 0 0 12px #eda85b55, 7px 9px 0 #020b0980; } 100% { opacity: 1; scale: 1; translate: 0 0; filter: brightness(1); box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; } }
|
||||||
.evidence-card header { border-bottom: 1px solid #989e94; display: flex; justify-content: space-between; padding-bottom: 6px; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #5d6763; }
|
.evidence-card header { border-bottom: 1px solid #989e94; display: flex; justify-content: space-between; padding-bottom: 6px; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #5d6763; }
|
||||||
.evidence-card h3 { font: 600 10px IBM Plex Mono; letter-spacing: .09em; margin: 12px 0 6px; color: #9a5d2e; }
|
.evidence-card h3 { font: 600 10px IBM Plex Mono; letter-spacing: .09em; margin: 12px 0 6px; color: #9a5d2e; }
|
||||||
@@ -122,6 +125,7 @@ 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; }
|
||||||
|
.evidence-card.folder p { max-height: 58px; padding-right: 2px; overflow: hidden; }
|
||||||
.evidence-card.event { background: linear-gradient(112deg, #d6d1bd, #c8c5b6); border-left: 5px solid #9a6332; min-height: 174px; }
|
.evidence-card.event { background: linear-gradient(112deg, #d6d1bd, #c8c5b6); border-left: 5px solid #9a6332; min-height: 174px; }
|
||||||
.evidence-card.event h3 { color: #70401e; }
|
.evidence-card.event h3 { color: #70401e; }
|
||||||
.evidence-card.event p { font-size: 15px; }
|
.evidence-card.event p { font-size: 15px; }
|
||||||
@@ -135,13 +139,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.party-identity small { color: #6a746e; font: 7px IBM Plex Mono; }
|
.party-identity small { color: #6a746e; font: 7px IBM Plex Mono; }
|
||||||
.evidence-card.party p { margin-top: 9px; font-size: 13px; }
|
.evidence-card.party p { margin-top: 9px; font-size: 13px; }
|
||||||
.party-aliases { padding: 5px 0; color: #76552f; font: 7px IBM Plex Mono; }
|
.party-aliases { padding: 5px 0; color: #76552f; font: 7px IBM Plex Mono; }
|
||||||
.folder-documents { clear: both; margin-top: 9px; border-top: 1px dashed #826c49; padding-top: 6px; }
|
.folder-actions { position: absolute; right: 13px; bottom: 10px; }
|
||||||
.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; }
|
.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-documents button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.folder-actions button:hover { background: #e5ca96; color: #321f0f; }
|
||||||
.folder-documents button time { border: 0; padding: 0; font-size: 7px; }
|
|
||||||
.folder-documents small { display: block; padding: 4px 0 1px 17px; color: #69573d; font: 7px IBM Plex Mono; }
|
|
||||||
.folder-actions { clear: both; display: flex; justify-content: flex-end; gap: 12px; margin-top: 7px; border-top: 1px solid #9a7d50; padding-top: 5px; }
|
|
||||||
.folder-actions button { float: none; color: #60401f; }
|
|
||||||
.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); }
|
||||||
@@ -190,7 +190,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.story-strip b { color: #d9ddd8; font: 9px 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; }
|
.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 { 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 { height: 44px; padding: 0 7px 0 13px; display: flex; align-items: center; background: #173d34; color: #e0e8e4; cursor: default; }
|
.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 > 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.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-panel > p { margin: 16px; padding: 13px; background: #e2dfd2; border-left: 3px solid #a66d37; font: 13px/1.55 Special Elite; }
|
||||||
@@ -220,11 +220,11 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.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; }
|
||||||
.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; }
|
.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; }
|
.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; }
|
.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 { 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 { 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; }
|
||||||
@@ -275,13 +275,376 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.metadata-row { display: grid; grid-template-columns: 150px 1fr 28px; gap: 7px; padding: 7px; border-bottom: 1px solid #a1a69f; }
|
.metadata-row { display: grid; grid-template-columns: 150px 1fr 28px; gap: 7px; padding: 7px; border-bottom: 1px solid #a1a69f; }
|
||||||
.metadata-row input { padding: 6px; font-size: 9px; }
|
.metadata-row input { padding: 6px; font-size: 9px; }
|
||||||
.metadata-row button { display: grid; place-items: center; border: 0; background: #b8bcb5; color: #6c3a2c; cursor: pointer; }
|
.metadata-row button { display: grid; place-items: center; border: 0; background: #b8bcb5; color: #6c3a2c; cursor: pointer; }
|
||||||
|
.gate-field { margin: 18px 0; padding: 12px; border: 1px dashed #9b6638; background: #d2c8af; }.gate-field small { color: #6f5840; font: 8px/1.4 IBM Plex Mono; }
|
||||||
|
.flags-editor { width: min(560px, 90vw); }.flags-editor-body { padding: 24px 27px 26px; }.flags-editor-body > small { color: #805027; font: 600 8px IBM Plex Mono; letter-spacing: .16em; }.flags-editor-body > p { margin: 12px 0 18px; font: 12px/1.5 Special Elite; }
|
||||||
|
.flag-list { max-height: 290px; overflow: auto; border: 1px solid #8b938d; background: #d3d4cc; }.flag-empty { padding: 28px 16px; text-align: center; color: #6d7771; font: 8px IBM Plex Mono; }
|
||||||
|
.flag-row { min-height: 55px; padding: 8px 10px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #a0a69f; }.flag-row.earned { background: #d9dfce; box-shadow: inset 4px 0 #3f755f; }.flag-row > div { flex: 1; min-width: 0; display: grid; gap: 4px; }.flag-row b { overflow: hidden; text-overflow: ellipsis; color: #273d36; font: 600 10px IBM Plex Mono; }.flag-row small { color: #737d77; font: 7px IBM Plex Mono; }.flag-row button, .flag-add button { border: 1px outset #89938d; background: #c6cbc4; color: #30463f; padding: 7px 9px; cursor: pointer; font: 8px IBM Plex Mono; }.flag-row.earned button { color: #783f2e; }
|
||||||
|
.flag-add { margin-top: 13px; display: grid; grid-template-columns: 1fr auto; gap: 7px; }.flag-add input { min-width: 0; border: 1px solid #7d8780; background: #e8e5d8; padding: 8px 9px; font: 10px IBM Plex Mono; }.flag-add button { background: #244c41; color: white; }.flag-row button:disabled, .flag-add button:disabled { opacity: .5; cursor: default; }.flags-editor-body .flag-error { margin: 10px 0 0; color: #8a342e; font: 8px IBM Plex Mono; }
|
||||||
|
.match-rules-editor { width: min(1000px, 94vw); max-height: min(820px, 92vh); }
|
||||||
|
.match-rules-body { padding: 22px 25px 25px; overflow: auto; }.match-rules-body > small { color: #805027; font: 600 8px IBM Plex Mono; letter-spacing: .16em; }.match-rules-body > p { max-width: 760px; margin: 10px 0 17px; font: 11px/1.5 Special Elite; }
|
||||||
|
.match-rule-layout { display: grid; grid-template-columns: minmax(250px, .75fr) minmax(390px, 1.25fr); gap: 13px; align-items: start; }.match-rule-list { max-height: 560px; overflow: auto; border: 1px solid #8b938d; background: #d3d4cc; }
|
||||||
|
.match-rule-row { min-height: 58px; display: grid; grid-template-columns: minmax(0, 1fr) auto 28px; align-items: center; gap: 6px; padding: 7px; border-bottom: 1px solid #a0a69f; }.match-rule-row.disabled { opacity: .55; }.match-rule-row > div { min-width: 0; display: grid; gap: 4px; }.match-rule-row b, .match-rule-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.match-rule-row b { color: #273d36; font: 600 9px IBM Plex Mono; }.match-rule-row small { color: #737d77; font: 7px IBM Plex Mono; }.match-rule-row button, .match-rule-form button { min-height: 27px; border: 1px outset #89938d; background: #c6cbc4; color: #30463f; cursor: pointer; font: 8px IBM Plex Mono; }.match-rule-row button:last-child { display: grid; place-items: center; color: #783f2e; }
|
||||||
|
.match-rule-form { padding: 13px; border: 1px solid #8b938d; background: #cecfc7; }.match-rule-form-heading, .anchor-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 11px; color: #44504c; font: 8px IBM Plex Mono; letter-spacing: .08em; }.match-rule-form-heading button, .anchor-heading button { display: flex; align-items: center; gap: 4px; padding: 5px 8px; }.match-rule-fields { display: grid; grid-template-columns: minmax(0, 1fr) 110px; gap: 9px; }.match-rule-enabled { display: flex; align-items: center; gap: 7px; margin: 10px 0 15px; color: #59645e; font: 8px IBM Plex Mono; }.anchor-heading { margin: 0; padding: 8px 0; border-bottom: 2px solid #59625d; }.anchor-list { max-height: 295px; overflow: auto; border: 1px solid #929991; border-top: 0; background: #d7d8d0; }.anchor-row { display: grid; grid-template-columns: minmax(0, 1fr) 88px 28px; align-items: end; gap: 7px; padding: 8px; border-bottom: 1px solid #a1a69f; }.anchor-row > div, .anchor-row label { display: grid; gap: 4px; }.anchor-row small, .anchor-row label span { color: #6b756f; font: 7px IBM Plex Mono; }.anchor-row textarea { min-width: 0; resize: vertical; padding: 7px; background: #eeeadd; font: 9px/1.4 IBM Plex Mono; }.anchor-row input { min-width: 0; padding: 7px 4px; font: 8px IBM Plex Mono; }.anchor-row > button { display: grid; place-items: center; color: #783f2e; }.match-rule-form .folder-editor-actions { margin-top: 12px; }.match-rule-form .folder-editor-actions button { padding: 8px 11px; }.match-rule-form .folder-editor-actions button:disabled, .match-rule-row button:disabled, .anchor-row > button:disabled { opacity: .45; cursor: default; }.match-rule-form .flag-error { margin: 9px 0 0; color: #8a342e; font: 8px IBM Plex Mono; }
|
||||||
.boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; }
|
.boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; }
|
||||||
.empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; }
|
.empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; }
|
||||||
@media (max-width: 900px) { .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) {
|
||||||
@media (max-width: 900px) and (orientation: portrait) {
|
.menubar { grid-template-columns: 39px minmax(0, 1fr) 10px; gap: 5px; padding: 0 8px; }
|
||||||
.board-actions { top: 50%; right: 8px; bottom: auto; left: auto; width: 96px; height: auto; max-height: calc(100% - 20px); padding: 5px; transform: translateY(-50%); flex-direction: column; align-items: stretch; overflow-y: auto; }
|
.brand { gap: 0; }
|
||||||
.board-actions button { flex: 0 0 36px; width: 100%; padding: 0 7px; justify-content: flex-start; }
|
.brand > span:not(.brand-mark) { display: none; }
|
||||||
.board-actions > span { flex: 0 0 1px; width: 100%; height: 1px; margin: 3px 0; }
|
.brand-mark { width: 33px; height: 33px; }
|
||||||
.board-actions > b { padding: 2px 0; text-align: center; }
|
.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; }
|
||||||
|
.match-rules-editor { width: 100vw; height: 100dvh; max-height: none; border: 0; }.match-rules-body { padding: 16px; }.match-rule-layout { grid-template-columns: 1fr; }.match-rule-list { max-height: 180px; }.match-rule-fields { grid-template-columns: 1fr 100px; }
|
||||||
|
.board-actions button { width: 38px; padding: 0; justify-content: center; gap: 0; font-size: 0; }
|
||||||
|
.board-actions > b { display: none; }
|
||||||
|
.board-actions > span { margin: 0 2px; }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
|
@media (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, .source-file-widget.arriving, .doc-row.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
|
||||||
|
|
||||||
|
/* Narrative layer: splash + NPC dialogue */
|
||||||
|
.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%); }
|
||||||
|
|
||||||
|
/* Admin: mystery list actions + asset library */
|
||||||
|
.mystery-list-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 0 10px; font: 600 9px IBM Plex Mono; letter-spacing: .18em; color: #78958d; }
|
||||||
|
.mystery-list-head button, .asset-upload-btn { background: #143229; border: 1px solid #3c5a52; color: #d79754; font: 9px IBM Plex Mono; padding: 6px 12px; cursor: pointer; }
|
||||||
|
.mystery-list-head button:hover, .asset-upload-btn:hover { background: #1c463a; }
|
||||||
|
.asset-upload-btn { display: inline-flex; align-items: center; letter-spacing: .1em; }
|
||||||
|
.asset-upload-btn input { display: none; }
|
||||||
|
.mystery-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 12px 6px; border: 0; border-bottom: 1px solid #1c352e; background: none; text-align: left; cursor: pointer; }
|
||||||
|
.mystery-row:hover { background: #12312a; }
|
||||||
|
.mystery-row-main { flex: 1; display: grid; gap: 3px; min-width: 0; }
|
||||||
|
.mystery-del { flex: 0 0 auto; background: none; border: 1px solid #3c5a52; color: #9bb0a9; width: 26px; height: 26px; font-size: 15px; cursor: pointer; }
|
||||||
|
.mystery-del:hover { border-color: #d78a7f; color: #e5b3ab; background: #4a221d; }
|
||||||
|
.ins-warn { color: #d78a7f; font-weight: 600; }
|
||||||
|
.asset-store { padding: 0 24px 24px; overflow: auto; }
|
||||||
|
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 14px; }
|
||||||
|
.asset-card { margin: 0; border: 1px solid #35544c; background: #0e2a24; display: flex; flex-direction: column; }
|
||||||
|
.asset-thumb { aspect-ratio: 4 / 3; display: grid; place-items: center; overflow: hidden; background: #0a211d; }
|
||||||
|
.asset-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.asset-icon { font: 600 20px IBM Plex Mono; color: #7f9a92; letter-spacing: .06em; }
|
||||||
|
.asset-card figcaption { padding: 7px 9px 2px; font: 10px IBM Plex Mono; color: #cfe0d9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.asset-card small { padding: 0 9px 6px; font: 8px IBM Plex Mono; color: #7f9a92; }
|
||||||
|
.asset-actions { display: flex; gap: 4px; padding: 0 8px 8px; margin-top: auto; }
|
||||||
|
.asset-actions button { flex: 1; background: #143229; border: 1px solid #3c5a52; color: #9bb0a9; font: 8px IBM Plex Mono; padding: 5px; cursor: pointer; }
|
||||||
|
.asset-actions button:hover { background: #1c463a; color: #e4e9e4; }
|
||||||
|
.asset-actions .asset-del { flex: 0 0 26px; }
|
||||||
|
.asset-actions .asset-del:hover { border-color: #d78a7f; color: #e5b3ab; background: #4a221d; }
|
||||||
|
|
||||||
|
/* Live dialogue preview (mini player, real DialoguePlayer scaled down) */
|
||||||
|
.dialogue.inline { position: absolute; inset: 0; width: 100%; height: 100%; animation: none; }
|
||||||
|
.dialogue-preview { width: 340px; background: #0a211d; border: 1px solid #40655b; box-shadow: 4px 6px 0 #04110e88; }
|
||||||
|
.dialogue-preview-bar { display: flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; background: #0e2a24; border-bottom: 1px solid #24413a; font: 8px IBM Plex Mono; letter-spacing: .16em; color: #86a199; text-transform: uppercase; }
|
||||||
|
.dialogue-preview-bar span { margin-right: auto; }
|
||||||
|
.dialogue-preview-bar button { background: none; border: 0; color: #9bb0a9; cursor: pointer; font-size: 12px; line-height: 1; padding: 0 2px; }
|
||||||
|
.dialogue-preview-bar button:hover { color: #e7b57e; }
|
||||||
|
.dialogue-preview-stage { position: relative; width: 340px; height: 191px; overflow: hidden; cursor: pointer; background: #06140f; }
|
||||||
|
.dialogue-preview-scale { position: absolute; top: 0; left: 0; width: 1020px; height: 573px; transform: scale(0.33333); transform-origin: top left; }
|
||||||
|
.dialogue-preview-empty { display: grid; place-items: center; height: 100%; color: #5f7b73; font: 9px IBM Plex Mono; }
|
||||||
|
.graph-node-preview { position: absolute; z-index: 6; }
|
||||||
|
.utterance-preview-dock { position: absolute; left: 16px; bottom: 16px; z-index: 6; }
|
||||||
|
|
||||||
|
/* Audio mute toggle (floats over cutscene/dialogue/board) */
|
||||||
|
.audio-toggle { position: fixed; top: 14px; right: 14px; z-index: 300; width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #3c5a52; background: #0a211de6; color: #d58a46; font-size: 16px; line-height: 1; cursor: pointer; }
|
||||||
|
.audio-toggle:hover { border-color: #6f8f85; color: #e7b57e; }
|
||||||
|
.audio-toggle.muted { color: #5f7b73; text-decoration: line-through; }
|
||||||
|
|
||||||
|
/* === 90s handset spike (src/phone.tsx) ============================ */
|
||||||
|
.phone-backdrop { position: fixed; inset: 0; z-index: 500; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
|
||||||
|
background: radial-gradient(120% 90% at 50% 30%, #10201c 0%, #060b0a 70%, #020403 100%); }
|
||||||
|
/* Fixed 1:2 portrait stage. The 3D scene and the DOM screen both live here, so
|
||||||
|
percentages resolve against the same box at every size. */
|
||||||
|
.phone-stage { position: relative; height: min(88vh, 720px); aspect-ratio: 1 / 2; max-width: 92vw; }
|
||||||
|
.phone-canvas { position: absolute; inset: 0; }
|
||||||
|
/* The interactive screen, positioned in % of the stage over the 3D glass. */
|
||||||
|
.phone-screen { position: absolute; box-sizing: border-box; padding: 4% 6%; overflow: hidden;
|
||||||
|
background: linear-gradient(180deg, #12200b, #0a1607); color: #cdea6a; font-family: "DejaVu Sans Mono", ui-monospace, monospace;
|
||||||
|
text-shadow: 0 0 6px #7fa02988; opacity: 0; transition: opacity .1s ease; pointer-events: none;
|
||||||
|
box-shadow: inset 0 0 18px #0006, inset 0 0 0 1px #2c3d16; }
|
||||||
|
.phone-screen.on { opacity: 1; pointer-events: auto; transition: opacity .28s ease .1s; }
|
||||||
|
.phone-screen::after { content: ""; position: absolute; inset: 0; pointer-events: none;
|
||||||
|
background: repeating-linear-gradient(180deg, #0000 0 2px, #00000022 2px 3px); mix-blend-mode: multiply; }
|
||||||
|
.pscr-status { display: flex; justify-content: space-between; font-size: 9px; letter-spacing: .5px; opacity: .85; }
|
||||||
|
.pscr-clock { text-align: center; font-size: 34px; font-weight: 700; margin-top: 14%; letter-spacing: 2px; }
|
||||||
|
.pscr-date { text-align: center; font-size: 11px; opacity: .8; letter-spacing: 3px; }
|
||||||
|
.pscr-soft { position: absolute; left: 6%; right: 6%; bottom: 4%; display: flex; justify-content: space-between; font-size: 10px; opacity: .9; }
|
||||||
|
.pscr-dir { text-align: center; font-size: 9px; opacity: .55; margin-top: 8%; letter-spacing: 1px; }
|
||||||
|
.pscr-num { text-align: center; font-size: 22px; letter-spacing: 3px; margin-top: 16%; word-break: break-all; }
|
||||||
|
.pscr-num.sm { font-size: 13px; margin-top: 4%; opacity: .8; }
|
||||||
|
.pscr-big { text-align: center; font-size: 17px; letter-spacing: 1px; margin-top: 12%; font-weight: 700; }
|
||||||
|
.pscr-big.warn { color: #e88a4a; text-shadow: 0 0 6px #e88a4a66; }
|
||||||
|
.pscr-big.ok { color: #8fe86a; }
|
||||||
|
.pscr-big:nth-of-type(3) { margin-top: 2%; }
|
||||||
|
.pscr-callee { text-align: center; font-size: 12px; margin-top: 6%; opacity: .95; }
|
||||||
|
.pscr-line { text-align: center; font-size: 10px; opacity: .7; margin-top: 3%; }
|
||||||
|
.pscr-dots::after { content: "…"; animation: pscr-blink 1s steps(1) infinite; }
|
||||||
|
@keyframes pscr-blink { 50% { opacity: .2; } }
|
||||||
|
.phone-open-btn { border: 1px solid #6f8f85; background: #0a211de6; color: #cdea6a; font-family: ui-monospace, monospace;
|
||||||
|
letter-spacing: 2px; padding: 8px 22px; cursor: pointer; }
|
||||||
|
.phone-open-btn:hover { border-color: #cdea6a; }
|
||||||
|
.phone-hint { color: #5f7b73; font-family: ui-monospace, monospace; font-size: 11px; margin: 0; }
|
||||||
|
.phone-hint code { color: #8fae4a; }
|
||||||
|
.phone-open-btn.glow { border-color: #cdea6a; color: #eafaa0; box-shadow: 0 0 14px #9fd02088, inset 0 0 8px #9fd02044; animation: phone-glow 1.4s ease-in-out infinite; }
|
||||||
|
@keyframes phone-glow { 50% { box-shadow: 0 0 22px #cdea6acc, inset 0 0 12px #9fd02066; } }
|
||||||
|
.phone-dev { display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||||
|
.phone-dev-btn { border: 1px dashed #6f8f85; background: #0a1a16cc; color: #9fd020; font-family: ui-monospace, monospace; font-size: 11px; letter-spacing: 1px; padding: 5px 12px; cursor: pointer; }
|
||||||
|
.phone-dev-btn:disabled { color: #5f7b73; border-style: solid; cursor: default; }
|
||||||
|
.phone-dev-btn:not(:disabled):hover { border-color: #cdea6a; color: #cdea6a; }
|
||||||
|
|||||||
+136
-38
@@ -1,8 +1,24 @@
|
|||||||
export type EvidenceType = 'folder' | 'evidence' | 'note' | 'event' | 'party'
|
export type ExhibitType = 'folder' | 'document' | 'note' | 'event' | 'party'
|
||||||
export type PartyKind = 'person' | 'organization'
|
export type PartyKind = 'person' | 'organization'
|
||||||
export type OrganizationKind = 'business' | 'public_body' | 'association' | 'informal_group' | 'other'
|
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
|
||||||
@@ -10,12 +26,19 @@ 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'
|
||||||
|
/** Author-mode reveal requirements. Omitted from play-mode payloads. */
|
||||||
|
requiredFlags?: string[]
|
||||||
publishedAt?: string
|
publishedAt?: string
|
||||||
|
capturedAt?: string
|
||||||
|
sourceUri?: string
|
||||||
body: string[]
|
body: string[]
|
||||||
regions: DocumentRegion[]
|
regions: DocumentRegion[]
|
||||||
assetId?: string
|
assetId?: string
|
||||||
@@ -26,40 +49,59 @@ export interface CaseDocument {
|
|||||||
metadata: Record<string, string>
|
metadata: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Evidence {
|
export interface DocumentUploadAnalysis {
|
||||||
id: string
|
extractionStatus: 'succeeded' | 'unsupported' | 'failed'
|
||||||
type: EvidenceType
|
matchedFlags: string[]
|
||||||
title: string
|
awardedFlags: string[]
|
||||||
content: string
|
|
||||||
sourceDocumentId?: string
|
|
||||||
sourceRegionId?: string
|
|
||||||
eventDate?: string
|
|
||||||
supportingEvidenceIds?: string[]
|
|
||||||
partyKind?: PartyKind
|
|
||||||
organizationKind?: OrganizationKind
|
|
||||||
aliases?: string[]
|
|
||||||
relatedEvidenceIds?: 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 UploadedCaseDocument extends DocumentExhibit {
|
||||||
id: string
|
/** Transient upload response data; it is not part of persisted exhibit state. */
|
||||||
fromWidgetId: string
|
analysis: DocumentUploadAnalysis
|
||||||
toWidgetId: string
|
|
||||||
type: string
|
|
||||||
sortOrder?: number
|
|
||||||
config?: Record<string, unknown>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NoteExhibit extends ExhibitBase {
|
||||||
|
type: 'note'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventExhibit extends ExhibitBase {
|
||||||
|
type: 'event'
|
||||||
|
content: string
|
||||||
|
eventDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
label?: string
|
||||||
/** Percentage from slack (0) to taut (100). */
|
/** Percentage from slack (0) to taut (100). */
|
||||||
tightness?: number
|
tightness?: number
|
||||||
@@ -73,6 +115,31 @@ export interface Connection {
|
|||||||
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 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 {
|
export interface BriefConcept {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
@@ -87,15 +154,46 @@ 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
|
||||||
timelineRange?: TimelineRange | null
|
|
||||||
brief: LevelBrief
|
brief: LevelBrief
|
||||||
|
revision: number
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
levelStatus?: string
|
levelStatus?: string
|
||||||
sourceTemplateVersionId?: string
|
sourceTemplateVersionId?: string
|
||||||
editingAllowed?: boolean
|
editingAllowed?: boolean
|
||||||
|
/** Visible documents that have not previously played their arrival flourish. */
|
||||||
|
newlyVisibleDocumentIds?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LevelFlag {
|
||||||
|
key: string
|
||||||
|
earnedAt?: string
|
||||||
|
gatedDocumentCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvidenceMatchAnchorDefinition {
|
||||||
|
id: string
|
||||||
|
phrase: string
|
||||||
|
minimumSimilarity: number
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvidenceMatchRuleDefinition {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
flagKey: string
|
||||||
|
matcherVersion: 'char_trigram_v1'
|
||||||
|
minimumAnchorMatches: number
|
||||||
|
enabled: boolean
|
||||||
|
anchors: EvidenceMatchAnchorDefinition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDocumentExhibit(exhibit: Exhibit): exhibit is DocumentExhibit { return exhibit.type === 'document' }
|
||||||
|
export function 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' }
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react'
|
||||||
|
import { DialoguePreview } from './narrative'
|
||||||
|
|
||||||
|
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 [revision, setRevision] = useState(0)
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
try { setUtterances(await api<Utterance[]>(`/api/admin/story-nodes/${nodeId}/utterances`, 'GET')); setRevision(r => r + 1) }
|
||||||
|
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 className="utterance-preview-dock" onPointerDown={event => event.stopPropagation()}>
|
||||||
|
<DialoguePreview nodeId={nodeId} revision={revision} startId={selected ? (selected.utterer === 'player' ? selected.parentUtteranceId : selected.id) : null} />
|
||||||
|
</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>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user