Compare commits
30
Commits
f455c433b7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c099a5daa | ||
|
|
625f28b00b | ||
|
|
9e426e91ab | ||
|
|
f6fbeb39cf | ||
|
|
ce30321532 | ||
|
|
ada26ec40a | ||
|
|
74ccd11d0c | ||
|
|
1a9b6124c2 | ||
|
|
5eab67dd5d | ||
|
|
1d1d082a53 | ||
|
|
fae5200846 | ||
|
|
072e82f253 | ||
|
|
37c9fa1ebe | ||
|
|
5754fa1f55 | ||
|
|
93febcaef9 | ||
|
|
189a98cc64 | ||
|
|
489da14c9e | ||
|
|
f4c9d30505 | ||
|
|
ce0e82f7fb | ||
|
|
03266b5f40 | ||
|
|
972a7f6862 | ||
|
|
a154849b3f | ||
|
|
c1c424ab25 | ||
|
|
e69558e0e7 | ||
|
|
90ef7b25e8 | ||
|
|
80bc9b21a7 | ||
|
|
56bf2f97d0 | ||
|
|
cde945ea2e | ||
|
|
2271a7b02c | ||
|
|
15e12ce048 |
@@ -0,0 +1,46 @@
|
|||||||
|
# GUPI production environment TEMPLATE. Copy to /opt/gupi/.env.prod on the server and
|
||||||
|
# fill in real values there (never commit real secrets). GUPI runs ONLY its own app
|
||||||
|
# container; Postgres + MinIO come from the shared gu_common stack (must already be up).
|
||||||
|
#
|
||||||
|
# Endpoints:
|
||||||
|
# - Public (frontend, TLS via the proxy): https://gupi.glitch.university
|
||||||
|
# - Internal (app -> services, plain HTTP): docker hostnames gnommo-db / gnommo-minio
|
||||||
|
# (those hostnames live in docker-compose.prod.yml; here you only set credentials).
|
||||||
|
#
|
||||||
|
# Deploy with: ./deploy.sh (server-side it runs, from /opt/gupi:)
|
||||||
|
# docker compose -f /opt/gupi/docker-compose.prod.yml --env-file /opt/gupi/.env.prod up -d
|
||||||
|
|
||||||
|
# --- Shared PostgreSQL (gu_common; reachable as gnommo-db on the shared network) ---
|
||||||
|
POSTGRES_USER=gupi
|
||||||
|
POSTGRES_PASSWORD=CHANGE_ME
|
||||||
|
POSTGRES_DB=gupi
|
||||||
|
|
||||||
|
# --- Shared MinIO / S3 (gu_common; reachable as gnommo-minio) ---
|
||||||
|
MINIO_ROOT_USER=CHANGE_ME
|
||||||
|
MINIO_ROOT_PASSWORD=CHANGE_ME
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
OSINT_S3_BUCKET=gupi-osint
|
||||||
|
|
||||||
|
# --- App ---
|
||||||
|
# DOMAIN drives the default public origin (https://gupi.${DOMAIN}); override CORS_ORIGIN
|
||||||
|
# directly if the frontend is served somewhere else.
|
||||||
|
DOMAIN=glitch.university
|
||||||
|
# CORS_ORIGIN=https://gupi.glitch.university
|
||||||
|
JWT_SECRET=CHANGE_ME_LONG_RANDOM_SECRET
|
||||||
|
LEVEL_EDITING_ENABLED=false
|
||||||
|
MAX_DOCUMENT_BYTES=26214400
|
||||||
|
|
||||||
|
# --- OCR (evidence text extraction) ---
|
||||||
|
OCR_ENABLED=true
|
||||||
|
OCR_LANGUAGES=nor+eng
|
||||||
|
OCR_TIMEOUT_MS=20000
|
||||||
|
MAX_OCR_BYTES=15728640
|
||||||
|
MAX_EXTRACTED_TEXT_CHARACTERS=200000
|
||||||
|
|
||||||
|
# --- Evidence LLM judge (optional; disabled by default) ---
|
||||||
|
EVIDENCE_JUDGE_PROVIDER=disabled
|
||||||
|
EVIDENCE_JUDGE_MODEL=
|
||||||
|
EVIDENCE_JUDGE_VERSION=evidence_claim_v1
|
||||||
|
EVIDENCE_JUDGE_TIMEOUT_MS=10000
|
||||||
|
EVIDENCE_JUDGE_MAX_CHARACTERS=20000
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
|
.env.prod
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ if [ "$1" = "--skip-pull" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
SERVER="${DEPLOY_SERVER:-root@76.13.144.52}"
|
SERVER="${DEPLOY_SERVER:-root@76.13.144.52}"
|
||||||
REMOTE_DIR="${DEPLOY_DIR:-/opt/osint-board}"
|
REMOTE_DIR="${DEPLOY_DIR:-/opt/gupi}"
|
||||||
COMPOSE="docker compose -f ${REMOTE_DIR}/docker-compose.prod.yml --env-file /opt/gu_common/.env.prod"
|
# GUPI runs from its own dir with its own env; the shared services come from gu_common.
|
||||||
|
COMPOSE="docker compose -f ${REMOTE_DIR}/docker-compose.prod.yml --env-file ${REMOTE_DIR}/.env.prod"
|
||||||
|
|
||||||
TARGET_HOST=$(echo "${SERVER}" | sed 's/.*@//')
|
TARGET_HOST=$(echo "${SERVER}" | sed 's/.*@//')
|
||||||
OWN_IP=$(curl -sf --max-time 3 ifconfig.me 2>/dev/null || echo "unknown")
|
OWN_IP=$(curl -sf --max-time 3 ifconfig.me 2>/dev/null || echo "unknown")
|
||||||
@@ -54,8 +55,8 @@ rsync -avz --delete \
|
|||||||
--exclude '.DS_Store' \
|
--exclude '.DS_Store' \
|
||||||
./ "${SERVER}:${REMOTE_DIR}/"
|
./ "${SERVER}:${REMOTE_DIR}/"
|
||||||
|
|
||||||
echo "==> Verifying gu_common configuration..."
|
echo "==> Verifying GUPI environment..."
|
||||||
ssh "$SERVER" "test -f /opt/gu_common/.env.prod || { echo 'ERROR: /opt/gu_common/.env.prod is missing'; exit 1; }"
|
ssh "$SERVER" "test -f ${REMOTE_DIR}/.env.prod || { echo 'ERROR: ${REMOTE_DIR}/.env.prod is missing (copy .env.prod.example and fill it in)'; exit 1; }"
|
||||||
|
|
||||||
echo "==> Ensuring shared network exists..."
|
echo "==> Ensuring shared network exists..."
|
||||||
ssh "$SERVER" "docker network create gnommo 2>/dev/null || true"
|
ssh "$SERVER" "docker network create gnommo 2>/dev/null || true"
|
||||||
@@ -97,4 +98,4 @@ for i in $(seq 1 24); do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "==> Done! https://osint.glitch.university"
|
echo "==> Done! https://gupi.glitch.university"
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 8787
|
PORT: 8787
|
||||||
|
# Internal: reach gu_common services by their docker hostnames over plain HTTP.
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@gnommo-db:5432/${POSTGRES_DB}
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@gnommo-db:5432/${POSTGRES_DB}
|
||||||
CORS_ORIGIN: https://osint.${DOMAIN}
|
# Public: the frontend is served over TLS at gupi.glitch.university by the proxy.
|
||||||
|
CORS_ORIGIN: ${CORS_ORIGIN:-https://gupi.${DOMAIN}}
|
||||||
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}
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -2,11 +2,20 @@ import { expect, test } from '@playwright/test'
|
|||||||
|
|
||||||
test('pasting, connecting, and citing one patent screenshot completes the Scene 7 report lesson', async ({ page, request }) => {
|
test('pasting, connecting, and citing one patent screenshot completes the Scene 7 report lesson', async ({ page, request }) => {
|
||||||
test.setTimeout(60_000)
|
test.setTimeout(60_000)
|
||||||
const levels = await (await request.get('/api/levels')).json() as { id:string }[]
|
const pageErrors:Error[]=[]
|
||||||
const sceneSeven = levels.find(level => level.id.startsWith('barricelli-inventor-proof-case-'))
|
page.on('pageerror',error => pageErrors.push(error))
|
||||||
|
let sceneSeven:{ id:string } | undefined
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const levels=await (await request.get('/api/levels')).json() as { id:string }[]
|
||||||
|
sceneSeven=levels.find(level => level.id.startsWith('barricelli-inventor-proof-case-'))
|
||||||
|
return Boolean(sceneSeven)
|
||||||
|
},{ timeout:20_000,message:'Scene 7 playable clone should be seeded' }).toBe(true)
|
||||||
if (!sceneSeven) throw new Error('Scene 7 playable clone was not seeded')
|
if (!sceneSeven) throw new Error('Scene 7 playable clone was not seeded')
|
||||||
|
const sceneSevenId=sceneSeven.id
|
||||||
|
|
||||||
await page.goto(`/level/${sceneSeven.id}`)
|
await page.goto(`/level/${sceneSevenId}`)
|
||||||
|
await page.waitForTimeout(250)
|
||||||
|
if (pageErrors.length) throw new Error(`Scene 7 failed to render: ${pageErrors.map(error => error.message).join('; ')}`)
|
||||||
await expect(page.getByRole('heading', { name:'The Barricelli Files' })).toBeVisible()
|
await expect(page.getByRole('heading', { name:'The Barricelli Files' })).toBeVisible()
|
||||||
await expect(page.locator('.brief-panel')).toBeVisible()
|
await expect(page.locator('.brief-panel')).toBeVisible()
|
||||||
await expect(page.locator('.brief-goals')).toContainText('Prove Nils Aall Barricelli was an inventor')
|
await expect(page.locator('.brief-goals')).toContainText('Prove Nils Aall Barricelli was an inventor')
|
||||||
@@ -14,7 +23,7 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene
|
|||||||
await page.getByRole('button', { name:'BEGIN INVESTIGATION',exact:true }).click()
|
await page.getByRole('button', { name:'BEGIN INVESTIGATION',exact:true }).click()
|
||||||
|
|
||||||
const uploadResponse = page.waitForResponse(response => response.request().method() === 'POST'
|
const uploadResponse = page.waitForResponse(response => response.request().method() === 'POST'
|
||||||
&& response.url().includes(`/api/levels/${sceneSeven.id}/documents`) && response.status() === 201)
|
&& response.url().includes(`/api/levels/${sceneSevenId}/documents`) && response.status() === 201)
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
canvas.width = 1280
|
canvas.width = 1280
|
||||||
@@ -46,6 +55,8 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene
|
|||||||
await page.getByRole('button',{ name:'Classify as Clip' }).click()
|
await page.getByRole('button',{ name:'Classify as Clip' }).click()
|
||||||
await expect(page.locator('.source-file-widget')).toHaveCount(1)
|
await expect(page.locator('.source-file-widget')).toHaveCount(1)
|
||||||
await expect(page.locator('.source-file-widget')).toHaveAttribute('data-capture-kind','clipping')
|
await expect(page.locator('.source-file-widget')).toHaveAttribute('data-capture-kind','clipping')
|
||||||
|
await expect(page.locator('.source-file-widget > strong')).toHaveCount(0)
|
||||||
|
await expect(page.locator('.clip-provenance')).toHaveText('ADD DATE + SOURCE')
|
||||||
await expect(page.locator('.source-file-widget')).toHaveClass(/\barriving\b/)
|
await expect(page.locator('.source-file-widget')).toHaveClass(/\barriving\b/)
|
||||||
await expect(page.locator('.goal-complete-card')).toHaveCount(0)
|
await expect(page.locator('.goal-complete-card')).toHaveCount(0)
|
||||||
await expect(page.locator('.evidence-card.claim')).toContainText('Nils Aall Barricelli was an inventor')
|
await expect(page.locator('.evidence-card.claim')).toContainText('Nils Aall Barricelli was an inventor')
|
||||||
@@ -60,35 +71,48 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene
|
|||||||
await page.getByRole('button',{ name:'FILE',exact:true }).click()
|
await page.getByRole('button',{ name:'FILE',exact:true }).click()
|
||||||
await page.getByRole('menuitem',{ name:/INFO/ }).click()
|
await page.getByRole('menuitem',{ name:/INFO/ }).click()
|
||||||
await expect(page.getByLabel('Board presentation')).toHaveValue('clipping')
|
await expect(page.getByLabel('Board presentation')).toHaveValue('clipping')
|
||||||
|
await expect(page.getByLabel('Document title')).toHaveValue('')
|
||||||
|
await page.getByLabel('Published date').fill('1953-08-19')
|
||||||
|
await expect(page.getByLabel('Published time')).toHaveValue('')
|
||||||
|
await page.getByLabel('Source publication').fill('Google Patents · GB695913A')
|
||||||
|
await page.getByLabel('Source URL').fill('https://patents.google.com/patent/GB695913A/en')
|
||||||
|
await page.getByRole('button',{ name:'SAVE METADATA' }).click()
|
||||||
|
await expect(page.locator('.file-editor')).toHaveCount(0)
|
||||||
|
await expect(page.locator('.clip-provenance')).toHaveText('SOURCE RECORDED')
|
||||||
|
await page.getByRole('button',{ name:'FILE',exact:true }).click()
|
||||||
|
await page.getByRole('menuitem',{ name:/INFO/ }).click()
|
||||||
|
await expect(page.getByLabel('Document title')).toHaveValue('Google Patents · GB695913A')
|
||||||
|
await expect(page.getByLabel('Source publication')).toHaveValue('Google Patents · GB695913A')
|
||||||
await page.getByRole('button',{ name:'Close file editor' }).click()
|
await page.getByRole('button',{ name:'Close file editor' }).click()
|
||||||
await page.getByRole('button',{ name:'Close document' }).click()
|
await page.getByRole('button',{ name:'Close document',exact:true }).click()
|
||||||
|
|
||||||
await page.locator('.evidence-card.claim').click()
|
await page.locator('.evidence-card.claim').click()
|
||||||
await page.getByRole('button',{ name:'Red thread' }).click()
|
await page.getByRole('button',{ name:'Red thread' }).click()
|
||||||
await page.locator('.source-file-widget').click()
|
await page.locator('.source-file-widget').click()
|
||||||
await expect(page.locator('.thread-editor')).toBeVisible()
|
await expect(page.locator('.thread-editor')).toBeVisible()
|
||||||
await expect(page.getByLabel('Thread tag')).toHaveValue('Proof that…')
|
await expect(page.getByLabel('Thread tag')).toHaveValue('Proof that…')
|
||||||
|
await page.getByLabel('Thread tag').fill('Proof that Nils Aall Barricelli is named as inventor on patent GB695913A.')
|
||||||
await page.getByRole('button',{ name:'ADD TAG & TIGHTEN' }).click()
|
await page.getByRole('button',{ name:'ADD TAG & TIGHTEN' }).click()
|
||||||
|
|
||||||
await page.getByRole('button',{ name:'Case report' }).click()
|
await page.getByRole('button',{ name:'Case report' }).click()
|
||||||
await expect(page.locator('.case-report')).toBeVisible()
|
await expect(page.locator('.case-report')).toBeVisible()
|
||||||
await expect(page.locator('.report-claim')).toContainText('Nils Aall Barricelli was an inventor')
|
await expect(page.locator('.report-claim')).toContainText('Nils Aall Barricelli was an inventor')
|
||||||
await expect(page.locator('.report-evidence')).toContainText('Exhibit 1')
|
await expect(page.locator('.report-evidence')).toContainText('Exhibit 1')
|
||||||
await page.getByRole('button',{ name:'SUBMIT CASE REPORT' }).click()
|
await expect(page.locator('.report-evidence')).toContainText('Proof that Nils Aall Barricelli is named as inventor on patent GB695913A.')
|
||||||
await expect(page.locator('.report-verdict')).toContainText("The evidence is good enough, but the report itself won't hold up in court")
|
await expect(page.locator('.report-evidence')).toContainText('1953-08-19')
|
||||||
|
await expect(page.locator('.report-evidence')).toContainText('Google Patents · GB695913A')
|
||||||
await page.getByLabel('Evidence statement for Exhibit 1').fill('Proof that Nils Aall Barricelli is named as inventor on patent GB695913A.')
|
await expect(page.locator('.report-evidence a')).toHaveAttribute('href','https://patents.google.com/patent/GB695913A/en')
|
||||||
await page.getByLabel('Date for Exhibit 1').fill('1953-08-19')
|
|
||||||
await page.getByLabel('Source for Exhibit 1').fill('Google Patents · GB695913A')
|
|
||||||
await page.getByLabel('Source link for Exhibit 1').fill('https://patents.google.com/patent/GB695913A/en')
|
|
||||||
await page.getByRole('button',{ name:'SUBMIT CASE REPORT' }).click()
|
await page.getByRole('button',{ name:'SUBMIT CASE REPORT' }).click()
|
||||||
await expect(page.locator('.report-verdict')).toContainText('Case report accepted')
|
await expect(page.locator('.report-verdict')).toContainText('Case report accepted')
|
||||||
await expect(page.getByRole('button',{ name:/CLOSE CASE/ })).toBeVisible()
|
await expect(page.getByRole('button',{ name:/CLOSE CASE/ })).toBeVisible()
|
||||||
|
|
||||||
const level = await (await request.get(`/api/levels/${sceneSeven.id}`)).json()
|
const level = await (await request.get(`/api/levels/${sceneSevenId}`)).json()
|
||||||
expect(level.goals).toEqual([expect.objectContaining({ key:'barricelli.inventor-proof',status:'complete',newlyCompleted:false })])
|
expect(level.goals).toEqual([expect.objectContaining({ key:'barricelli.inventor-proof',status:'complete',newlyCompleted:false })])
|
||||||
expect(level.exhibits.filter((exhibit: { type:string }) => exhibit.type === 'document')).toEqual([
|
const savedDocuments=level.exhibits.filter((exhibit: { type:string }) => exhibit.type === 'document')
|
||||||
expect.objectContaining({ captureKind:'clipping',width:210,height:194 }),
|
expect(savedDocuments).toEqual([
|
||||||
|
expect.objectContaining({ title:'Google Patents · GB695913A',captureKind:'clipping',width:230,height:290 }),
|
||||||
])
|
])
|
||||||
|
expect(savedDocuments[0].rotation).toBeGreaterThanOrEqual(-10)
|
||||||
|
expect(savedDocuments[0].rotation).toBeLessThanOrEqual(10)
|
||||||
expect(level.report).toMatchObject({ status:'accepted',investigatorName:'Player' })
|
expect(level.report).toMatchObject({ status:'accepted',investigatorName:'Player' })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#071916" />
|
<meta name="theme-color" content="#071916" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Reenie+Beanie&display=swap" rel="stylesheet" />
|
||||||
<title>GUPI OSINT Board</title>
|
<title>GUPI OSINT Board</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- The player's field notebook: lines captured from NPCs during play, per playthrough.
|
||||||
|
-- A page can later be torn onto the board as a note exhibit.
|
||||||
|
CREATE TABLE osint.notebook_pages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
playthrough_id UUID NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
source_utterance_id UUID REFERENCES osint.utterances(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX notebook_pages_playthrough_idx ON osint.notebook_pages (playthrough_id, created_at);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Locally-issued player accounts. GUPI mints the JWT for now; external_id is
|
||||||
|
-- reserved so a glitch.university key-exchange can later link/migrate an account
|
||||||
|
-- without changing how playthroughs bind (they key off the JWT sub = users.id).
|
||||||
|
CREATE TABLE osint.users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
handle TEXT NOT NULL UNIQUE CHECK (handle ~ '^[a-z0-9_.-]{3,32}$'),
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
avatar_url TEXT,
|
||||||
|
external_id TEXT UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE osint.note_exhibits
|
||||||
|
ADD COLUMN presentation_kind TEXT NOT NULL DEFAULT 'luggage'
|
||||||
|
CHECK (presentation_kind IN ('luggage', 'lined_sheet'));
|
||||||
|
|
||||||
|
COMMENT ON COLUMN osint.note_exhibits.presentation_kind IS
|
||||||
|
'Visual presentation of a note exhibit. Notebook tear-outs use lined_sheet; ordinary working notes use luggage.';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
UPDATE osint.exhibits AS exhibit
|
||||||
|
SET width = 220,
|
||||||
|
height = 272,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM osint.document_exhibits AS document
|
||||||
|
WHERE document.exhibit_id = exhibit.id
|
||||||
|
AND document.capture_kind_id = 'clipping'
|
||||||
|
AND exhibit.width = 210
|
||||||
|
AND exhibit.height = 194;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
UPDATE osint.document_exhibits AS document
|
||||||
|
SET title = document.citation_text
|
||||||
|
WHERE document.capture_kind_id = 'clipping'
|
||||||
|
AND BTRIM(document.citation_text) <> ''
|
||||||
|
AND (
|
||||||
|
BTRIM(document.title) = ''
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM osint.assets AS asset
|
||||||
|
WHERE asset.id = document.asset_id
|
||||||
|
AND document.title = asset.original_name
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
UPDATE osint.exhibits AS exhibit
|
||||||
|
SET height = 220,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM osint.document_exhibits AS document
|
||||||
|
WHERE document.exhibit_id = exhibit.id
|
||||||
|
AND document.capture_kind_id = 'clipping'
|
||||||
|
AND exhibit.width = 220
|
||||||
|
AND exhibit.height = 272;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
UPDATE osint.exhibits AS exhibit
|
||||||
|
SET width = 220,
|
||||||
|
height = 220,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM osint.document_exhibits AS document
|
||||||
|
WHERE document.exhibit_id = exhibit.id
|
||||||
|
AND document.capture_kind_id = 'clipping'
|
||||||
|
AND (exhibit.width <> 220 OR exhibit.height <> 220);
|
||||||
|
|
||||||
|
UPDATE osint.document_exhibits AS document
|
||||||
|
SET title = document.citation_text
|
||||||
|
WHERE document.capture_kind_id = 'clipping'
|
||||||
|
AND BTRIM(document.citation_text) <> ''
|
||||||
|
AND (
|
||||||
|
BTRIM(document.title) = ''
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM osint.assets AS asset
|
||||||
|
WHERE asset.id = document.asset_id
|
||||||
|
AND document.title = asset.original_name
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
UPDATE osint.exhibits AS exhibit
|
||||||
|
SET width = 230,
|
||||||
|
height = 290,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM osint.document_exhibits AS document
|
||||||
|
WHERE document.exhibit_id = exhibit.id
|
||||||
|
AND document.capture_kind_id = 'clipping'
|
||||||
|
AND (exhibit.width <> 230 OR exhibit.height <> 290);
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
{
|
||||||
|
"slug": "barricelli-files",
|
||||||
|
"title": "The Barricelli Files",
|
||||||
|
"narrative": {
|
||||||
|
"cast": [
|
||||||
|
{
|
||||||
|
"key": "dobby",
|
||||||
|
"name": "Dobby",
|
||||||
|
"role": "Glitch University · Student Counsellor",
|
||||||
|
"defaultPose": "neutral"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "glitch-hunter",
|
||||||
|
"name": "Glitch Hunter",
|
||||||
|
"role": "Glitch University · Cosmotologist",
|
||||||
|
"defaultPose": "neutral",
|
||||||
|
"phoneNumber": "5550100",
|
||||||
|
"email": "hunter@glitch.university"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"graph": {
|
||||||
|
"entry": "dobby-intro",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"key": "dobby-intro",
|
||||||
|
"type": "dialogue",
|
||||||
|
"label": "Scene 5 · Dobby",
|
||||||
|
"x": 200,
|
||||||
|
"y": 60,
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "continue",
|
||||||
|
"label": "Continue",
|
||||||
|
"to": "dobby-tasks"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"utterances": [
|
||||||
|
{
|
||||||
|
"npc": "dobby",
|
||||||
|
"text": "Oh — you're the new Principal Investigator. I'm Dobby, student counsellor. Welcome to the Glitch University PI programme."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc": "dobby",
|
||||||
|
"text": "You'll want the GUPI course over at glitch.university — Open Source Intelligence, basics for citizen scientists. If you can't be bothered, there's a video that covers the absolute minimum."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc": "dobby",
|
||||||
|
"text": "It's remarkably simple, provided you have sufficient intelligence. Find the evidence on the internet, screenshot it, paste it in here. Fill out the source, date and URL so it can be verified — then connect it to the claim with a red thread and submit the report to the professor. You'll hear back within five earth-seconds."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "dobby-tasks",
|
||||||
|
"type": "dialogue",
|
||||||
|
"label": "Scene 6.1 · Dobby tasks",
|
||||||
|
"x": 200,
|
||||||
|
"y": 240,
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "continue",
|
||||||
|
"label": "Find a phone",
|
||||||
|
"to": "note-board"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"utterances": [
|
||||||
|
{
|
||||||
|
"key": "d0",
|
||||||
|
"npc": "dobby",
|
||||||
|
"text": "Back already? Well — we do have something. Our Cosmotologist is investigating somebody. Some old mathematician, it seems. You should call him up."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-name",
|
||||||
|
"parent": "d0",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "Who's the mathematician?",
|
||||||
|
"awardsFlag": "dobby.knows_barricelli_name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-name-a",
|
||||||
|
"parent": "d-name",
|
||||||
|
"npc": "dobby",
|
||||||
|
"text": "Niels Aall Barricelli. There — don't say I never give you anything."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-more",
|
||||||
|
"parent": "d-name-a",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "Tell me more about him.",
|
||||||
|
"awardsFlag": "dobby.knows_barricelli"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-more-a",
|
||||||
|
"parent": "d-more",
|
||||||
|
"npc": "dobby",
|
||||||
|
"text": "Italian-Norwegian mathematician. Niels — spelled with an 'e'. That's all you're getting from me."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-call2",
|
||||||
|
"parent": "d-more-a",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "Right. I'll call him.",
|
||||||
|
"terminal": "continue"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-call1",
|
||||||
|
"parent": "d-name-a",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "I'll call him.",
|
||||||
|
"terminal": "continue"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-reach",
|
||||||
|
"parent": "d0",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "How do I reach him?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-reach-a",
|
||||||
|
"parent": "d-reach",
|
||||||
|
"npc": "dobby",
|
||||||
|
"text": "There's a number floating about. Find a phone. You're an investigator — investigate."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-reach-go",
|
||||||
|
"parent": "d-reach-a",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "On it.",
|
||||||
|
"terminal": "continue"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "note-board",
|
||||||
|
"type": "level",
|
||||||
|
"label": "Scene 6 · Note board",
|
||||||
|
"x": 200,
|
||||||
|
"y": 420,
|
||||||
|
"templateSlug": "barricelli-phone-note",
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "report_back",
|
||||||
|
"label": "Call Glitch Hunter",
|
||||||
|
"to": "hunter-intro"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "phone",
|
||||||
|
"label": "Phone",
|
||||||
|
"to": "phone"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "phone",
|
||||||
|
"type": "phone",
|
||||||
|
"label": "Phone (dial Glitch Hunter)",
|
||||||
|
"x": 470,
|
||||||
|
"y": 420,
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "call-hunter",
|
||||||
|
"label": "Glitch Hunter",
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"to": "hunter-intro"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "hunter-intro",
|
||||||
|
"type": "dialogue",
|
||||||
|
"label": "Scene 6.2 · Glitch Hunter",
|
||||||
|
"x": 200,
|
||||||
|
"y": 600,
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "proceed",
|
||||||
|
"label": "Take the task",
|
||||||
|
"to": "hunter-correct"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "decline",
|
||||||
|
"label": "Back",
|
||||||
|
"to": "note-board"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"utterances": [
|
||||||
|
{
|
||||||
|
"key": "h0",
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"text": "Right — Dobby said you were up for some research tasks. I want a junior investigator. Interesting stuff, piling up everywhere. Question is: do you want in, or do you want out?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "h-out",
|
||||||
|
"parent": "h0",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "Out.",
|
||||||
|
"terminal": "decline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "h-in",
|
||||||
|
"parent": "h0",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "I'm in."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "h-in-a",
|
||||||
|
"parent": "h-in",
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"text": "Good. I think we just found the entrance to a rabbit hole. I'll give you the name: Niels Aall Barricelli. Does it ring a bell?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "h-noidea",
|
||||||
|
"parent": "h-in-a",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "No — no idea who that is.",
|
||||||
|
"terminal": "proceed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "h-knows",
|
||||||
|
"parent": "h-in-a",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "Niels Aall Barricelli — a Norwegian-Italian mathematician.",
|
||||||
|
"requiresFlag": "dobby.knows_barricelli",
|
||||||
|
"terminal": "proceed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "h-name",
|
||||||
|
"parent": "h-in-a",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "Niels Aall Barricelli.",
|
||||||
|
"requiresFlag": "dobby.knows_barricelli_name",
|
||||||
|
"terminal": "proceed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "hunter-correct",
|
||||||
|
"type": "dialogue",
|
||||||
|
"label": "Scene 6.2 · The task",
|
||||||
|
"x": 200,
|
||||||
|
"y": 780,
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "continue",
|
||||||
|
"label": "To the board",
|
||||||
|
"to": "scene7"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"utterances": [
|
||||||
|
{
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"text": "Ah — impressive. He's not very well known. But you got the first name wrong there. It's Nils, not Niels. Easy mistake to make. Might come in handy to remember that."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"text": "Anyway — the task is simple. Nils Aall Barricelli. He wasn't just a brilliant mathematician; he was an inventor. Prove that to me, using open sources, and I'll take you on board."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "scene7",
|
||||||
|
"type": "level",
|
||||||
|
"label": "Scene 7 · Prove he was an inventor",
|
||||||
|
"x": 200,
|
||||||
|
"y": 960,
|
||||||
|
"templateSlug": "barricelli-inventor-proof",
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "report_back",
|
||||||
|
"label": "Report back",
|
||||||
|
"to": "hunter-drawer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "hunter-drawer",
|
||||||
|
"type": "dialogue",
|
||||||
|
"label": "Scene 8 · Glitch Hunter",
|
||||||
|
"x": 200,
|
||||||
|
"y": 1140,
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "continue",
|
||||||
|
"label": "Continue",
|
||||||
|
"to": "luggage"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"utterances": [
|
||||||
|
{
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"text": "Ha — there it is. Using a suitcase as drawers. You can just take the drawer with you. Told you he was a genius."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "luggage",
|
||||||
|
"type": "merit",
|
||||||
|
"label": "Scene 9 · The Barricelli Luggage",
|
||||||
|
"x": 200,
|
||||||
|
"y": 1320,
|
||||||
|
"awardsFlag": "barricelli_luggage",
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "continue",
|
||||||
|
"label": "Continue",
|
||||||
|
"to": "hunter-deepweb"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "hunter-deepweb",
|
||||||
|
"type": "dialogue",
|
||||||
|
"label": "Scene 10 · The deep web",
|
||||||
|
"x": 200,
|
||||||
|
"y": 1500,
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "done",
|
||||||
|
"label": "End",
|
||||||
|
"to": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"utterances": [
|
||||||
|
{
|
||||||
|
"key": "w0",
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"text": "Well done — you've mastered Open Source Intelligence. Barricelli was an unusual man. Not only an inventor and a mathematician. A genius. He saved his family too, you know."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "w-who",
|
||||||
|
"parent": "w0",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "Who are you, exactly?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "w-who-a",
|
||||||
|
"parent": "w-who",
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"text": "Me? I study Philosophical Cosmology. There's a video series and a book over at Glitch University, if you're curious.",
|
||||||
|
"terminal": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "w-family",
|
||||||
|
"parent": "w0",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "Saved his family?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "w-family-a",
|
||||||
|
"parent": "w-family",
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"text": "A remarkable character. Yes, he did. But perhaps I shouldn't tell you — it's a fitting task, one that would prove some real skill. If you can tell me how old he was when he saved his mother and father, and had a newspaper write about it — you'll need the deep web. Historical archives, national libraries. Figure that out, and you're ready for something deeper.",
|
||||||
|
"terminal": "done"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"slug": "barricelli-phone-note",
|
||||||
|
"name": "Phone note",
|
||||||
|
"title": "Phone note",
|
||||||
|
"subtitle": "",
|
||||||
|
"brief": { "body": "", "concepts": [] },
|
||||||
|
"documents": [],
|
||||||
|
"folders": [],
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"title": "Note",
|
||||||
|
"content": "PHONE FOR GLITCH HUNTER\n\nCall: 5550100",
|
||||||
|
"presentation": "luggage",
|
||||||
|
"x": 420,
|
||||||
|
"y": 260,
|
||||||
|
"width": 230,
|
||||||
|
"height": 180
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import { readFile } from 'node:fs/promises'
|
import { readFile, stat } from 'node:fs/promises'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import type { CaseDocument, CaseState, ClaimExhibit, DocumentCaptureKind, PartyKind, SourceFileType } from '../src/types.js'
|
import type { CaseDocument, CaseState, ClaimExhibit, DocumentCaptureKind, NoteExhibit, NotePresentation, PartyKind, SourceFileType } from '../src/types.js'
|
||||||
|
|
||||||
type MysteryDocument = {
|
type MysteryDocument = {
|
||||||
key: string
|
key: string
|
||||||
@@ -37,22 +37,28 @@ type MysterySemanticRule = {
|
|||||||
goalKey:string; name:string; targetSubject:string; relatedSubject?:string; assertion:string; successFlagKey:string
|
goalKey:string; name:string; targetSubject:string; relatedSubject?:string; assertion:string; successFlagKey:string
|
||||||
relatedFlagKey?:string; minimumConfidence?:number; evaluatorVersion?:string; enabled?:boolean
|
relatedFlagKey?:string; minimumConfidence?:number; evaluatorVersion?:string; enabled?:boolean
|
||||||
}
|
}
|
||||||
type MysteryManifest = {
|
// A single playable board (level template). Assets are filenames resolved against
|
||||||
|
// the mystery folder (e.g. "assets/photo.png"), matching the on-disk layout.
|
||||||
|
type MysteryLevel = {
|
||||||
slug: string
|
slug: string
|
||||||
name: string
|
name: string
|
||||||
title: string
|
title: string
|
||||||
subtitle: string
|
subtitle?: string
|
||||||
timelineRange?: { start: string; end: string }
|
timelineRange?: { start: string; end: string }
|
||||||
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[] }[]
|
||||||
claims?: { key:string;statement:string;x:number;y:number;width?:number;height?:number }[]
|
claims?: { key:string;statement:string;x:number;y:number;width?:number;height?:number }[]
|
||||||
|
notes?: { title?:string;content:string;presentation?:NotePresentation;x:number;y:number;width?:number;height?:number }[]
|
||||||
report?: { title?:string;requiredForCompletion?:boolean }
|
report?: { title?:string;requiredForCompletion?:boolean }
|
||||||
goals?: MysteryGoal[]
|
goals?: MysteryGoal[]
|
||||||
evidenceMatchRules?: MysteryEvidenceMatchRule[]
|
evidenceMatchRules?: MysteryEvidenceMatchRule[]
|
||||||
evidenceSemanticRules?: MysterySemanticRule[]
|
evidenceSemanticRules?: MysterySemanticRule[]
|
||||||
narrative?: MysteryNarrative
|
|
||||||
}
|
}
|
||||||
|
// Legacy single-manifest: one level plus an optional narrative in the same file.
|
||||||
|
type MysteryManifest = MysteryLevel & { narrative?: MysteryNarrative }
|
||||||
|
// New self-contained format: a mystery with every level it uses embedded.
|
||||||
|
type MysteryFile = { slug: string; title: string; levels: MysteryLevel[]; narrative?: MysteryNarrative }
|
||||||
|
|
||||||
function requireOk(response: Response, action: string) {
|
function requireOk(response: Response, action: string) {
|
||||||
if (response.ok) return response
|
if (response.ok) return response
|
||||||
@@ -75,24 +81,30 @@ async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string
|
|||||||
return await response.json() as CaseDocument
|
return await response.json() as CaseDocument
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importMysteryTemplate(manifestPath: string, baseUrl = 'http://localhost:8787', adminJwt = process.env.OSINT_ADMIN_JWT) {
|
type ImportHeaders = Record<string, string>
|
||||||
const absoluteManifest = path.resolve(manifestPath)
|
function authHeaders(adminJwt?: string): { authorization?: string; headers: ImportHeaders } {
|
||||||
const manifest = JSON.parse(await readFile(absoluteManifest, 'utf8')) as MysteryManifest
|
|
||||||
const manifestDir = path.dirname(absoluteManifest)
|
|
||||||
const authoringId = `${manifest.slug}-authoring-${Date.now()}`
|
|
||||||
const authorization = adminJwt ? `Bearer ${adminJwt}` : undefined
|
const authorization = adminJwt ? `Bearer ${adminJwt}` : undefined
|
||||||
const headers = { 'content-type': 'application/json', ...(authorization ? { authorization } : {}) }
|
return { authorization, headers: { 'content-type': 'application/json', ...(authorization ? { authorization } : {}) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
type LevelResult = { template: { slug: string; currentVersion: number }; authoringLevelId: string; playableLevel: CaseState }
|
||||||
|
|
||||||
|
// Import one board: create a mutable authoring level, upload assets, save exhibits,
|
||||||
|
// goals and evidence rules, freeze an immutable template, and instantiate a playable copy.
|
||||||
|
async function importLevel(baseUrl: string, folderDir: string, level: MysteryLevel, headers: ImportHeaders, authorization?: string): Promise<LevelResult> {
|
||||||
|
const authoringId = `${level.slug}-authoring-${Date.now()}`
|
||||||
const createdResponse = await requireOk(await fetch(`${baseUrl}/api/levels`, {
|
const createdResponse = await requireOk(await fetch(`${baseUrl}/api/levels`, {
|
||||||
method: 'POST', headers,
|
method: 'POST', headers,
|
||||||
body: JSON.stringify({ id: authoringId, title: manifest.title, subtitle: manifest.subtitle }),
|
body: JSON.stringify({ id: authoringId, title: level.title, subtitle: level.subtitle || '' }),
|
||||||
}), '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 }>()
|
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 levelFolders = level.folders || []
|
||||||
|
levelFolders.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 level.documents || []) {
|
||||||
const uploaded = await uploadAsset(baseUrl, state.id, manifestDir, source, authorization)
|
const uploaded = await uploadAsset(baseUrl, state.id, folderDir, source, authorization)
|
||||||
documents.set(source.key, {
|
documents.set(source.key, {
|
||||||
id: uploaded?.id || randomUUID(), type: 'document', title: source.title, publishedAt: source.publishedAt,
|
id: uploaded?.id || randomUUID(), type: 'document', title: source.title, publishedAt: source.publishedAt,
|
||||||
x: documentPositions.get(source.key)?.x || 100, y: documentPositions.get(source.key)?.y || 100,
|
x: documentPositions.get(source.key)?.x || 100, y: documentPositions.get(source.key)?.y || 100,
|
||||||
@@ -103,17 +115,19 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderIds = new Map(manifest.folders.map(folder => [folder.key, randomUUID()]))
|
const folderIds = new Map(levelFolders.map(folder => [folder.key, randomUUID()]))
|
||||||
state.brief = { body: manifest.brief.body, concepts: manifest.brief.concepts.map(concept => ({ id: randomUUID(), ...concept })) }
|
if (level.brief) state.brief = { body: level.brief.body, concepts: level.brief.concepts.map(concept => ({ id: randomUUID(), ...concept })) }
|
||||||
state.views = state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: manifest.timelineRange ? 'fixed' : 'auto', range: manifest.timelineRange } : view)
|
state.views = state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: level.timelineRange ? 'fixed' : 'auto', range: level.timelineRange } : view)
|
||||||
const folders = manifest.folders.map(folder => ({
|
const folders = levelFolders.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, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false,
|
x: folder.x, y: folder.y, width: folder.width, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false,
|
||||||
} as const))
|
} as const))
|
||||||
const claims:ClaimExhibit[]=(manifest.claims || []).map(claim => ({ id:randomUUID(),type:'claim',title:claim.statement,statement:claim.statement,
|
const claims:ClaimExhibit[]=(level.claims || []).map(claim => ({ id:randomUUID(),type:'claim',title:claim.statement,statement:claim.statement,
|
||||||
x:claim.x,y:claim.y,width:claim.width || 310,height:claim.height || 180,rotation:0,zIndex:2,hidden:false }))
|
x:claim.x,y:claim.y,width:claim.width || 310,height:claim.height || 180,rotation:0,zIndex:2,hidden:false }))
|
||||||
state.exhibits = [...documents.values(), ...folders,...claims]
|
const notes:NoteExhibit[]=(level.notes || []).map(note => ({ id:randomUUID(),type:'note',title:note.title || 'NOTE',content:note.content,
|
||||||
state.relations = manifest.folders.flatMap(folder => folder.members.map((key, memberIndex) => {
|
presentation:note.presentation || 'luggage',x:note.x,y:note.y,width:note.width || 230,height:note.height || 180,rotation:0,zIndex:2,hidden:false }))
|
||||||
|
state.exhibits = [...documents.values(), ...folders,...claims,...notes]
|
||||||
|
state.relations = levelFolders.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 {
|
||||||
@@ -122,26 +136,26 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
state.connections = []
|
state.connections = []
|
||||||
state.report=manifest.report ? { title:manifest.report.title || 'Case Report',investigatorName:'',requiredForCompletion:manifest.report.requiredForCompletion !== false,
|
state.report=level.report ? { title:level.report.title || 'Case Report',investigatorName:'',requiredForCompletion:level.report.requiredForCompletion !== false,
|
||||||
status:'draft',issues:[],claims:[] } : undefined
|
status:'draft',issues:[],claims:[] } : undefined
|
||||||
state.viewport = { x: 0, y: 28, zoom: 0.7 }
|
state.viewport = { x: 0, y: 28, zoom: 0.7 }
|
||||||
|
|
||||||
await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||||
method: 'PUT', headers, body: JSON.stringify(state),
|
method: 'PUT', headers, body: JSON.stringify(state),
|
||||||
}), 'Save authored mystery')
|
}), 'Save authored level')
|
||||||
|
|
||||||
const goalIds = new Map<string,string>()
|
const goalIds = new Map<string,string>()
|
||||||
for (const goal of manifest.goals || []) {
|
for (const goal of level.goals || []) {
|
||||||
const response = await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/goals`, {
|
const response = await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/goals`, {
|
||||||
method:'POST',headers,body:JSON.stringify(goal),
|
method:'POST',headers,body:JSON.stringify(goal),
|
||||||
}), `Create goal ${goal.key}`)
|
}), `Create goal ${goal.key}`)
|
||||||
const created = await response.json() as { id:string;key:string }
|
const created = await response.json() as { id:string;key:string }
|
||||||
goalIds.set(created.key,created.id)
|
goalIds.set(created.key,created.id)
|
||||||
}
|
}
|
||||||
for (const rule of manifest.evidenceMatchRules || []) await requireOk(await fetch(
|
for (const rule of level.evidenceMatchRules || []) await requireOk(await fetch(
|
||||||
`${baseUrl}/api/levels/${state.id}/evidence-match-rules`, { method:'POST',headers,body:JSON.stringify(rule) }),
|
`${baseUrl}/api/levels/${state.id}/evidence-match-rules`, { method:'POST',headers,body:JSON.stringify(rule) }),
|
||||||
`Create evidence match rule ${rule.name}`)
|
`Create evidence match rule ${rule.name}`)
|
||||||
for (const rule of manifest.evidenceSemanticRules || []) {
|
for (const rule of level.evidenceSemanticRules || []) {
|
||||||
const goalId = goalIds.get(rule.goalKey)
|
const goalId = goalIds.get(rule.goalKey)
|
||||||
if (!goalId) throw new Error(`Semantic evidence rule ${rule.name} refers to unknown goal ${rule.goalKey}`)
|
if (!goalId) throw new Error(`Semantic evidence rule ${rule.name} refers to unknown goal ${rule.goalKey}`)
|
||||||
await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/evidence-semantic-rules`, {
|
await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/evidence-semantic-rules`, {
|
||||||
@@ -149,47 +163,72 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
|
|||||||
}), `Create semantic evidence rule ${rule.name}`)
|
}), `Create semantic evidence rule ${rule.name}`)
|
||||||
}
|
}
|
||||||
const templateResponse = await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
|
const templateResponse = await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
|
||||||
method: 'POST', headers, body: JSON.stringify({ slug: manifest.slug, name: manifest.name }),
|
method: 'POST', headers, body: JSON.stringify({ slug: level.slug, name: level.name || level.title }),
|
||||||
}), 'Freeze mystery template')
|
}), 'Freeze level 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.
|
const playableId = `${level.slug}-case-${Date.now()}`
|
||||||
let mystery: { slug: string } | undefined
|
const playableResponse = await requireOk(await fetch(`${baseUrl}/api/templates/${level.slug}/levels?edit=1`, {
|
||||||
if (manifest.narrative) {
|
method: 'POST', headers, body: JSON.stringify({ id: playableId, title: level.title }),
|
||||||
const mysteryResponse = await requireOk(await fetch(`${baseUrl}/api/mysteries?edit=1`, {
|
}), 'Instantiate playable level')
|
||||||
method: 'POST', headers, body: JSON.stringify({ slug: manifest.slug, title: manifest.title, cast: manifest.narrative.cast }),
|
const playable = await playableResponse.json() as CaseState
|
||||||
}), 'Author narrative mystery')
|
return { template, authoringLevelId: state.id, playableLevel: playable }
|
||||||
mystery = await mysteryResponse.json() as { slug: string }
|
}
|
||||||
|
|
||||||
// Seed the story flow graph (default authored content that survives re-imports).
|
// Author the narrative mystery (NPC cast) and seed its story flow graph.
|
||||||
if (manifest.narrative.graph) {
|
async function importNarrative(baseUrl: string, slug: string, title: string, narrative: MysteryNarrative, headers: ImportHeaders, authorization?: string): Promise<{ slug: string }> {
|
||||||
|
const mysteryResponse = await requireOk(await fetch(`${baseUrl}/api/mysteries?edit=1`, {
|
||||||
|
method: 'POST', headers, body: JSON.stringify({ slug, title, cast: narrative.cast }),
|
||||||
|
}), 'Author narrative mystery')
|
||||||
|
const mystery = await mysteryResponse.json() as { slug: string }
|
||||||
|
if (narrative.graph) {
|
||||||
const listResponse = await requireOk(await fetch(`${baseUrl}/api/admin/mysteries`, { headers: authorization ? { authorization } : undefined }), 'List mysteries')
|
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 mysteries = await listResponse.json() as { id: string; slug: string }[]
|
||||||
const mysteryId = mysteries.find(m => m.slug === manifest.slug)?.id
|
const mysteryId = mysteries.find(m => m.slug === slug)?.id
|
||||||
if (mysteryId) await requireOk(await fetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, {
|
if (mysteryId) await requireOk(await fetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, {
|
||||||
method: 'POST', headers, body: JSON.stringify(manifest.narrative.graph),
|
method: 'POST', headers, body: JSON.stringify(narrative.graph),
|
||||||
}), 'Seed story graph')
|
}), 'Seed story graph')
|
||||||
}
|
}
|
||||||
}
|
return mystery
|
||||||
|
}
|
||||||
|
|
||||||
const playableId = `${manifest.slug}-case-${Date.now()}`
|
// Legacy single-manifest import: one level plus an optional narrative in the same file.
|
||||||
const playableResponse = await requireOk(await fetch(`${baseUrl}/api/templates/${manifest.slug}/levels?edit=1`, {
|
export async function importMysteryTemplate(manifestPath: string, baseUrl = 'http://localhost:8787', adminJwt = process.env.OSINT_ADMIN_JWT) {
|
||||||
method: 'POST', headers, body: JSON.stringify({ id: playableId, title: manifest.title }),
|
const absoluteManifest = path.resolve(manifestPath)
|
||||||
}), 'Instantiate playable mystery')
|
const manifest = JSON.parse(await readFile(absoluteManifest, 'utf8')) as MysteryManifest
|
||||||
const playable = await playableResponse.json() as CaseState
|
const folderDir = path.dirname(absoluteManifest)
|
||||||
return { manifest, template, mystery, authoringLevelId: state.id, playableLevel: playable }
|
const { headers, authorization } = authHeaders(adminJwt)
|
||||||
|
const { narrative, ...level } = manifest
|
||||||
|
const result = await importLevel(baseUrl, folderDir, level, headers, authorization)
|
||||||
|
const mystery = narrative ? await importNarrative(baseUrl, manifest.slug, manifest.title, narrative, headers, authorization) : undefined
|
||||||
|
return { manifest, template: result.template, mystery, authoringLevelId: result.authoringLevelId, playableLevel: result.playableLevel }
|
||||||
|
}
|
||||||
|
|
||||||
|
// New self-contained import: a mystery folder (or single-file) with every level embedded.
|
||||||
|
// Accepts a directory (uses <dir>/mystery.json) or a manifest path; falls back to the
|
||||||
|
// legacy path when the file has no top-level `levels` array.
|
||||||
|
export async function importMystery(inputPath: string, baseUrl = 'http://localhost:8787', adminJwt = process.env.OSINT_ADMIN_JWT) {
|
||||||
|
const resolved = path.resolve(inputPath)
|
||||||
|
const manifestPath = (await stat(resolved)).isDirectory() ? path.join(resolved, 'mystery.json') : resolved
|
||||||
|
const parsed = JSON.parse(await readFile(manifestPath, 'utf8')) as MysteryFile | MysteryManifest
|
||||||
|
if (!Array.isArray((parsed as MysteryFile).levels)) return importMysteryTemplate(manifestPath, baseUrl, adminJwt)
|
||||||
|
const file = parsed as MysteryFile
|
||||||
|
const folderDir = path.dirname(manifestPath)
|
||||||
|
const { headers, authorization } = authHeaders(adminJwt)
|
||||||
|
const levels: LevelResult[] = []
|
||||||
|
for (const level of file.levels) levels.push(await importLevel(baseUrl, folderDir, level, headers, authorization))
|
||||||
|
const mystery = file.narrative ? await importNarrative(baseUrl, file.slug, file.title, file.narrative, headers, authorization) : undefined
|
||||||
|
return { slug: file.slug, title: file.title, levels, mystery }
|
||||||
}
|
}
|
||||||
|
|
||||||
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ''
|
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ''
|
||||||
if (invokedPath === fileURLToPath(import.meta.url)) {
|
if (invokedPath === fileURLToPath(import.meta.url)) {
|
||||||
const manifestPath = process.argv[2]
|
const inputPath = process.argv[2]
|
||||||
if (!manifestPath) throw new Error('Usage: npm run mystery:import -- <manifest.json>')
|
if (!inputPath) throw new Error('Usage: npm run mystery:import -- <mystery-folder | manifest.json>')
|
||||||
const result = await importMysteryTemplate(manifestPath, process.env.OSINT_BOARD_URL)
|
const result = await importMystery(inputPath, process.env.OSINT_BOARD_URL)
|
||||||
console.log(JSON.stringify({
|
const playUrl = `${process.env.OSINT_BOARD_URL || 'http://localhost:8787'}/`
|
||||||
template: `${result.template.slug}@v${result.template.currentVersion}`,
|
const summary = 'levels' in result
|
||||||
mystery: result.mystery ? result.mystery.slug : undefined,
|
? { mystery: result.mystery?.slug, levels: result.levels.map(l => `${l.template.slug}@v${l.template.currentVersion}`), playUrl }
|
||||||
authoringLevelId: result.authoringLevelId,
|
: { template: `${result.template.slug}@v${result.template.currentVersion}`, mystery: result.mystery?.slug, authoringLevelId: result.authoringLevelId, playableLevelId: result.playableLevel.id, playUrl }
|
||||||
playableLevelId: result.playableLevel.id,
|
console.log(JSON.stringify(summary, null, 2))
|
||||||
playUrl: `${process.env.OSINT_BOARD_URL || 'http://localhost:8787'}/`,
|
|
||||||
}, null, 2))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ suite('normalized level persistence API', () => {
|
|||||||
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', captureKind:'full_page',metadata: {}, ...placed(1051, 417, 205, 282, 2) }
|
const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', captureKind:'full_page',metadata: {}, ...placed(1051, 417, 205, 282, 2) }
|
||||||
const gatedDocument: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Later tip', body: [], regions: [], fileType: 'image', captureKind:'clipping',metadata: {}, requiredFlags: ['tip.received'], ...placed(1260, 417, 210, 178, 3) }
|
const gatedDocument: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Later tip', body: [], regions: [], fileType: 'image', captureKind:'clipping',metadata: {}, requiredFlags: ['tip.received'], ...placed(1260, 417, 210, 178, 3) }
|
||||||
const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) }
|
const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) }
|
||||||
const note: NoteExhibit = { id: randomUUID(), type: 'note', title: 'Extract', content: 'Date matters', ...placed(420, 300, 108, 154) }
|
const note: NoteExhibit = { id:randomUUID(),type:'note',title:'Extract',content:'Date matters',presentation:'lined_sheet',...placed(420,300,220,270) }
|
||||||
const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) }
|
const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) }
|
||||||
const party: PartyExhibit = { id: randomUUID(), type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as correspondent.', aliases: ['A. A. L.'], ...placed(720, 250, 280, 190) }
|
const party: PartyExhibit = { id: randomUUID(), type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as correspondent.', aliases: ['A. A. L.'], ...placed(720, 250, 280, 190) }
|
||||||
state.exhibits = [document, gatedDocument, folder, note, event, party]
|
state.exhibits = [document, gatedDocument, folder, note, event, party]
|
||||||
@@ -123,6 +123,7 @@ suite('normalized level persistence API', () => {
|
|||||||
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.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
|
expect(loaded.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
|
||||||
expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true })
|
expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true })
|
||||||
|
expect(loaded.exhibits.find(item => item.id === note.id)).toMatchObject({ presentation:'lined_sheet',width:220,height:270 })
|
||||||
expect(loaded.exhibits.find(item => item.id === document.id)).toMatchObject({ captureKind:'full_page',width:205,height:282 })
|
expect(loaded.exhibits.find(item => item.id === document.id)).toMatchObject({ captureKind:'full_page',width:205,height:282 })
|
||||||
expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })]))
|
expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })]))
|
||||||
expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' })
|
expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' })
|
||||||
@@ -234,6 +235,7 @@ suite('normalized level persistence API', () => {
|
|||||||
expect(clone.exhibits.find(item => item.type === 'folder')).toMatchObject({ x: 685, y: 417 })
|
expect(clone.exhibits.find(item => item.type === 'folder')).toMatchObject({ x: 685, y: 417 })
|
||||||
expect(clone.relations.filter(relation => relation.type === 'supports')).toHaveLength(2)
|
expect(clone.relations.filter(relation => relation.type === 'supports')).toHaveLength(2)
|
||||||
expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 })
|
expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 })
|
||||||
|
expect(clone.exhibits.find(item => item.type === 'note')).toMatchObject({ presentation:'lined_sheet',width:220,height:270 })
|
||||||
expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id)
|
expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id)
|
||||||
const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
|
const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
|
||||||
expect(authoredClone.exhibits.find(item => item.type === 'document' && item.title === 'Later tip')).toMatchObject({ captureKind:'clipping',requiredFlags: ['tip.received'] })
|
expect(authoredClone.exhibits.find(item => item.type === 'document' && item.title === 'Later tip')).toMatchObject({ captureKind:'clipping',requiredFlags: ['tip.received'] })
|
||||||
@@ -250,6 +252,37 @@ suite('normalized level persistence API', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('evaluates existing documents when an evidence fingerprint is added later', async () => {
|
||||||
|
const created = await adminFetch(`${baseUrl}/api/levels`, {
|
||||||
|
method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ id:'late-evidence-rule',title:'Late evidence rule' }),
|
||||||
|
})
|
||||||
|
expect(created.status).toBe(201)
|
||||||
|
const level = await created.json() as CaseState
|
||||||
|
const upload = new FormData()
|
||||||
|
upload.append('file',new Blob(['Archive patent 93585 names Niels Aall Baricelli and describes a Koffert-kommode.'],{ type:'text/plain' }),'patent.txt')
|
||||||
|
const uploaded = await (await fetch(`${baseUrl}/api/levels/${level.id}/documents`,{ method:'POST',body:upload })).json() as DocumentExhibit & {
|
||||||
|
analysis:{ matchedFlags:string[] }
|
||||||
|
}
|
||||||
|
expect(uploaded.analysis.matchedFlags).toEqual([])
|
||||||
|
|
||||||
|
const rule = await adminFetch(`${baseUrl}/api/levels/${level.id}/evidence-match-rules`,{
|
||||||
|
method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
||||||
|
name:'Late National Library fingerprint',flagKey:'late.patent-recognized',minimumAnchorMatches:2,
|
||||||
|
anchors:[
|
||||||
|
{ phrase:'Archive patent 93585 names Niels Aall Baricelli',minimumSimilarity:.7 },
|
||||||
|
{ phrase:'describes a Koffert-kommode',minimumSimilarity:.7 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
expect(rule.status).toBe(201)
|
||||||
|
expect(await (await adminFetch(`${baseUrl}/api/levels/${level.id}/flags`)).json()).toEqual([
|
||||||
|
expect.objectContaining({ key:'late.patent-recognized',earnedAt:expect.any(String) }),
|
||||||
|
])
|
||||||
|
const evaluation = 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(evaluation.rows).toEqual([{ matched:true,matched_anchor_count:2 }])
|
||||||
|
})
|
||||||
|
|
||||||
it('imports the data-defined Scene 7 template with its private recognition rules', async () => {
|
it('imports the data-defined Scene 7 template with its private recognition rules', async () => {
|
||||||
const { importMysteryTemplate } = await import('../scripts/importMysteryTemplate.js')
|
const { importMysteryTemplate } = await import('../scripts/importMysteryTemplate.js')
|
||||||
const manifestPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'mysteries', 'barricelli-scene-7', 'mystery.json')
|
const manifestPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'mysteries', 'barricelli-scene-7', 'mystery.json')
|
||||||
@@ -299,7 +332,7 @@ suite('normalized level persistence API', () => {
|
|||||||
relatedState.connections.push({ id:relatedConnectionId,fromExhibitId:relatedClaim.id,toExhibitId:fatherDocument.id,label:'Proof that the Barricelli family included an inventor.',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
|
relatedState.connections.push({ id:relatedConnectionId,fromExhibitId:relatedClaim.id,toExhibitId:fatherDocument.id,label:'Proof that the Barricelli family included an inventor.',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
|
||||||
expect((await fetch(`${baseUrl}/api/levels/${relatedState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(relatedState) })).status).toBe(200)
|
expect((await fetch(`${baseUrl}/api/levels/${relatedState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(relatedState) })).status).toBe(200)
|
||||||
const relatedSubmission=await (await fetch(`${baseUrl}/api/levels/${relatedState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
const relatedSubmission=await (await fetch(`${baseUrl}/api/levels/${relatedState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
||||||
investigatorName:'Test Player',evidence:[{ connectionId:relatedConnectionId,documentExhibitId:fatherDocument.id,relationText:'Proof that the Barricelli family included an inventor.' }],
|
investigatorName:'Test Player',
|
||||||
}) })).json()
|
}) })).json()
|
||||||
expect(relatedSubmission).toMatchObject({ status:'evidence_insufficient',issues:expect.arrayContaining(['missing_accepted_evidence','connected_evidence_unverified']),
|
expect(relatedSubmission).toMatchObject({ status:'evidence_insufficient',issues:expect.arrayContaining(['missing_accepted_evidence','connected_evidence_unverified']),
|
||||||
feedback:expect.stringContaining('related person rather than the claim subject'),claims:[expect.objectContaining({ evidence:[expect.objectContaining({
|
feedback:expect.stringContaining('related person rather than the claim subject'),claims:[expect.objectContaining({ evidence:[expect.objectContaining({
|
||||||
@@ -319,14 +352,24 @@ suite('normalized level persistence API', () => {
|
|||||||
reportState.connections.push({ id:connectionId,fromExhibitId:claim.id,toExhibitId:targetDocument.id,label:'Proof that…',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
|
reportState.connections.push({ id:connectionId,fromExhibitId:claim.id,toExhibitId:targetDocument.id,label:'Proof that…',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
|
||||||
expect((await fetch(`${baseUrl}/api/levels/${reportState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(reportState) })).status).toBe(200)
|
expect((await fetch(`${baseUrl}/api/levels/${reportState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(reportState) })).status).toBe(200)
|
||||||
const incomplete=await fetch(`${baseUrl}/api/levels/${reportState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
const incomplete=await fetch(`${baseUrl}/api/levels/${reportState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
||||||
investigatorName:'Test Player',evidence:[{ connectionId,documentExhibitId:targetDocument.id,relationText:'Proof that…' }],
|
investigatorName:'Test Player',
|
||||||
|
// Legacy/forged report fields must not mutate board-owned provenance.
|
||||||
|
evidence:[{ connectionId,documentExhibitId:targetDocument.id,relationText:'Forged complete statement',publishedAt:'1953-08-19',sourceCitation:'Forged source' }],
|
||||||
}) })
|
}) })
|
||||||
expect(incomplete.status).toBe(201)
|
expect(incomplete.status).toBe(201)
|
||||||
expect(await incomplete.json()).toMatchObject({ status:'evidence_accepted_report_incomplete',issues:expect.arrayContaining(['unfinished_relation','missing_date','missing_source']),
|
expect(await incomplete.json()).toMatchObject({ status:'evidence_accepted_report_incomplete',issues:expect.arrayContaining(['unfinished_relation','missing_date','missing_source']),
|
||||||
feedback:"The evidence is good enough, but the report itself won't hold up in court. Add the date, cite the source, and provide the link if you can. Then we can accept it." })
|
feedback:"The evidence is good enough, but the report itself won't hold up in court. Add the date, cite the source, and provide the link if you can. Then we can accept it." })
|
||||||
|
const targetOnBoard=reportState.exhibits.find(exhibit => exhibit.type === 'document' && exhibit.id === targetDocument.id)
|
||||||
|
if (!targetOnBoard || targetOnBoard.type !== 'document') throw new Error('Target document missing from board')
|
||||||
|
targetOnBoard.publishedAt='1953-08-19T00:00:00.000Z'
|
||||||
|
targetOnBoard.sourceCitation='Google Patents · GB695913A'
|
||||||
|
targetOnBoard.sourceUri='https://patents.google.com/patent/GB695913A/en'
|
||||||
|
const targetConnection=reportState.connections.find(connection => connection.id === connectionId)
|
||||||
|
if (!targetConnection) throw new Error('Target connection missing from board')
|
||||||
|
targetConnection.label='Proof that Barricelli is named as the inventor on patent GB695913A.'
|
||||||
|
expect((await fetch(`${baseUrl}/api/levels/${reportState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(reportState) })).status).toBe(200)
|
||||||
const accepted=await fetch(`${baseUrl}/api/levels/${reportState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
const accepted=await fetch(`${baseUrl}/api/levels/${reportState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
||||||
investigatorName:'Test Player',evidence:[{ connectionId,documentExhibitId:targetDocument.id,relationText:'Proof that Barricelli is named as the inventor on patent GB695913A.',
|
investigatorName:'Test Player',
|
||||||
publishedAt:'1953-08-19',sourceCitation:'Google Patents · GB695913A',sourceUri:'https://patents.google.com/patent/GB695913A/en' }],
|
|
||||||
}) })
|
}) })
|
||||||
expect(accepted.status).toBe(201)
|
expect(accepted.status).toBe(201)
|
||||||
const acceptedBody=await accepted.json()
|
const acceptedBody=await accepted.json()
|
||||||
@@ -345,10 +388,14 @@ suite('normalized level persistence API', () => {
|
|||||||
const repeatedState=await (await fetch(`${baseUrl}/api/levels/${reportState.id}`)).json() as CaseState
|
const repeatedState=await (await fetch(`${baseUrl}/api/levels/${reportState.id}`)).json() as CaseState
|
||||||
const repeatedConnectionId=randomUUID()
|
const repeatedConnectionId=randomUUID()
|
||||||
repeatedState.connections.push({ id:repeatedConnectionId,fromExhibitId:claim.id,toExhibitId:repeatedDocument.id,label:'Proof that Barricelli is named as the inventor on patent GB695913A.',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
|
repeatedState.connections.push({ id:repeatedConnectionId,fromExhibitId:claim.id,toExhibitId:repeatedDocument.id,label:'Proof that Barricelli is named as the inventor on patent GB695913A.',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
|
||||||
|
const repeatedOnBoard=repeatedState.exhibits.find(exhibit => exhibit.type === 'document' && exhibit.id === repeatedDocument.id)
|
||||||
|
if (!repeatedOnBoard || repeatedOnBoard.type !== 'document') throw new Error('Repeated document missing from board')
|
||||||
|
repeatedOnBoard.publishedAt='1953-08-19T00:00:00.000Z'
|
||||||
|
repeatedOnBoard.sourceCitation='Google Patents · GB695913A'
|
||||||
|
repeatedOnBoard.sourceUri='https://patents.google.com/patent/GB695913A/en'
|
||||||
expect((await fetch(`${baseUrl}/api/levels/${repeatedState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(repeatedState) })).status).toBe(200)
|
expect((await fetch(`${baseUrl}/api/levels/${repeatedState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(repeatedState) })).status).toBe(200)
|
||||||
const repeatedReport=await (await fetch(`${baseUrl}/api/levels/${repeatedState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
const repeatedReport=await (await fetch(`${baseUrl}/api/levels/${repeatedState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
||||||
investigatorName:'Test Player',evidence:[{ connectionId:repeatedConnectionId,documentExhibitId:repeatedDocument.id,relationText:'Proof that Barricelli is named as the inventor on patent GB695913A.',
|
investigatorName:'Test Player',
|
||||||
publishedAt:'1953-08-19',sourceCitation:'Google Patents · GB695913A',sourceUri:'https://patents.google.com/patent/GB695913A/en' }],
|
|
||||||
}) })).json()
|
}) })).json()
|
||||||
expect(repeatedReport).toMatchObject({ status:'accepted',claims:[expect.objectContaining({ evidence:expect.arrayContaining([expect.objectContaining({
|
expect(repeatedReport).toMatchObject({ status:'accepted',claims:[expect.objectContaining({ evidence:expect.arrayContaining([expect.objectContaining({
|
||||||
documentExhibitId:repeatedDocument.id,evidenceAccepted:true,verification:expect.objectContaining({ status:'accepted' }),
|
documentExhibitId:repeatedDocument.id,evidenceAccepted:true,verification:expect.objectContaining({ status:'accepted' }),
|
||||||
|
|||||||
+12
-2
@@ -34,9 +34,12 @@ export function hasAdminClaim(req: Request) {
|
|||||||
* when the external handoff is wired — the `user_id` column stays the same.
|
* when the external handoff is wired — the `user_id` column stays the same.
|
||||||
*/
|
*/
|
||||||
export const DEVELOPMENT_TEST_USER_ID = 'osint-test-player'
|
export const DEVELOPMENT_TEST_USER_ID = 'osint-test-player'
|
||||||
export function resolveUserId(req: Request): string {
|
export function resolveUserId(req: Request): string | null {
|
||||||
const sub = req.authClaims?.sub
|
const sub = req.authClaims?.sub
|
||||||
return typeof sub === 'string' && sub.length > 0 ? sub : DEVELOPMENT_TEST_USER_ID
|
if (typeof sub === 'string' && sub.length > 0) return sub
|
||||||
|
// In production an absent token is anonymous (no shared identity); locally it
|
||||||
|
// resolves to a single dev user so the game is playable without an issuer.
|
||||||
|
return process.env.NODE_ENV === 'production' ? null : DEVELOPMENT_TEST_USER_ID
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePlayerName(req: Request): string {
|
export function resolvePlayerName(req: Request): string {
|
||||||
@@ -49,6 +52,13 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
|||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mint a player token (path A: GUPI is the issuer for now). Verification is
|
||||||
|
// issuer-agnostic — a glitch.university token with the same sub verifies identically.
|
||||||
|
export function signPlayerToken(user: { id: string; displayName: string }) {
|
||||||
|
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required')
|
||||||
|
return jwt.sign({ sub: user.id, name: user.displayName, role: 'player' }, process.env.JWT_SECRET, { expiresIn: '30d' })
|
||||||
|
}
|
||||||
|
|
||||||
export function createDevelopmentAdminToken() {
|
export function createDevelopmentAdminToken() {
|
||||||
if (process.env.NODE_ENV === 'production') throw new Error('Development sessions are disabled in production')
|
if (process.env.NODE_ENV === 'production') throw new Error('Development sessions are disabled in production')
|
||||||
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required')
|
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required')
|
||||||
|
|||||||
@@ -140,10 +140,11 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
|
|||||||
'INSERT INTO osint.image_documents (exhibit_id,pixel_width,pixel_height,alt_text) VALUES ($1,$2,$3,$4)',
|
'INSERT INTO osint.image_documents (exhibit_id,pixel_width,pixel_height,alt_text) VALUES ($1,$2,$3,$4)',
|
||||||
[mapped(exhibitIds, row.exhibit_id, 'image'), row.pixel_width, row.pixel_height, row.alt_text])
|
[mapped(exhibitIds, row.exhibit_id, 'image'), row.pixel_width, row.pixel_height, row.alt_text])
|
||||||
|
|
||||||
const notes = await client.query<{ exhibit_id: string; title: string; note_text: string }>(
|
const notes = await client.query<{ exhibit_id: string; title: string; note_text: string; presentation_kind:'luggage'|'lined_sheet' }>(
|
||||||
`SELECT n.* FROM osint.note_exhibits n JOIN osint.exhibits e ON e.id=n.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
`SELECT n.* FROM osint.note_exhibits n JOIN osint.exhibits e ON e.id=n.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||||
for (const row of notes.rows) await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)',
|
for (const row of notes.rows) await client.query(
|
||||||
[mapped(exhibitIds, row.exhibit_id, 'note'), row.title, row.note_text])
|
'INSERT INTO osint.note_exhibits (exhibit_id,title,note_text,presentation_kind) VALUES ($1,$2,$3,$4)',
|
||||||
|
[mapped(exhibitIds,row.exhibit_id,'note'),row.title,row.note_text,row.presentation_kind])
|
||||||
|
|
||||||
const claims = await client.query<{ exhibit_id:string;statement:string }>(
|
const claims = await client.query<{ exhibit_id:string;statement:string }>(
|
||||||
`SELECT claim.exhibit_id,claim.statement FROM osint.claim_exhibits claim
|
`SELECT claim.exhibit_id,claim.statement FROM osint.claim_exhibits claim
|
||||||
|
|||||||
@@ -5,18 +5,6 @@ import type { CaseReport, CaseReportEvidence, CaseReportSubmissionInput, CaseRep
|
|||||||
type LevelRef = { id:string; board_id:string }
|
type LevelRef = { id:string; board_id:string }
|
||||||
|
|
||||||
function trimmed(value: unknown, max: number) { return String(value || '').trim().slice(0,max) }
|
function trimmed(value: unknown, max: number) { return String(value || '').trim().slice(0,max) }
|
||||||
function optionalUrl(value: unknown) {
|
|
||||||
const candidate = trimmed(value,2_000)
|
|
||||||
if (!candidate) return null
|
|
||||||
try { return new URL(candidate).toString() } catch { throw new Error('Source links must be absolute URLs') }
|
|
||||||
}
|
|
||||||
function optionalTimestamp(value: unknown) {
|
|
||||||
const candidate = trimmed(value,100)
|
|
||||||
if (!candidate) return null
|
|
||||||
const date = new Date(candidate)
|
|
||||||
if (!Number.isFinite(date.getTime())) throw new Error('Evidence dates must be valid dates')
|
|
||||||
return date.toISOString()
|
|
||||||
}
|
|
||||||
function unfinishedRelation(value: string) {
|
function unfinishedRelation(value: string) {
|
||||||
return !value.trim() || /^proof\s+that(?:\s*(?:…|\.{3}))?\s*$/iu.test(value.trim())
|
return !value.trim() || /^proof\s+that(?:\s*(?:…|\.{3}))?\s*$/iu.test(value.trim())
|
||||||
}
|
}
|
||||||
@@ -130,29 +118,6 @@ export async function submitCaseReport(pool: Pool, levelSlug: string, rawInput:
|
|||||||
const report = (await client.query<{ board_id:string }>('SELECT board_id FROM osint.case_reports WHERE board_id=$1 FOR UPDATE', [level.board_id])).rows[0]
|
const report = (await client.query<{ board_id:string }>('SELECT board_id FROM osint.case_reports WHERE board_id=$1 FOR UPDATE', [level.board_id])).rows[0]
|
||||||
if (!report) throw new Error('This level does not have a case report')
|
if (!report) throw new Error('This level does not have a case report')
|
||||||
const investigatorName = trimmed(rawInput?.investigatorName,300) || 'Player'
|
const investigatorName = trimmed(rawInput?.investigatorName,300) || 'Player'
|
||||||
const drafts = Array.isArray(rawInput?.evidence) ? rawInput.evidence.slice(0,100) : []
|
|
||||||
if (new Set(drafts.map(item => item.connectionId)).size !== drafts.length) throw new Error('A report cannot submit the same connection twice')
|
|
||||||
for (const draft of drafts) {
|
|
||||||
const relationText = trimmed(draft.relationText,2_000)
|
|
||||||
const sourceCitation = trimmed(draft.sourceCitation,1_000)
|
|
||||||
const sourceUri = optionalUrl(draft.sourceUri)
|
|
||||||
const publishedAt = optionalTimestamp(draft.publishedAt)
|
|
||||||
const owned = (await client.query<{ connection_id:string; document_id:string }>(`SELECT connection.id AS connection_id,document.exhibit_id AS document_id
|
|
||||||
FROM osint.exhibit_connections connection
|
|
||||||
JOIN osint.claim_exhibits claim ON claim.exhibit_id=CASE
|
|
||||||
WHEN connection.from_exhibit_id=$3 THEN connection.to_exhibit_id ELSE connection.from_exhibit_id END
|
|
||||||
JOIN osint.document_exhibits document ON document.exhibit_id=CASE
|
|
||||||
WHEN connection.from_exhibit_id=$3 THEN connection.from_exhibit_id ELSE connection.to_exhibit_id END
|
|
||||||
WHERE connection.id=$1 AND connection.board_id=$2
|
|
||||||
AND $3::uuid IN (connection.from_exhibit_id,connection.to_exhibit_id)`, [draft.connectionId,level.board_id,draft.documentExhibitId])).rows[0]
|
|
||||||
if (!owned || owned.document_id !== draft.documentExhibitId) throw new Error('Report evidence must reference a claim-to-document connection on this board')
|
|
||||||
await client.query('UPDATE osint.exhibit_connections SET label=$2 WHERE id=$1', [draft.connectionId,relationText || null])
|
|
||||||
await client.query(`UPDATE osint.document_exhibits SET published_at=$2,citation_text=$3,source_uri=$4 WHERE exhibit_id=$1`,
|
|
||||||
[draft.documentExhibitId,publishedAt,sourceCitation,sourceUri])
|
|
||||||
await client.query(`INSERT INTO osint.exhibit_citations (board_id,exhibit_id,display_number)
|
|
||||||
SELECT $1,$2,COALESCE(MAX(display_number),0)+1 FROM osint.exhibit_citations WHERE board_id=$1
|
|
||||||
ON CONFLICT (board_id,exhibit_id) DO NOTHING`, [level.board_id,draft.documentExhibitId])
|
|
||||||
}
|
|
||||||
await client.query('UPDATE osint.case_reports SET investigator_name=$2,updated_at=NOW() WHERE board_id=$1', [level.board_id,investigatorName])
|
await client.query('UPDATE osint.case_reports SET investigator_name=$2,updated_at=NOW() WHERE board_id=$1', [level.board_id,investigatorName])
|
||||||
const assembled = (await loadCaseReport(client,level))!
|
const assembled = (await loadCaseReport(client,level))!
|
||||||
const pendingGoals = Number((await client.query<{ count:string }>(`SELECT COUNT(*)::text AS count FROM osint.level_goals goal
|
const pendingGoals = Number((await client.query<{ count:string }>(`SELECT COUNT(*)::text AS count FROM osint.level_goals goal
|
||||||
|
|||||||
+92
-7
@@ -8,7 +8,8 @@ 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, resolvePlayerName, resolveUserId } from './auth.js'
|
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolvePlayerName, resolveUserId, signPlayerToken } from './auth.js'
|
||||||
|
import { createUserRepository } from './userRepository.js'
|
||||||
import { submitCaseReport } from './caseReports.js'
|
import { submitCaseReport } from './caseReports.js'
|
||||||
import { createLevelRepository } from './levelRepository.js'
|
import { createLevelRepository } from './levelRepository.js'
|
||||||
import { createEvidenceJudgeFromEnv } from './evidenceJudge.js'
|
import { createEvidenceJudgeFromEnv } from './evidenceJudge.js'
|
||||||
@@ -33,11 +34,25 @@ const evidenceJudge = createEvidenceJudgeFromEnv()
|
|||||||
const levels = createLevelRepository(pool, editingEnabled, objectStorage, evidenceJudge)
|
const levels = createLevelRepository(pool, editingEnabled, objectStorage, evidenceJudge)
|
||||||
const narrative = createNarrativeRepository(pool, objectStorage)
|
const narrative = createNarrativeRepository(pool, objectStorage)
|
||||||
const storyGraph = createStoryGraphRepository(pool)
|
const storyGraph = createStoryGraphRepository(pool)
|
||||||
|
const users = createUserRepository(pool)
|
||||||
|
const AUTH_COOKIE = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, path: '/', maxAge: 30 * 24 * 60 * 60 * 1000 }
|
||||||
const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate', 'merit', 'phone']
|
const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate', 'merit', 'phone']
|
||||||
|
|
||||||
function wantsEdit(req: express.Request) {
|
function wantsEdit(req: express.Request) {
|
||||||
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
|
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
|
||||||
}
|
}
|
||||||
|
// Identity guards for player-scoped routes.
|
||||||
|
function requireUser(req: express.Request, res: express.Response): string | null {
|
||||||
|
const userId = resolveUserId(req)
|
||||||
|
if (!userId) { res.status(401).json({ error: 'Sign in required' }); return null }
|
||||||
|
return userId
|
||||||
|
}
|
||||||
|
async function ownsPlaythroughOr403(req: express.Request, res: express.Response, playthroughId: string): Promise<string | null> {
|
||||||
|
const userId = requireUser(req, res)
|
||||||
|
if (!userId) return null
|
||||||
|
if (!await narrative.ownsPlaythrough(userId, playthroughId)) { res.status(403).json({ error: 'Not your playthrough' }); return null }
|
||||||
|
return userId
|
||||||
|
}
|
||||||
function slug(value: unknown, fallback: string) {
|
function slug(value: unknown, fallback: string) {
|
||||||
return String(value || fallback).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
|
return String(value || fallback).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||||
}
|
}
|
||||||
@@ -59,6 +74,35 @@ app.get('/api/health', async (_req, res) => {
|
|||||||
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), playerName:resolvePlayerName(req) }))
|
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req), playerName:resolvePlayerName(req) }))
|
||||||
|
|
||||||
|
// Player accounts (path A: GUPI issues the token). register/login set the auth_token
|
||||||
|
// cookie; every game write then binds to this user via resolveUserId.
|
||||||
|
app.post('/api/auth/register', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body = req.body || {}
|
||||||
|
const result = await users.registerUser({ handle: String(body.handle || ''), password: String(body.password || ''), displayName: String(body.displayName || '') })
|
||||||
|
if (result.error || !result.user) return res.status(result.error === 'That handle is taken' ? 409 : 400).json({ error: result.error || 'Registration failed' })
|
||||||
|
res.cookie('auth_token', signPlayerToken(result.user), AUTH_COOKIE)
|
||||||
|
res.status(201).json({ user: result.user })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.post('/api/auth/login', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body = req.body || {}
|
||||||
|
const user = await users.authenticateUser(String(body.handle || ''), String(body.password || ''))
|
||||||
|
if (!user) return res.status(401).json({ error: 'Invalid handle or password' })
|
||||||
|
res.cookie('auth_token', signPlayerToken(user), AUTH_COOKIE)
|
||||||
|
res.json({ user })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.post('/api/auth/logout', (_req, res) => { res.clearCookie('auth_token', { path: '/' }); res.json({ ok: true }) })
|
||||||
|
app.get('/api/auth/me', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const sub = req.authClaims?.sub
|
||||||
|
const user = typeof sub === 'string' ? await users.getUser(sub) : null
|
||||||
|
user ? res.json({ user }) : res.status(204).end()
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
if (process.env.NODE_ENV !== 'production') app.get('/api/dev/admin-session', (req, res) => {
|
if (process.env.NODE_ENV !== 'production') app.get('/api/dev/admin-session', (req, res) => {
|
||||||
const requestedReturn = String(req.query.returnTo || '/')
|
const requestedReturn = String(req.query.returnTo || '/')
|
||||||
const returnTo = requestedReturn.startsWith('/') && !requestedReturn.startsWith('//') ? requestedReturn : '/'
|
const returnTo = requestedReturn.startsWith('/') && !requestedReturn.startsWith('//') ? requestedReturn : '/'
|
||||||
@@ -124,7 +168,8 @@ app.post('/api/levels/:id/documents', upload.single('file'), async (req, res, ne
|
|||||||
})
|
})
|
||||||
app.post('/api/levels/:id/documents/:documentId/judge', async (req, res, next) => {
|
app.post('/api/levels/:id/documents/:documentId/judge', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
if (!hasAdminClaim(req) && !await narrative.ownsActiveLevel(resolveUserId(req), String(req.params.id))) {
|
const levelUser = resolveUserId(req)
|
||||||
|
if (!hasAdminClaim(req) && (!levelUser || !await narrative.ownsActiveLevel(levelUser, String(req.params.id)))) {
|
||||||
return res.status(403).json({ error: 'This level is not active for the current player' })
|
return res.status(403).json({ error: 'This level is not active for the current player' })
|
||||||
}
|
}
|
||||||
const result = await levels.judgeDocument(String(req.params.id), String(req.params.documentId))
|
const result = await levels.judgeDocument(String(req.params.id), String(req.params.documentId))
|
||||||
@@ -181,7 +226,6 @@ app.post('/api/levels/:id/report/submissions', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const report = await submitCaseReport(pool,String(req.params.id),{
|
const report = await submitCaseReport(pool,String(req.params.id),{
|
||||||
investigatorName:String(req.body?.investigatorName || resolvePlayerName(req)),
|
investigatorName:String(req.body?.investigatorName || resolvePlayerName(req)),
|
||||||
evidence:Array.isArray(req.body?.evidence) ? req.body.evidence : [],
|
|
||||||
})
|
})
|
||||||
report ? res.status(201).json(report) : res.status(404).json({ error:'Level not found' })
|
report ? res.status(201).json(report) : res.status(404).json({ error:'Level not found' })
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
@@ -472,13 +516,15 @@ app.post('/api/mysteries', requireAdmin, async (req, res, next) => {
|
|||||||
// New Game creates a playthrough bound to the caller's identity.
|
// New Game creates a playthrough bound to the caller's identity.
|
||||||
app.post('/api/playthroughs', async (req, res, next) => {
|
app.post('/api/playthroughs', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const result = await narrative.createPlaythrough(resolveUserId(req), req.body?.mystery ? slug(req.body.mystery, req.body.mystery) : undefined)
|
const userId = requireUser(req, res); if (!userId) return
|
||||||
|
const result = await narrative.createPlaythrough(userId, 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' })
|
result ? res.status(201).json(result) : res.status(404).json({ error: 'No mystery available' })
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
app.get('/api/playthroughs/current', async (req, res, next) => {
|
app.get('/api/playthroughs/current', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const result = await narrative.getCurrentPlaythrough(resolveUserId(req))
|
const userId = resolveUserId(req)
|
||||||
|
const result = userId ? await narrative.getCurrentPlaythrough(userId) : null
|
||||||
result ? res.json(result) : res.status(204).end()
|
result ? res.json(result) : res.status(204).end()
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
@@ -486,7 +532,8 @@ app.get('/api/playthroughs/current', async (req, res, next) => {
|
|||||||
// gates; instantiates the board when entering a level node).
|
// gates; instantiates the board when entering a level node).
|
||||||
app.post('/api/playthroughs/:id/advance', async (req, res, next) => {
|
app.post('/api/playthroughs/:id/advance', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const result = await narrative.advancePlaythrough(resolveUserId(req), String(req.params.id), req.body?.terminalKey)
|
const userId = requireUser(req, res); if (!userId) return
|
||||||
|
const result = await narrative.advancePlaythrough(userId, String(req.params.id), req.body?.terminalKey)
|
||||||
result.ok ? res.json(result.state ?? null) : res.status(result.error === 'Playthrough not found' ? 404 : result.errorCode ? 409 : 400)
|
result.ok ? res.json(result.state ?? null) : res.status(result.error === 'Playthrough not found' ? 404 : result.errorCode ? 409 : 400)
|
||||||
.json({ error: result.error, errorCode: result.errorCode, pendingGoals: result.pendingGoals })
|
.json({ error: result.error, errorCode: result.errorCode, pendingGoals: result.pendingGoals })
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
@@ -496,6 +543,7 @@ app.post('/api/playthroughs/:id/advance', async (req, res, next) => {
|
|||||||
// stand-in until the server-side achievement rule engine drives awards from play.
|
// stand-in until the server-side achievement rule engine drives awards from play.
|
||||||
app.get('/api/playthroughs/:id/achievements', async (req, res, next) => {
|
app.get('/api/playthroughs/:id/achievements', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
|
||||||
const flags = await narrative.listAchievements(String(req.params.id))
|
const flags = await narrative.listAchievements(String(req.params.id))
|
||||||
flags ? res.json(flags) : res.status(404).json({ error: 'Playthrough not found' })
|
flags ? res.json(flags) : res.status(404).json({ error: 'Playthrough not found' })
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
@@ -503,6 +551,7 @@ app.get('/api/playthroughs/:id/achievements', async (req, res, next) => {
|
|||||||
app.post('/api/playthroughs/:id/achievements', async (req, res, next) => {
|
app.post('/api/playthroughs/:id/achievements', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Manual grants are disabled' })
|
if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Manual grants are disabled' })
|
||||||
|
if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
|
||||||
if (!req.body?.flagKey) return res.status(400).json({ error: 'A flagKey is required' })
|
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)
|
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 })
|
result.ok ? res.json({ earned: result.earned }) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
|
||||||
@@ -512,16 +561,52 @@ app.post('/api/playthroughs/:id/achievements', async (req, res, next) => {
|
|||||||
// server-side against the player's current node, so players can't forge flags).
|
// server-side against the player's current node, so players can't forge flags).
|
||||||
app.post('/api/playthroughs/:id/utterances/:uid/reach', async (req, res, next) => {
|
app.post('/api/playthroughs/:id/utterances/:uid/reach', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
|
||||||
const result = await narrative.reachUtterance(String(req.params.id), String(req.params.uid))
|
const result = await narrative.reachUtterance(String(req.params.id), String(req.params.uid))
|
||||||
result.ok ? res.json({ earned: result.earned ?? false }) : res.status(404).json({ error: 'Not found' })
|
result.ok ? res.json({ earned: result.earned ?? false }) : res.status(404).json({ error: 'Not found' })
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
|
// Field notebook: capture NPC lines during play, list them, and remove (on tear/discard).
|
||||||
|
app.get('/api/playthroughs/:id/notebook', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
|
||||||
|
res.json(await narrative.notebookPages(String(req.params.id)))
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.post('/api/playthroughs/:id/notebook', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
|
||||||
|
const page = await narrative.addNotebookPage(String(req.params.id), String(req.body?.text || ''), req.body?.utteranceId ? String(req.body.utteranceId) : null)
|
||||||
|
page ? res.status(201).json(page) : res.status(400).json({ error: 'Empty text or unknown playthrough' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.delete('/api/playthroughs/:id/notebook/:pageId', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
|
||||||
|
const ok = await narrative.removeNotebookPage(String(req.params.id), String(req.params.pageId))
|
||||||
|
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
// The phone tool: the directory available on the current node, and dialing a number.
|
||||||
|
app.get('/api/playthroughs/:id/phone', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
|
||||||
|
res.json(await narrative.phoneDirectory(String(req.params.id)))
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.post('/api/playthroughs/:id/dial', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
|
||||||
|
res.json(await narrative.dial(String(req.params.id), String(req.body?.number || '')))
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
// Dev teleport: jump the playthrough to an explicit story node. Powers /node/:id.
|
// Dev teleport: jump the playthrough to an explicit story node. Powers /node/:id.
|
||||||
app.post('/api/playthroughs/:id/goto', async (req, res, next) => {
|
app.post('/api/playthroughs/:id/goto', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Node teleport is disabled' })
|
if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Node teleport is disabled' })
|
||||||
|
const userId = requireUser(req, res); if (!userId) return
|
||||||
if (!req.body?.nodeId) return res.status(400).json({ error: 'A nodeId is required' })
|
if (!req.body?.nodeId) return res.status(400).json({ error: 'A nodeId is required' })
|
||||||
const result = await narrative.gotoNode(resolveUserId(req), String(req.params.id), String(req.body.nodeId))
|
const result = await narrative.gotoNode(userId, String(req.params.id), String(req.body.nodeId))
|
||||||
result.ok ? res.json(result.state ?? null) : res.status(result.error === 'Playthrough not found' || result.error === 'Node not found' ? 404 : 400).json({ error: result.error })
|
result.ok ? res.json(result.state ?? null) : res.status(result.error === 'Playthrough not found' || result.error === 'Node not found' ? 404 : 400).json({ error: result.error })
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ type ExhibitRow = {
|
|||||||
captured_at: Date | null; source_uri: string | null; citation_text: string | null; display_number: number | null; statement: string | null
|
captured_at: Date | null; source_uri: string | null; citation_text: string | null; display_number: number | null; statement: string | 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
|
||||||
source_document_id: string | null; source_region_key: string | null
|
source_document_id: string | null; source_region_key: string | null
|
||||||
|
note_presentation_kind: 'luggage' | 'lined_sheet' | null
|
||||||
party_kind: PartyKind | null; organization_kind: OrganizationKind | null
|
party_kind: PartyKind | null; organization_kind: OrganizationKind | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,8 +216,41 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
for (const [sortOrder, anchor] of input.anchors.entries()) await client.query(`INSERT INTO osint.evidence_match_anchors
|
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)`,
|
(id,rule_id,phrase_text,minimum_similarity,sort_order) VALUES ($1,$2,$3,$4,$5)`,
|
||||||
[randomUUID(), ruleId, anchor.phrase, anchor.minimumSimilarity, sortOrder])
|
[randomUUID(), ruleId, anchor.phrase, anchor.minimumSimilarity, sortOrder])
|
||||||
|
const rule = (await evidenceMatchRules(client, level.board_id, true)).find(candidate => candidate.id === ruleId) || null
|
||||||
|
if (rule) await evaluateRuleForExistingDocuments(client, level, rule)
|
||||||
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])
|
||||||
return (await evidenceMatchRules(client, level.board_id, true)).find(rule => rule.id === ruleId) || null
|
return rule
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluateRuleForExistingDocuments(client: PoolClient, level: LevelRow, rule: EvidenceMatchRuleDefinition) {
|
||||||
|
if (!rule.enabled) return
|
||||||
|
const documents = await client.query<{ document_exhibit_id:string;extraction_id:string;extracted_text:string }>(`
|
||||||
|
SELECT DISTINCT ON (document.exhibit_id) document.exhibit_id AS document_exhibit_id,
|
||||||
|
extraction.id AS extraction_id,extraction.extracted_text
|
||||||
|
FROM osint.document_exhibits document
|
||||||
|
JOIN osint.exhibits exhibit ON exhibit.id=document.exhibit_id AND exhibit.board_id=$1
|
||||||
|
JOIN osint.asset_text_extractions extraction ON extraction.asset_id=document.asset_id
|
||||||
|
AND extraction.status='succeeded' AND BTRIM(extraction.extracted_text)<>''
|
||||||
|
ORDER BY document.exhibit_id,extraction.updated_at DESC,extraction.id DESC`, [level.board_id])
|
||||||
|
for (const document of documents.rows) {
|
||||||
|
const evaluation = evaluateEvidenceRules(document.extracted_text, [rule as EvidenceMatchRule])[0]
|
||||||
|
const persisted = await client.query<{ id:string }>(`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)
|
||||||
|
ON CONFLICT (level_id,document_exhibit_id,rule_id) DO UPDATE SET
|
||||||
|
extraction_id=EXCLUDED.extraction_id,matched=EXCLUDED.matched,
|
||||||
|
matched_anchor_count=EXCLUDED.matched_anchor_count,score=EXCLUDED.score,evaluated_at=NOW()
|
||||||
|
RETURNING id`, [randomUUID(),level.id,level.board_id,document.document_exhibit_id,document.extraction_id,
|
||||||
|
rule.id,evaluation.matched,evaluation.matchedAnchorCount,evaluation.score])
|
||||||
|
const evaluationId = persisted.rows[0].id
|
||||||
|
await client.query('DELETE FROM osint.evidence_match_anchor_evaluations WHERE evaluation_id=$1', [evaluationId])
|
||||||
|
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) 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`, [level.id,level.board_id,evaluation.flagKey,evaluationId])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function levelGoalStates(
|
async function levelGoalStates(
|
||||||
@@ -319,6 +353,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, claim.statement, '') AS title,
|
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, claim.statement, '') 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.capture_kind_id, d.asset_id, d.published_at, d.captured_at, d.source_uri,d.citation_text,citation.display_number,
|
f.is_open, d.document_type_id, d.capture_kind_id, d.asset_id, d.published_at, d.captured_at, d.source_uri,d.citation_text,citation.display_number,
|
||||||
|
n.presentation_kind AS note_presentation_kind,
|
||||||
ev.occurred_at,claim.statement,
|
ev.occurred_at,claim.statement,
|
||||||
p.party_kind, op.organization_kind,
|
p.party_kind, op.organization_kind,
|
||||||
a.original_name, a.mime_type, a.byte_size,
|
a.original_name, a.mime_type, a.byte_size,
|
||||||
@@ -415,7 +450,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
else if (row.exhibit_type_id === 'event') evidence.push({ ...common, type:'event', eventDate:row.occurred_at?.toISOString() })
|
else if (row.exhibit_type_id === 'event') evidence.push({ ...common, type:'event', eventDate:row.occurred_at?.toISOString() })
|
||||||
else if (row.exhibit_type_id === 'party') evidence.push({ ...common, type:'party', partyKind:row.party_kind || 'person', organizationKind:row.organization_kind || undefined, aliases:aliases.get(row.id) || [] })
|
else if (row.exhibit_type_id === 'party') evidence.push({ ...common, type:'party', partyKind:row.party_kind || 'person', organizationKind:row.organization_kind || undefined, aliases:aliases.get(row.id) || [] })
|
||||||
else if (row.exhibit_type_id === 'claim') evidence.push({ ...base(row), type:'claim', title:row.title, statement:row.statement || row.title })
|
else if (row.exhibit_type_id === 'claim') evidence.push({ ...base(row), type:'claim', title:row.title, statement:row.statement || row.title })
|
||||||
else if (row.exhibit_type_id === 'note') evidence.push({ ...common, type:'note' })
|
else if (row.exhibit_type_id === 'note') evidence.push({ ...common, type:'note', presentation:row.note_presentation_kind || 'luggage' })
|
||||||
else throw new Error(`Unsupported exhibit type ${row.exhibit_type_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,
|
const views: BoardView[] = viewsResult.rows.map(row => ({ id: row.id, type: 'timeline', visible: row.visible, zIndex: row.z_index,
|
||||||
@@ -492,18 +527,21 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
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 exhibit of state.exhibits) {
|
for (const exhibit of state.exhibits) {
|
||||||
|
const clipping=exhibit.type === 'document' && documentCaptureKind(exhibit.captureKind) === 'clipping'
|
||||||
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,rotation,z_index,hidden)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||||
ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=$7,rotation=$8,z_index=$9,hidden=$10,updated_at=NOW()`,
|
ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=$7,rotation=$8,z_index=$9,hidden=$10,updated_at=NOW()`,
|
||||||
[exhibit.id, level.board_id, exhibit.type, exhibit.x, exhibit.y, exhibit.width, exhibit.height, exhibit.rotation, exhibit.zIndex, exhibit.hidden])
|
[exhibit.id,level.board_id,exhibit.type,exhibit.x,exhibit.y,clipping ? 230 : exhibit.width,clipping ? 290 : exhibit.height,exhibit.rotation,exhibit.zIndex,exhibit.hidden])
|
||||||
}
|
}
|
||||||
let nextCitation = Number((await client.query<{ maximum:number }>('SELECT COALESCE(MAX(display_number),0)::int AS maximum FROM osint.exhibit_citations WHERE board_id=$1',[level.board_id])).rows[0].maximum)
|
let nextCitation = Number((await client.query<{ maximum:number }>('SELECT COALESCE(MAX(display_number),0)::int AS maximum FROM osint.exhibit_citations WHERE board_id=$1',[level.board_id])).rows[0].maximum)
|
||||||
for (const document of documents) {
|
for (const document of documents) {
|
||||||
|
const clippingTitle=document.captureKind === 'clipping' && document.sourceCitation?.trim()
|
||||||
|
&& (!document.title.trim() || document.title === document.fileName) ? document.sourceCitation.trim() : document.title
|
||||||
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,capture_kind_id,asset_id,title,published_at,captured_at,source_uri,citation_text)
|
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,capture_kind_id,asset_id,title,published_at,captured_at,source_uri,citation_text)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (exhibit_id) DO UPDATE SET
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (exhibit_id) DO UPDATE SET
|
||||||
document_type_id=EXCLUDED.document_type_id,asset_id=EXCLUDED.asset_id,title=EXCLUDED.title,published_at=EXCLUDED.published_at,
|
document_type_id=EXCLUDED.document_type_id,asset_id=EXCLUDED.asset_id,title=EXCLUDED.title,published_at=EXCLUDED.published_at,
|
||||||
capture_kind_id=EXCLUDED.capture_kind_id,captured_at=EXCLUDED.captured_at,source_uri=EXCLUDED.source_uri,citation_text=EXCLUDED.citation_text`,
|
capture_kind_id=EXCLUDED.capture_kind_id,captured_at=EXCLUDED.captured_at,source_uri=EXCLUDED.source_uri,citation_text=EXCLUDED.citation_text`,
|
||||||
[document.id, documentType(document), documentCaptureKind(document.captureKind), document.assetId || null, document.title,
|
[document.id, documentType(document), documentCaptureKind(document.captureKind), document.assetId || null, clippingTitle,
|
||||||
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null,document.sourceCitation || ''])
|
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null,document.sourceCitation || ''])
|
||||||
const existingCitation = await client.query<{ display_number:number }>('SELECT display_number FROM osint.exhibit_citations WHERE board_id=$1 AND exhibit_id=$2',[level.board_id,document.id])
|
const existingCitation = await client.query<{ display_number:number }>('SELECT display_number FROM osint.exhibit_citations WHERE board_id=$1 AND exhibit_id=$2',[level.board_id,document.id])
|
||||||
if (!existingCitation.rows[0]) {
|
if (!existingCitation.rows[0]) {
|
||||||
@@ -527,7 +565,9 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
if (isFolderExhibit(exhibit)) await client.query(
|
if (isFolderExhibit(exhibit)) 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, exhibit.isOpen])
|
[exhibit.id, exhibit.title, exhibit.content, exhibit.isOpen])
|
||||||
if (exhibit.type === 'note') await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)', [exhibit.id, exhibit.title, exhibit.content])
|
if (exhibit.type === 'note') await client.query(
|
||||||
|
'INSERT INTO osint.note_exhibits (exhibit_id,title,note_text,presentation_kind) VALUES ($1,$2,$3,$4)',
|
||||||
|
[exhibit.id,exhibit.title,exhibit.content,exhibit.presentation === 'lined_sheet' ? 'lined_sheet' : 'luggage'])
|
||||||
if (isEventExhibit(exhibit)) 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)])
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVi
|
|||||||
const placed = { x: 10, y: 20, width: 174, height: 145, rotation: 0, zIndex: 1, hidden: false }
|
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', captureKind:'unclassified',metadata: {}, ...placed }
|
const open: DocumentExhibit = { id: 'open', type: 'document', title: 'Open', body: [], regions: [], fileType: 'image', captureKind:'unclassified',metadata: {}, ...placed }
|
||||||
const gated: DocumentExhibit = { id: 'gated', type: 'document', title: 'Gated', body: [], regions: [], fileType: 'image', captureKind:'unclassified',metadata: {}, requiredFlags: ['tip.received'], ...placed }
|
const gated: DocumentExhibit = { id: 'gated', type: 'document', title: 'Gated', body: [], regions: [], fileType: 'image', captureKind:'unclassified',metadata: {}, requiredFlags: ['tip.received'], ...placed }
|
||||||
const note: NoteExhibit = { id: 'note', type: 'note', title: 'Note', content: '', ...placed }
|
const note: NoteExhibit = { id:'note',type:'note',title:'Note',content:'',presentation:'luggage',...placed }
|
||||||
const state: CaseState = {
|
const state: CaseState = {
|
||||||
id: 'demo', title: 'Demo', subtitle: '', exhibits: [open, gated, note], viewport: { x: 0, y: 0, zoom: 1 },
|
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 }],
|
relations: [{ id: 'source', type: 'source', fromExhibitId: note.id, toExhibitId: gated.id, sortOrder: 0 }],
|
||||||
|
|||||||
@@ -59,7 +59,13 @@ export interface NarrativeRepository {
|
|||||||
listAchievements(playthroughId: string): Promise<string[] | null>
|
listAchievements(playthroughId: string): Promise<string[] | null>
|
||||||
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }>
|
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }>
|
||||||
reachUtterance(playthroughId: string, utteranceId: string): Promise<{ ok: boolean; earned?: boolean }>
|
reachUtterance(playthroughId: string, utteranceId: string): Promise<{ ok: boolean; earned?: boolean }>
|
||||||
|
notebookPages(playthroughId: string): Promise<{ id: string; text: string; createdAt: string }[]>
|
||||||
|
addNotebookPage(playthroughId: string, text: string, sourceUtteranceId?: string | null): Promise<{ id: string; text: string } | null>
|
||||||
|
removeNotebookPage(playthroughId: string, pageId: string): Promise<boolean>
|
||||||
|
phoneDirectory(playthroughId: string): Promise<{ available: boolean; numbers: { number: string; name: string }[] }>
|
||||||
|
dial(playthroughId: string, number: string): Promise<{ outcome: 'connect' | 'voicemail' | 'unknown'; name?: string; state?: PlaythroughState }>
|
||||||
gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
||||||
|
ownsPlaythrough(userId: string, playthroughId: string): Promise<boolean>
|
||||||
listMysteries(): Promise<MysterySummary[]>
|
listMysteries(): Promise<MysterySummary[]>
|
||||||
listPlayableMysteries(): Promise<{ slug: string; title: string }[]>
|
listPlayableMysteries(): Promise<{ slug: string; title: string }[]>
|
||||||
deleteMystery(id: string): Promise<boolean>
|
deleteMystery(id: string): Promise<boolean>
|
||||||
@@ -280,6 +286,70 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
return { ok: true, earned: (result.rowCount || 0) > 0 }
|
return { ok: true, earned: (result.rowCount || 0) > 0 }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Field notebook: lines the player captured from NPCs during this playthrough.
|
||||||
|
async notebookPages(playthroughId) {
|
||||||
|
const rows = (await pool.query<{ id: string; text: string; created_at: Date }>(
|
||||||
|
'SELECT id,text,created_at FROM osint.notebook_pages WHERE playthrough_id=$1 ORDER BY created_at', [playthroughId])).rows
|
||||||
|
return rows.map(row => ({ id: row.id, text: row.text, createdAt: row.created_at.toISOString() }))
|
||||||
|
},
|
||||||
|
async addNotebookPage(playthroughId, text, sourceUtteranceId) {
|
||||||
|
const clean = text.trim()
|
||||||
|
if (!clean) return null
|
||||||
|
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
|
||||||
|
const row = (await pool.query<{ id: string }>(
|
||||||
|
'INSERT INTO osint.notebook_pages (playthrough_id,text,source_utterance_id) VALUES ($1,$2,$3) RETURNING id',
|
||||||
|
[playthroughId, clean, sourceUtteranceId || null])).rows[0]
|
||||||
|
return { id: row.id, text: clean }
|
||||||
|
},
|
||||||
|
async removeNotebookPage(playthroughId, pageId) {
|
||||||
|
const result = await pool.query('DELETE FROM osint.notebook_pages WHERE id=$1 AND playthrough_id=$2', [pageId, playthroughId])
|
||||||
|
return (result.rowCount || 0) > 0
|
||||||
|
},
|
||||||
|
|
||||||
|
// The phone directory available on the player's current node: the terminals of a
|
||||||
|
// phone node the current node is wired to. No connected phone node => nobody's listed.
|
||||||
|
async phoneDirectory(playthroughId) {
|
||||||
|
const pt = (await pool.query<{ current_node_id: string | null }>('SELECT current_node_id FROM osint.playthroughs WHERE id=$1', [playthroughId])).rows[0]
|
||||||
|
if (!pt?.current_node_id) return { available: false, numbers: [] }
|
||||||
|
const phoneNode = (await pool.query<{ id: string }>(
|
||||||
|
`SELECT pn.id FROM osint.story_node_terminals t JOIN osint.story_nodes pn ON pn.id=t.to_node_id
|
||||||
|
WHERE t.parent_node_id=$1 AND pn.node_type='phone' LIMIT 1`, [pt.current_node_id])).rows[0]
|
||||||
|
if (!phoneNode) return { available: true, numbers: [] }
|
||||||
|
const dir = (await pool.query<{ number: string; name: string }>(
|
||||||
|
`SELECT npc.phone_number AS number, npc.name FROM osint.story_node_terminals t JOIN osint.npcs npc ON npc.id=t.npc_id
|
||||||
|
WHERE t.parent_node_id=$1 AND npc.phone_number IS NOT NULL ORDER BY t.sort_order`, [phoneNode.id])).rows
|
||||||
|
return { available: true, numbers: dir }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Resolve a dialed number: connect (advance to the wired dialogue), voicemail (a
|
||||||
|
// known contact with no line here), or not-in-service (no such number).
|
||||||
|
async dial(playthroughId, rawNumber) {
|
||||||
|
const number = rawNumber.replace(/\D/g, '')
|
||||||
|
if (!number) return { outcome: 'unknown' }
|
||||||
|
const pt = (await pool.query<{ current_node_id: string | null }>('SELECT current_node_id FROM osint.playthroughs WHERE id=$1', [playthroughId])).rows[0]
|
||||||
|
if (!pt?.current_node_id) return { outcome: 'unknown' }
|
||||||
|
const phoneNode = (await pool.query<{ id: string }>(
|
||||||
|
`SELECT pn.id FROM osint.story_node_terminals t JOIN osint.story_nodes pn ON pn.id=t.to_node_id
|
||||||
|
WHERE t.parent_node_id=$1 AND pn.node_type='phone' LIMIT 1`, [pt.current_node_id])).rows[0]
|
||||||
|
if (phoneNode) {
|
||||||
|
const term = (await pool.query<{ to_node_id: string | null; name: string }>(
|
||||||
|
`SELECT t.to_node_id, npc.name FROM osint.story_node_terminals t JOIN osint.npcs npc ON npc.id=t.npc_id
|
||||||
|
WHERE t.parent_node_id=$1 AND regexp_replace(npc.phone_number,'\\D','','g')=$2 LIMIT 1`, [phoneNode.id, number])).rows[0]
|
||||||
|
if (term?.to_node_id) {
|
||||||
|
await pool.query('UPDATE osint.playthroughs SET current_node_id=$2,current_level_id=NULL,updated_at=NOW() WHERE id=$1', [playthroughId, term.to_node_id])
|
||||||
|
const state = await stateForPlaythrough(playthroughId)
|
||||||
|
return { outcome: 'connect', name: term.name, state: state ?? undefined }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const npc = (await pool.query<{ name: string }>(
|
||||||
|
`SELECT name FROM osint.npcs WHERE regexp_replace(phone_number,'\\D','','g')=$1 AND mystery_id IS NULL LIMIT 1`, [number])).rows[0]
|
||||||
|
return npc ? { outcome: 'voicemail', name: npc.name } : { outcome: 'unknown' }
|
||||||
|
},
|
||||||
|
|
||||||
|
async ownsPlaythrough(userId, playthroughId) {
|
||||||
|
return ((await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1 AND user_id=$2', [playthroughId, userId])).rowCount || 0) > 0
|
||||||
|
},
|
||||||
|
|
||||||
async listAchievements(playthroughId) {
|
async listAchievements(playthroughId) {
|
||||||
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
|
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
|
const rows = (await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1 ORDER BY flag_key', [playthroughId])).rows
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
|
||||||
|
import type { Pool } from 'pg'
|
||||||
|
|
||||||
|
export type UserDto = { id: string; handle: string; displayName: string; avatarUrl: string | null }
|
||||||
|
|
||||||
|
export interface UserRepository {
|
||||||
|
registerUser(input: { handle: string; password: string; displayName: string }): Promise<{ user?: UserDto; error?: string }>
|
||||||
|
authenticateUser(handle: string, password: string): Promise<UserDto | null>
|
||||||
|
getUser(id: string): Promise<UserDto | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
const HANDLE = /^[a-z0-9_.-]{3,32}$/
|
||||||
|
|
||||||
|
// scrypt with a per-user random salt; stored as `salt:hash` hex. No dependency.
|
||||||
|
function hashPassword(password: string): string {
|
||||||
|
const salt = randomBytes(16)
|
||||||
|
return `${salt.toString('hex')}:${scryptSync(password, salt, 64).toString('hex')}`
|
||||||
|
}
|
||||||
|
function verifyPassword(password: string, stored: string): boolean {
|
||||||
|
const [saltHex, hashHex] = stored.split(':')
|
||||||
|
if (!saltHex || !hashHex) return false
|
||||||
|
const expected = Buffer.from(hashHex, 'hex')
|
||||||
|
const actual = scryptSync(password, Buffer.from(saltHex, 'hex'), 64)
|
||||||
|
return expected.length === actual.length && timingSafeEqual(expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUserRepository(pool: Pool): UserRepository {
|
||||||
|
const toDto = (row: { id: string; handle: string; display_name: string; avatar_url: string | null }): UserDto =>
|
||||||
|
({ id: row.id, handle: row.handle, displayName: row.display_name, avatarUrl: row.avatar_url })
|
||||||
|
|
||||||
|
return {
|
||||||
|
async registerUser({ handle: rawHandle, password, displayName: rawName }) {
|
||||||
|
const handle = rawHandle.trim().toLowerCase()
|
||||||
|
const displayName = rawName.trim().slice(0, 60)
|
||||||
|
if (!HANDLE.test(handle)) return { error: 'Handle must be 3–32 chars: a–z, 0–9, . _ -' }
|
||||||
|
if (password.length < 6) return { error: 'Password must be at least 6 characters' }
|
||||||
|
if (!displayName) return { error: 'A display name is required' }
|
||||||
|
const existing = await pool.query('SELECT 1 FROM osint.users WHERE handle=$1', [handle])
|
||||||
|
if (existing.rowCount) return { error: 'That handle is taken' }
|
||||||
|
const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null }>(
|
||||||
|
'INSERT INTO osint.users (handle,password_hash,display_name) VALUES ($1,$2,$3) RETURNING id,handle,display_name,avatar_url',
|
||||||
|
[handle, hashPassword(password), displayName])).rows[0]
|
||||||
|
return { user: toDto(row) }
|
||||||
|
},
|
||||||
|
|
||||||
|
async authenticateUser(rawHandle, password) {
|
||||||
|
const handle = rawHandle.trim().toLowerCase()
|
||||||
|
const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null; password_hash: string }>(
|
||||||
|
'SELECT id,handle,display_name,avatar_url,password_hash FROM osint.users WHERE handle=$1', [handle])).rows[0]
|
||||||
|
if (!row || !verifyPassword(password, row.password_hash)) return null
|
||||||
|
return toDto(row)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUser(id) {
|
||||||
|
const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null }>(
|
||||||
|
'SELECT id,handle,display_name,avatar_url FROM osint.users WHERE id=$1', [id])).rows[0]
|
||||||
|
return row ? toDto(row) : null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
-41
@@ -1,12 +1,15 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Images, Info, Link2, Minus, MousePointer2, Network, Newspaper, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Images, Info, Link2, Minus, MousePointer2, Network, Newspaper, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||||
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, NotePresentation, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||||||
import { AdminPanel } from './admin'
|
import { AdminPanel } from './admin'
|
||||||
import { audio } from './audio'
|
import { audio } from './audio'
|
||||||
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
||||||
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
||||||
import type { PlaythroughState } from './narrative'
|
import type { PlaythroughState } from './narrative'
|
||||||
|
|
||||||
|
// three.js stays out of the board bundle until the player opens the inventory.
|
||||||
|
const Inventory = lazy(() => import('./inventory').then(m => ({ default: m.Inventory })))
|
||||||
|
|
||||||
const BOARD_W = 2400
|
const BOARD_W = 2400
|
||||||
const BOARD_H = 1500
|
const BOARD_H = 1500
|
||||||
const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
|
const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
|
||||||
@@ -25,6 +28,12 @@ function documentSearchText(document: CaseDocument) {
|
|||||||
...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 clippingInsetRotation(id:string) {
|
||||||
|
let hash=0
|
||||||
|
for (const character of id) hash=(hash * 31 + character.charCodeAt(0)) >>> 0
|
||||||
|
const degrees=((hash % 49) - 24) / 10
|
||||||
|
return degrees === 0 ? .7 : degrees
|
||||||
|
}
|
||||||
function connectionPoint(item: Exhibit) {
|
function connectionPoint(item: Exhibit) {
|
||||||
return exhibitWidget(item.type).connectionPorts(item)[0]
|
return exhibitWidget(item.type).connectionPorts(item)[0]
|
||||||
}
|
}
|
||||||
@@ -72,6 +81,7 @@ export function App() {
|
|||||||
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 [flagsOpen, setFlagsOpen] = useState(false)
|
||||||
|
const [inventoryOpen, setInventoryOpen] = useState(false)
|
||||||
const [matchRulesOpen, setMatchRulesOpen] = useState(false)
|
const [matchRulesOpen, setMatchRulesOpen] = useState(false)
|
||||||
const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
|
const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
|
||||||
const [activePlaythroughId, setActivePlaythroughId] = useState<string | null>(null)
|
const [activePlaythroughId, setActivePlaythroughId] = useState<string | null>(null)
|
||||||
@@ -210,12 +220,14 @@ export function App() {
|
|||||||
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')
|
||||||
}
|
}
|
||||||
|
|
||||||
const addNote = () => {
|
const addNote = (preset?: string, presentation:NotePresentation = 'luggage') => {
|
||||||
const content = window.prompt('What do you think this evidence means?')?.trim()
|
const content = typeof preset === 'string' ? preset : 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.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
|
const size = presentation === 'lined_sheet' ? { width:220,height:270 } : { width:108,height:154 }
|
||||||
const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...placement(position.x, position.y, 108, 154) }
|
const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width:size.width })
|
||||||
|
const note: Evidence = { id: uid('note'), type:'note', title:presentation === 'lined_sheet' ? 'FIELD NOTE' : 'WORKING NOTE', content,presentation,
|
||||||
|
...placement(position.x,position.y,size.width,size.height) }
|
||||||
update(s => ({ ...s, exhibits: [...s.exhibits, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id)
|
update(s => ({ ...s, exhibits: [...s.exhibits, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,13 +381,23 @@ export function App() {
|
|||||||
const uploadFiles = useCallback(async (files: FileList | File[], source: 'file' | 'clipboard' = 'file') => {
|
const uploadFiles = useCallback(async (files: FileList | File[], source: 'file' | 'clipboard' = 'file') => {
|
||||||
if (!caseState) return
|
if (!caseState) return
|
||||||
const queue = Array.from(files)
|
const queue = Array.from(files)
|
||||||
|
const boardScreen = boardRef.current?.getBoundingClientRect()
|
||||||
|
const screen = { width: boardScreen?.width || window.innerWidth, height: boardScreen?.height || window.innerHeight }
|
||||||
|
const portraitMobile = screen.width <= 900 && screen.height > screen.width
|
||||||
|
const insets = {
|
||||||
|
top: Math.min(140, screen.height * .28),
|
||||||
|
right: portraitMobile ? 68 : 18,
|
||||||
|
bottom: portraitMobile ? 18 : 68,
|
||||||
|
left: 18,
|
||||||
|
}
|
||||||
|
const reserved = [...caseState.exhibits]
|
||||||
setUploading(queue.length)
|
setUploading(queue.length)
|
||||||
setDraggingFiles(false)
|
setDraggingFiles(false)
|
||||||
for (const [queueIndex, file] of queue.entries()) {
|
for (const file of queue) {
|
||||||
const position = nextOpenBoardPosition(caseState.exhibits, {
|
// Reserve for the largest image presentation so choosing Clip, Image, or
|
||||||
x: Math.max(100, (520 - caseState.viewport.x) / caseState.viewport.zoom) + queueIndex * 24,
|
// Mugshot in the following dialog cannot make it grow beyond the viewport.
|
||||||
y: Math.max(100, (310 - caseState.viewport.y) / caseState.viewport.zoom) + queueIndex * 24,
|
const reservedSize = { width: 244, height: 294 }
|
||||||
}, { width: 174, height: 145 })
|
const position = nextVisibleBoardPosition(reserved, caseState.viewport, screen, reservedSize, insets, { width: BOARD_W, height: BOARD_H })
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
form.append('x', String(position.x))
|
form.append('x', String(position.x))
|
||||||
@@ -386,6 +408,7 @@ export function App() {
|
|||||||
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 uploaded: UploadedCaseDocument = await response.json()
|
const uploaded: UploadedCaseDocument = await response.json()
|
||||||
const { analysis, ...document } = uploaded
|
const { analysis, ...document } = uploaded
|
||||||
|
reserved.push({ ...document, ...position, ...reservedSize })
|
||||||
update(s => {
|
update(s => {
|
||||||
return { ...s, exhibits: [...s.exhibits, { ...document, ...position }] }
|
return { ...s, exhibits: [...s.exhibits, { ...document, ...position }] }
|
||||||
})
|
})
|
||||||
@@ -435,7 +458,10 @@ export function App() {
|
|||||||
|
|
||||||
const classifyDocument = (documentId:string,captureKind:DocumentCaptureKind) => {
|
const classifyDocument = (documentId:string,captureKind:DocumentCaptureKind) => {
|
||||||
const presentation=documentCapture(captureKind)
|
const presentation=documentCapture(captureKind)
|
||||||
const classify = (document:CaseDocument):CaseDocument => ({ ...document,captureKind,width:presentation.defaultSize.width,height:presentation.defaultSize.height })
|
const initialClipRotation=Math.round((Math.random() * 20 - 10) * 10) / 10
|
||||||
|
const classify = (document:CaseDocument):CaseDocument => ({ ...document,captureKind,width:presentation.defaultSize.width,height:presentation.defaultSize.height,
|
||||||
|
title:captureKind === 'clipping' && document.captureKind === 'unclassified' && document.title === document.fileName ? '' : document.title,
|
||||||
|
rotation:captureKind === 'clipping' && document.captureKind === 'unclassified' ? initialClipRotation : document.rotation })
|
||||||
update(state => ({ ...state,exhibits:state.exhibits.map(exhibit => exhibit.id === documentId && exhibit.type === 'document'
|
update(state => ({ ...state,exhibits:state.exhibits.map(exhibit => exhibit.id === documentId && exhibit.type === 'document'
|
||||||
? classify(exhibit)
|
? classify(exhibit)
|
||||||
: exhibit) }))
|
: exhibit) }))
|
||||||
@@ -454,7 +480,7 @@ export function App() {
|
|||||||
if (!response.ok) { setStatus('OBJECTIVE COMPLETE · STORY ADVANCE UNAVAILABLE'); return }
|
if (!response.ok) { setStatus('OBJECTIVE COMPLETE · STORY ADVANCE UNAVAILABLE'); return }
|
||||||
const next = await response.json() as PlaythroughState
|
const next = await response.json() as PlaythroughState
|
||||||
if (next.node?.kind === 'level' && next.node.levelSlug) window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`)
|
if (next.node?.kind === 'level' && next.node.levelSlug) window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`)
|
||||||
else window.location.assign('/')
|
else window.location.assign('/?resume=1') // resume straight into the next node, not the splash
|
||||||
} finally { setAdvancing(false) }
|
} finally { setAdvancing(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,12 +551,16 @@ export function App() {
|
|||||||
const reportAttentionCount = caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0
|
const reportAttentionCount = caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0
|
||||||
const activeGoal = caseState.goals.find(goal => goal.status === 'pending') || caseState.goals[0]
|
const activeGoal = caseState.goals.find(goal => goal.status === 'pending') || caseState.goals[0]
|
||||||
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
|
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
|
||||||
const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => exhibitWidget(exhibit.type).temporalFacts(exhibit).map(fact => {
|
const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => {
|
||||||
|
const widget=exhibitWidget(exhibit.type)
|
||||||
|
if (!widget) throw new Error(`No widget is registered for exhibit type “${String(exhibit.type)}”`)
|
||||||
|
return widget.temporalFacts(exhibit).map(fact => {
|
||||||
const membership = exhibit.type === 'document' ? caseState.relations.find(relation => relation.type === 'contains' && relation.toExhibitId === exhibit.id) : undefined
|
const membership = exhibit.type === 'document' ? caseState.relations.find(relation => relation.type === 'contains' && relation.toExhibitId === exhibit.id) : undefined
|
||||||
const folder = membership ? caseState.exhibits.find(candidate => candidate.id === membership.fromExhibitId && candidate.type === 'folder') as FolderExhibit | undefined : undefined
|
const folder = membership ? caseState.exhibits.find(candidate => candidate.id === membership.fromExhibitId && candidate.type === 'folder') as FolderExhibit | undefined : undefined
|
||||||
const sourceTemporalId = folder && !folder.isOpen ? `widget:${folder.id}` : `widget:${exhibit.id}`
|
const sourceTemporalId = folder && !folder.isOpen ? `widget:${folder.id}` : `widget:${exhibit.id}`
|
||||||
return { id: fact.id, sourceTemporalId, date: fact.start, label: fact.label, kind: exhibit.type === 'document' ? 'document' as const : 'widget' as const, exhibitId: exhibit.id }
|
return { id: fact.id, sourceTemporalId, date: fact.start, label: fact.label, kind: exhibit.type === 'document' ? 'document' as const : 'widget' as const, exhibitId: exhibit.id }
|
||||||
})).sort((a, b) => dateValue(a.date) - dateValue(b.date))
|
})
|
||||||
|
}).sort((a, b) => dateValue(a.date) - dateValue(b.date))
|
||||||
const storyEvents = evidence.filter((item): item is EventExhibit => item.type === 'event').sort((a, b) => {
|
const storyEvents = evidence.filter((item): item is EventExhibit => 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
|
||||||
@@ -538,6 +568,10 @@ export function App() {
|
|||||||
})
|
})
|
||||||
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
|
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
|
||||||
return <main className="desktop">
|
return <main className="desktop">
|
||||||
|
{inventoryOpen && activePlaythroughId && <Suspense fallback={null}>
|
||||||
|
<Inventory session={{ playthroughId: activePlaythroughId, onConnect: () => window.location.assign('/?resume=1') }}
|
||||||
|
onTearToBoard={text => { addNote(text,'lined_sheet'); setInventoryOpen(false) }} onClose={() => setInventoryOpen(false)} />
|
||||||
|
</Suspense>}
|
||||||
<header className="menubar">
|
<header className="menubar">
|
||||||
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
||||||
<nav>
|
<nav>
|
||||||
@@ -545,6 +579,7 @@ export function App() {
|
|||||||
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{briefAttentionCount > 0 && <b className="brief-count">{briefAttentionCount}</b>}</button>
|
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{briefAttentionCount > 0 && <b className="brief-count">{briefAttentionCount}</b>}</button>
|
||||||
{caseState.report && <button className={reportOpen ? 'active' : ''} aria-label="Case report" onClick={() => reportOpen ? setReportOpen(false) : void openCaseReport()}>CASE REPORT{reportAttentionCount > 0 && <b className="brief-count">{reportAttentionCount}</b>}</button>}
|
{caseState.report && <button className={reportOpen ? 'active' : ''} aria-label="Case report" onClick={() => reportOpen ? setReportOpen(false) : void openCaseReport()}>CASE REPORT{reportAttentionCount > 0 && <b className="brief-count">{reportAttentionCount}</b>}</button>}
|
||||||
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
|
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
|
||||||
|
{activePlaythroughId && <button className={inventoryOpen ? 'active' : ''} onClick={() => setInventoryOpen(true)}>INVENTORY</button>}
|
||||||
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||||
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
||||||
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
|
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
|
||||||
@@ -600,7 +635,7 @@ export function App() {
|
|||||||
<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>
|
||||||
<span />
|
<span />
|
||||||
<button onClick={addNote}><NotebookPen size={17}/> NEW NOTE</button>
|
<button onClick={() => addNote()}><NotebookPen size={17}/> NEW NOTE</button>
|
||||||
<button onClick={addEvent}><CalendarClock size={17}/> NEW EVENT</button>
|
<button onClick={addEvent}><CalendarClock size={17}/> NEW EVENT</button>
|
||||||
<button onClick={addParty}><UserRound size={17}/> NEW PARTY</button>
|
<button onClick={addParty}><UserRound size={17}/> NEW PARTY</button>
|
||||||
<button className={`thread-tool ${linkFrom ? 'active' : ''}`} aria-label="Red thread" title={linkFrom ? 'Cancel red thread' : selected ? 'Connect selected exhibit with red thread' : 'Select an exhibit first'} disabled={!selected} onClick={toggleThreadTool}><Link2 size={18}/></button>
|
<button className={`thread-tool ${linkFrom ? 'active' : ''}`} aria-label="Red thread" title={linkFrom ? 'Cancel red thread' : selected ? 'Connect selected exhibit with red thread' : 'Select an exhibit first'} disabled={!selected} onClick={toggleThreadTool}><Link2 size={18}/></button>
|
||||||
@@ -940,20 +975,22 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
|
|||||||
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
|
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
|
||||||
{containmentRelations.map(relation => { const folder = byId.get(relation.fromExhibitId), document = byId.get(relation.toExhibitId); if (folder?.type !== 'folder' || document?.type !== 'document') return null; const origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={folder.isOpen ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={folder.isOpen ? document.x + document.width / 2 : origin.x} y2={folder.isOpen ? document.y + document.height / 2 : origin.y}/> })}
|
{containmentRelations.map(relation => { const folder = byId.get(relation.fromExhibitId), document = byId.get(relation.toExhibitId); if (folder?.type !== 'folder' || document?.type !== 'document') return null; const origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={folder.isOpen ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={folder.isOpen ? document.x + document.width / 2 : origin.x} y2={folder.isOpen ? document.y + document.height / 2 : origin.y}/> })}
|
||||||
</svg>
|
</svg>
|
||||||
{evidenceExhibits(state.exhibits).filter(exhibit => !exhibit.hidden).map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = byId.get(id); return document?.type === 'document' ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !ev.isOpen && containedDocuments.some(document => document.id === locatorDocumentId) ? locatorDocumentId : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; 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 }}
|
{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)); const notePresentation=ev.type === 'note' ? ev.presentation === 'lined_sheet' ? 'lined-sheet' : 'luggage-tag' : ''; 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} ${notePresentation} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id || arrivingExhibitIds.includes(ev.id) || containsArrival ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }}
|
||||||
onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (ev.type === 'folder') startFolderLongPress(e, ev.id); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
|
onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (ev.type === 'folder') startFolderLongPress(e, ev.id); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
|
||||||
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
|
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
|
||||||
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (ev.type === 'folder' && e.detail > 1) return; if (tool === 'move') onCardClick(ev.id) }} onDoubleClick={e => { e.stopPropagation(); if (ev.type === 'folder' && tool === 'move' && !linkFrom && !(e.target as HTMLElement).closest('button')) toggleFolder(ev.id) }} onKeyDown={e => { if (ev.type === 'folder' && e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); toggleFolder(ev.id) } }}>
|
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (ev.type === 'folder' && e.detail > 1) return; if (tool === 'move') onCardClick(ev.id) }} onDoubleClick={e => { e.stopPropagation(); if (ev.type === 'folder' && tool === 'move' && !linkFrom && !(e.target as HTMLElement).closest('button')) toggleFolder(ev.id) }} onKeyDown={e => { if (ev.type === 'folder' && e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); toggleFolder(ev.id) } }}>
|
||||||
<header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
|
<header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
|
||||||
<Widget exhibit={ev} context={widgetContext}/>
|
<Widget exhibit={ev} context={widgetContext}/>
|
||||||
</article>})}
|
</article>})}
|
||||||
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const presentation=documentCapture(document.captureKind); const identification=mugshotIdentification(document,state.exhibits,state.connections); 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}`} data-capture-kind={document.captureKind} data-identified-party-id={identification?.party.id} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} capture-kind-${document.captureKind} ${identification ? 'mugshot-identified' : ''} ${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 }}
|
{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 presentation=documentCapture(document.captureKind); const identification=mugshotIdentification(document,state.exhibits,state.connections); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; const missingProvenance=[!document.publishedAt ? 'DATE' : '',!document.sourceCitation?.trim() ? 'SOURCE' : ''].filter(Boolean); return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} data-capture-kind={document.captureKind} data-identified-party-id={identification?.party.id} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} capture-kind-${document.captureKind} ${identification ? 'mugshot-identified' : ''} ${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,'--clip-inset-rotation':`${clippingInsetRotation(document.id)}deg` } as React.CSSProperties}
|
||||||
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
|
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
|
||||||
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
|
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
|
||||||
|
{document.captureKind === 'clipping' && <span className="clip-stamped-pin" aria-hidden="true"/>}
|
||||||
<header><span>{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}</span><i>{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
<header><span>{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}</span><i>{String(document.displayNumber || (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>
|
||||||
{document.captureKind === 'photo' ? <MugshotCaption name={identification?.party.title || ''}/> : <strong>{document.title}</strong>}<time>{document.publishedAt?.slice(0, 10) || 'UNDATED'}</time>
|
{document.captureKind === 'photo' ? <MugshotCaption name={identification?.party.title || ''}/> : document.captureKind !== 'clipping' ? <strong>{document.title}</strong> : null}
|
||||||
<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-footer">{document.captureKind === 'clipping' && <span className={`clip-provenance ${missingProvenance.length ? 'incomplete' : 'complete'}`}>{missingProvenance.length ? `ADD ${missingProvenance.join(' + ')}` : 'SOURCE RECORDED'}</span>}<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>
|
||||||
</article> })}
|
</article> })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1079,6 +1116,15 @@ function localDateTime(value?: string) {
|
|||||||
return local.toISOString().slice(0, 16)
|
return local.toISOString().slice(0, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function publishedDateParts(value?: string) {
|
||||||
|
if (!value) return { date:'',time:'' }
|
||||||
|
const parsed=new Date(value)
|
||||||
|
if (!Number.isFinite(parsed.getTime())) return { date:'',time:'' }
|
||||||
|
const iso=parsed.toISOString()
|
||||||
|
const time=iso.slice(11,16)
|
||||||
|
return { date:iso.slice(0,10),time:time === '00:00' ? '' : time }
|
||||||
|
}
|
||||||
|
|
||||||
function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: TimelineRange | null; dates: string[]; onClose: () => void; onSave: (range: TimelineRange | null) => void }) {
|
function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: TimelineRange | null; dates: string[]; onClose: () => void; onSave: (range: TimelineRange | null) => void }) {
|
||||||
const dated = dates.map(date => date.slice(0, 10)).filter(Boolean).sort()
|
const dated = dates.map(date => date.slice(0, 10)).filter(Boolean).sort()
|
||||||
const [start, setStart] = useState(range?.start || dated[0] || '')
|
const [start, setStart] = useState(range?.start || dated[0] || '')
|
||||||
@@ -1097,21 +1143,15 @@ function CaseReportPanel({ report, defaultInvestigator, hasNext, onClose, onSubm
|
|||||||
report:CaseReport;defaultInvestigator:string;hasNext:boolean;onClose:()=>void
|
report:CaseReport;defaultInvestigator:string;hasNext:boolean;onClose:()=>void
|
||||||
onSubmit:(input:CaseReportSubmissionInput)=>Promise<CaseReport>;onContinue:()=>void
|
onSubmit:(input:CaseReportSubmissionInput)=>Promise<CaseReport>;onContinue:()=>void
|
||||||
}) {
|
}) {
|
||||||
const [investigatorName,setInvestigatorName]=useState(report.investigatorName || defaultInvestigator)
|
const investigatorName=report.investigatorName || defaultInvestigator
|
||||||
const [evidence,setEvidence]=useState<CaseReportSubmissionInput['evidence']>(() => report.claims.flatMap(claim => claim.evidence.map(item => ({
|
|
||||||
connectionId:item.connectionId,documentExhibitId:item.documentExhibitId,relationText:item.relationText || 'Proof that…',
|
|
||||||
publishedAt:item.publishedAt?.slice(0,10),sourceCitation:item.sourceCitation,sourceUri:item.sourceUri,
|
|
||||||
}))))
|
|
||||||
const [busy,setBusy]=useState(false)
|
const [busy,setBusy]=useState(false)
|
||||||
const [error,setError]=useState('')
|
const [error,setError]=useState('')
|
||||||
const updateEvidence=(connectionId:string,patch:Partial<CaseReportSubmissionInput['evidence'][number]>) => setEvidence(items => items.map(item => item.connectionId === connectionId ? { ...item,...patch } : item))
|
|
||||||
const submit=async (event:React.FormEvent) => {
|
const submit=async (event:React.FormEvent) => {
|
||||||
event.preventDefault();setBusy(true);setError('')
|
event.preventDefault();setBusy(true);setError('')
|
||||||
try { await onSubmit({ investigatorName,evidence }) }
|
try { await onSubmit({ investigatorName }) }
|
||||||
catch (reason) { setError(reason instanceof Error ? reason.message : 'Report submission failed') }
|
catch (reason) { setError(reason instanceof Error ? reason.message : 'Report submission failed') }
|
||||||
finally { setBusy(false) }
|
finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
const draftByConnection=new Map(evidence.map(item => [item.connectionId,item]))
|
|
||||||
return <div className="modal-shade case-report-shade"><form className="window case-report" onSubmit={event => void submit(event)}>
|
return <div className="modal-shade case-report-shade"><form className="window case-report" onSubmit={event => void submit(event)}>
|
||||||
<header><ClipboardCheck size={16}/><b>{report.title}</b><span/><button type="button" aria-label="Close case report" onClick={onClose}><X size={14}/></button></header>
|
<header><ClipboardCheck size={16}/><b>{report.title}</b><span/><button type="button" aria-label="Close case report" onClick={onClose}><X size={14}/></button></header>
|
||||||
<div className="case-report-paper"><div className="case-report-letterhead"><small>GLITCH UNIVERSITY · PRINCIPAL INVESTIGATOR PROGRAMME</small><h2>CASE REPORT</h2><span>FORM GUPI–7 / EVIDENTIARY FINDING</span></div>
|
<div className="case-report-paper"><div className="case-report-letterhead"><small>GLITCH UNIVERSITY · PRINCIPAL INVESTIGATOR PROGRAMME</small><h2>CASE REPORT</h2><span>FORM GUPI–7 / EVIDENTIARY FINDING</span></div>
|
||||||
@@ -1119,16 +1159,16 @@ function CaseReportPanel({ report, defaultInvestigator, hasNext, onClose, onSubm
|
|||||||
<h3><span>CLAIM {claimIndex + 1}</span>{claim.statement}</h3>
|
<h3><span>CLAIM {claimIndex + 1}</span>{claim.statement}</h3>
|
||||||
<h4>EVIDENCE</h4>
|
<h4>EVIDENCE</h4>
|
||||||
{claim.evidence.length === 0 ? <div className="report-empty-evidence">No source evidence is connected to this claim. Return to the board and use red thread to attach a document.</div>
|
{claim.evidence.length === 0 ? <div className="report-empty-evidence">No source evidence is connected to this claim. Return to the board and use red thread to attach a document.</div>
|
||||||
: claim.evidence.map(item => { const draft=draftByConnection.get(item.connectionId)!; const rejected=report.status !== 'draft' && !item.evidenceAccepted; return <article className={`report-evidence ${item.evidenceAccepted ? 'verified' : rejected ? 'rejected' : ''}`} key={item.connectionId}>
|
: claim.evidence.map(item => { const rejected=report.status !== 'draft' && !item.evidenceAccepted; return <article className={`report-evidence ${item.evidenceAccepted ? 'verified' : rejected ? 'rejected' : ''}`} key={item.connectionId}>
|
||||||
<div className="report-evidence-heading"><b>Exhibit {item.displayNumber}</b><span>{item.fileType.replaceAll('_',' ')} · {item.documentTitle}</span>{item.evidenceAccepted ? <em>CONTENT VERIFIED</em> : rejected ? <em className="rejected">NOT VERIFIED</em> : null}</div>
|
<div className="report-evidence-heading"><b>Exhibit {item.displayNumber}</b><span>{item.fileType.replaceAll('_',' ')} · {item.documentTitle}</span>{item.evidenceAccepted ? <em>CONTENT VERIFIED</em> : rejected ? <em className="rejected">NOT VERIFIED</em> : null}</div>
|
||||||
{rejected && <p className="report-evidence-diagnostic">{item.verification.detail}</p>}
|
{rejected && <p className="report-evidence-diagnostic">{item.verification.detail}</p>}
|
||||||
<label><span>EVIDENTIARY STATEMENT</span><textarea aria-label={`Evidence statement for Exhibit ${item.displayNumber}`} rows={2} value={draft.relationText} onChange={event => updateEvidence(item.connectionId,{ relationText:event.target.value })}/></label>
|
<div className="report-reference"><span>EVIDENTIARY STATEMENT</span><p>{item.relationText || 'No evidentiary statement attached.'}</p></div>
|
||||||
<div className="report-fields"><label><span>DATED</span><input aria-label={`Date for Exhibit ${item.displayNumber}`} type="date" value={draft.publishedAt || ''} onChange={event => updateEvidence(item.connectionId,{ publishedAt:event.target.value || undefined })}/></label>
|
<div className="report-fields"><div className="report-reference"><span>DATED</span><p>{item.publishedAt?.slice(0,10) || 'NOT RECORDED'}</p></div>
|
||||||
<label><span>SOURCE / PUBLICATION</span><input aria-label={`Source for Exhibit ${item.displayNumber}`} value={draft.sourceCitation || ''} placeholder="e.g. Google Patents · GB695913A" onChange={event => updateEvidence(item.connectionId,{ sourceCitation:event.target.value })}/></label></div>
|
<div className="report-reference"><span>SOURCE / PUBLICATION</span><p>{item.sourceCitation || 'NOT RECORDED'}</p></div></div>
|
||||||
<label><span>SOURCE LINK · IF AVAILABLE</span><input aria-label={`Source link for Exhibit ${item.displayNumber}`} type="url" value={draft.sourceUri || ''} placeholder="https://…" onChange={event => updateEvidence(item.connectionId,{ sourceUri:event.target.value })}/></label>
|
<div className="report-reference"><span>SOURCE LINK · IF AVAILABLE</span>{item.sourceUri ? <a href={item.sourceUri} target="_blank" rel="noreferrer">{item.sourceUri}</a> : <p>NOT RECORDED</p>}</div>
|
||||||
</article> })}
|
</article> })}
|
||||||
</section>)}
|
</section>)}
|
||||||
<label className="report-investigator"><span>INVESTIGATOR</span><input aria-label="Investigator name" value={investigatorName} maxLength={300} onChange={event => setInvestigatorName(event.target.value)}/></label>
|
<div className="report-investigator report-reference"><span>INVESTIGATOR</span><p>{investigatorName}</p></div>
|
||||||
{report.status !== 'draft' && <section className={`report-verdict ${report.status}`} role="status"><small>{report.status === 'accepted' ? 'REPORT ACCEPTED' : report.status === 'evidence_accepted_report_incomplete' ? 'EVIDENCE PASSED · REPORT RETURNED' : 'EVIDENCE NOT ESTABLISHED'}</small><p>{report.feedback}</p></section>}
|
{report.status !== 'draft' && <section className={`report-verdict ${report.status}`} role="status"><small>{report.status === 'accepted' ? 'REPORT ACCEPTED' : report.status === 'evidence_accepted_report_incomplete' ? 'EVIDENCE PASSED · REPORT RETURNED' : 'EVIDENCE NOT ESTABLISHED'}</small><p>{report.feedback}</p></section>}
|
||||||
{error && <p className="flag-error">{error}</p>}
|
{error && <p className="flag-error">{error}</p>}
|
||||||
<div className="case-report-actions"><button type="button" onClick={onClose}>RETURN TO BOARD</button>{report.status === 'accepted'
|
<div className="case-report-actions"><button type="button" onClick={onClose}>RETURN TO BOARD</button>{report.status === 'accepted'
|
||||||
@@ -1315,14 +1355,19 @@ function FileEditor({ document, canEditGates, onClose, onSave }: { document: Cas
|
|||||||
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 [captureKind, setCaptureKind] = useState<DocumentCaptureKind>(document.captureKind)
|
const [captureKind, setCaptureKind] = useState<DocumentCaptureKind>(document.captureKind)
|
||||||
const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt))
|
const [publishedDate,setPublishedDate]=useState(() => publishedDateParts(document.publishedAt).date)
|
||||||
|
const [publishedTime,setPublishedTime]=useState(() => publishedDateParts(document.publishedAt).time)
|
||||||
|
const [sourceCitation,setSourceCitation]=useState(document.sourceCitation || '')
|
||||||
|
const [sourceUri,setSourceUri]=useState(document.sourceUri || '')
|
||||||
const [requiredFlags, setRequiredFlags] = useState((document.requiredFlags || []).join(', '))
|
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=publishedDate ? `${publishedDate}T${publishedTime || '00:00'}:00.000Z` : undefined
|
||||||
const presentation=documentCapture(captureKind)
|
const presentation=documentCapture(captureKind)
|
||||||
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType,captureKind,publishedAt,
|
const resolvedTitle=title.trim() || (captureKind === 'clipping' ? sourceCitation.trim() : document.fileName || 'UNTITLED FILE')
|
||||||
|
onSave({ ...document,title:resolvedTitle,fileType,captureKind,publishedAt,
|
||||||
|
sourceCitation:sourceCitation.trim() || undefined,sourceUri:sourceUri.trim() || undefined,
|
||||||
...(captureKind !== document.captureKind ? presentation.defaultSize : {}),
|
...(captureKind !== document.captureKind ? presentation.defaultSize : {}),
|
||||||
requiredFlags: canEditGates ? [...new Set(requiredFlags.split(',').map(value => value.trim().toLowerCase()).filter(Boolean))] : document.requiredFlags,
|
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])) })
|
metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
|
||||||
@@ -1332,15 +1377,22 @@ function FileEditor({ document, canEditGates, onClose, onSave }: { document: Cas
|
|||||||
<div className="file-editor-body">
|
<div className="file-editor-body">
|
||||||
<small>SOURCE FILE WIDGET</small>
|
<small>SOURCE FILE WIDGET</small>
|
||||||
<div className="file-editor-grid">
|
<div className="file-editor-grid">
|
||||||
<label className="field"><span>TITLE</span><input value={title} onChange={event => setTitle(event.target.value)}/></label>
|
<label className="field"><span>TITLE · DEFAULTS TO SOURCE FOR CLIPS</span><input aria-label="Document title" value={title} placeholder={captureKind === 'clipping' ? 'Uses Source / Publication when blank' : ''} onChange={event => setTitle(event.target.value)}/></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>
|
<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>BOARD PRESENTATION</span><select aria-label="Board presentation" value={captureKind} onChange={event => setCaptureKind(event.target.value as DocumentCaptureKind)}>{Object.entries(documentCaptureRegistry).map(([kind,definition]) => <option key={kind} value={kind}>{definition.label} — {definition.description}</option>)}</select></label>
|
<label className="field"><span>BOARD PRESENTATION</span><select aria-label="Board presentation" value={captureKind} onChange={event => setCaptureKind(event.target.value as DocumentCaptureKind)}>{Object.entries(documentCaptureRegistry).map(([kind,definition]) => <option key={kind} value={kind}>{definition.label} — {definition.description}</option>)}</select></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>
|
<div className="file-editor-grid published-fields">
|
||||||
|
<label className="field"><span><CalendarClock size={13}/> PUBLISHED DATE</span><input aria-label="Published date" type="date" value={publishedDate} onChange={event => { setPublishedDate(event.target.value); if (!event.target.value) setPublishedTime('') }}/></label>
|
||||||
|
<label className="field"><span>TIME · OPTIONAL · UTC</span><input aria-label="Published time" type="time" step="60" disabled={!publishedDate} value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
|
||||||
|
</div>
|
||||||
|
<div className="file-editor-grid">
|
||||||
|
<label className="field"><span>SOURCE / PUBLICATION</span><input aria-label="Source publication" value={sourceCitation} maxLength={1000} placeholder="e.g. Google Patents · GB695913A" onChange={event => setSourceCitation(event.target.value)}/></label>
|
||||||
|
<label className="field"><span>SOURCE URL</span><input aria-label="Source URL" type="url" value={sourceUri} maxLength={2000} placeholder="https://…" onChange={event => setSourceUri(event.target.value)}/></label>
|
||||||
|
</div>
|
||||||
{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>}
|
{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>
|
|
||||||
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE METADATA</button></div>
|
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE METADATA</button></div>
|
||||||
</div>
|
</div>
|
||||||
</form></div>
|
</form></div>
|
||||||
|
|||||||
+28
-1
@@ -9,6 +9,7 @@ import {
|
|||||||
folderIsOpen,
|
folderIsOpen,
|
||||||
moveBoardPoint,
|
moveBoardPoint,
|
||||||
nextOpenBoardPosition,
|
nextOpenBoardPosition,
|
||||||
|
nextVisibleBoardPosition,
|
||||||
normalizeCase,
|
normalizeCase,
|
||||||
panViewport,
|
panViewport,
|
||||||
relationPosition,
|
relationPosition,
|
||||||
@@ -121,6 +122,32 @@ describe('board coordinate math', () => {
|
|||||||
{ x: 826, y: 400, width: 280, height: 160 },
|
{ x: 826, y: 400, width: 280, height: 160 },
|
||||||
], preferred, { width: 280 })).toEqual({ x: 174, y: 400 })
|
], preferred, { width: 280 })).toEqual({ x: 174, y: 400 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('places new evidence within a panned and zoomed mobile viewport', () => {
|
||||||
|
const viewport = { x: -720, y: -310, zoom: .75 }
|
||||||
|
const screen = { width: 390, height: 658 }
|
||||||
|
const size = { width: 244, height: 294 }
|
||||||
|
const position = nextVisibleBoardPosition([], viewport, screen, size, { top: 120, right: 68, bottom: 18, left: 16 })
|
||||||
|
const screenLeft = viewport.x + position.x * viewport.zoom
|
||||||
|
const screenTop = viewport.y + position.y * viewport.zoom
|
||||||
|
expect(screenLeft).toBeGreaterThanOrEqual(16)
|
||||||
|
expect(screenTop).toBeGreaterThanOrEqual(120)
|
||||||
|
expect(screenLeft + size.width * viewport.zoom).toBeLessThanOrEqual(screen.width - 68)
|
||||||
|
expect(screenTop + size.height * viewport.zoom).toBeLessThanOrEqual(screen.height - 18)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps collision avoidance inside the currently visible board area', () => {
|
||||||
|
const viewport = { x: -400, y: -200, zoom: .5 }
|
||||||
|
const screen = { width: 800, height: 600 }
|
||||||
|
const size = { width: 174, height: 145 }
|
||||||
|
const first = nextVisibleBoardPosition([], viewport, screen, size)
|
||||||
|
const second = nextVisibleBoardPosition([{ ...first, ...size }], viewport, screen, size)
|
||||||
|
expect(second).not.toEqual(first)
|
||||||
|
expect(viewport.x + second.x * viewport.zoom).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(viewport.y + second.y * viewport.zoom).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(viewport.x + (second.x + size.width) * viewport.zoom).toBeLessThanOrEqual(screen.width)
|
||||||
|
expect(viewport.y + (second.y + size.height) * viewport.zoom).toBeLessThanOrEqual(screen.height)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('timeline projection', () => {
|
describe('timeline projection', () => {
|
||||||
@@ -194,7 +221,7 @@ describe('folder domain behavior', () => {
|
|||||||
|
|
||||||
describe('exhibit disposal', () => {
|
describe('exhibit disposal', () => {
|
||||||
it('removes an exhibit and every graph reference while retaining unrelated exhibits', () => {
|
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',presentation:'luggage' as const }
|
||||||
const event = { ...folder, id: 'event-1', type: 'event' as const, eventDate: undefined }
|
const event = { ...folder, id: 'event-1', type: 'event' as const, eventDate: undefined }
|
||||||
const party = { ...folder, id: 'party-1', type: 'party' as const, partyKind: 'person' as const, aliases: [] }
|
const 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, captureKind:'unclassified' as const, metadata: {} }
|
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, captureKind:'unclassified' as const, metadata: {} }
|
||||||
|
|||||||
+70
-2
@@ -1,7 +1,9 @@
|
|||||||
import type { BoardView, CaseState, Connection, DocumentCaptureKind, Exhibit, ExhibitRelation, FolderExhibit, OrganizationKind, SourceFileType, TimelineRange, Viewport } from './types'
|
import type { BoardView, CaseState, Connection, DocumentCaptureKind, Exhibit, ExhibitRelation, FolderExhibit, NotePresentation, OrganizationKind, SourceFileType, TimelineRange, Viewport } from './types'
|
||||||
|
|
||||||
export interface BoardPoint { x: number; y: number }
|
export interface BoardPoint { x: number; y: number }
|
||||||
|
|
||||||
|
function notePresentation(value: unknown): NotePresentation { return value === 'lined_sheet' ? 'lined_sheet' : 'luggage' }
|
||||||
|
|
||||||
function threadControls(from: BoardPoint, to: BoardPoint, tightness = 65) {
|
function threadControls(from: BoardPoint, to: BoardPoint, tightness = 65) {
|
||||||
const tautness = Math.max(0, Math.min(100, Number(tightness) || 0)) / 100
|
const tautness = Math.max(0, Math.min(100, Number(tightness) || 0)) / 100
|
||||||
const distance = Math.hypot(to.x - from.x, to.y - from.y)
|
const distance = Math.hypot(to.x - from.x, to.y - from.y)
|
||||||
@@ -121,6 +123,71 @@ export function nextOpenBoardPosition(
|
|||||||
return { x: Math.max(80, Math.min(bounds.width - size.width - 80, preferred.x)), y: Math.min(bounds.height - height - 80, preferred.y + evidence.length * 24) }
|
return { x: Math.max(80, Math.min(bounds.width - size.width - 80, preferred.x)), y: Math.min(bounds.height - height - 80, preferred.y + evidence.length * 24) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BoardScreenSize { width: number; height: number }
|
||||||
|
export interface BoardScreenInsets { top?: number; right?: number; bottom?: number; left?: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds an open board position inside the portion of the canvas the player can
|
||||||
|
* currently see. Screen insets reserve space for board chrome such as the mobile
|
||||||
|
* tool rail. If the visible area is too small to contain the whole exhibit, the
|
||||||
|
* exhibit is centred so the largest useful portion remains on screen.
|
||||||
|
*/
|
||||||
|
export function nextVisibleBoardPosition(
|
||||||
|
exhibits: Pick<Exhibit, 'x' | 'y' | 'width' | 'height'>[],
|
||||||
|
viewport: Viewport,
|
||||||
|
screen: BoardScreenSize,
|
||||||
|
size: { width: number; height: number },
|
||||||
|
insets: BoardScreenInsets = {},
|
||||||
|
boardBounds = { width: 2400, height: 1500 },
|
||||||
|
) {
|
||||||
|
if (!Number.isFinite(viewport.zoom) || viewport.zoom <= 0) throw new RangeError('Board zoom must be positive')
|
||||||
|
const left = Math.max(0, insets.left || 0)
|
||||||
|
const top = Math.max(0, insets.top || 0)
|
||||||
|
const right = Math.max(0, insets.right || 0)
|
||||||
|
const bottom = Math.max(0, insets.bottom || 0)
|
||||||
|
const usableWidth = Math.max(1, screen.width - left - right)
|
||||||
|
const usableHeight = Math.max(1, screen.height - top - bottom)
|
||||||
|
const centre = {
|
||||||
|
x: (left + usableWidth / 2 - viewport.x) / viewport.zoom - size.width / 2,
|
||||||
|
y: (top + usableHeight / 2 - viewport.y) / viewport.zoom - size.height / 2,
|
||||||
|
}
|
||||||
|
const worldMinX = 40
|
||||||
|
const worldMinY = 40
|
||||||
|
const worldMaxX = Math.max(worldMinX, boardBounds.width - size.width - 40)
|
||||||
|
const worldMaxY = Math.max(worldMinY, boardBounds.height - size.height - 40)
|
||||||
|
const visibleMinX = Math.max(worldMinX, (left - viewport.x) / viewport.zoom)
|
||||||
|
const visibleMinY = Math.max(worldMinY, (top - viewport.y) / viewport.zoom)
|
||||||
|
const visibleMaxX = Math.min(worldMaxX, (screen.width - right - viewport.x) / viewport.zoom - size.width)
|
||||||
|
const visibleMaxY = Math.min(worldMaxY, (screen.height - bottom - viewport.y) / viewport.zoom - size.height)
|
||||||
|
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value))
|
||||||
|
|
||||||
|
// A whole exhibit may not fit at the current zoom on a very short landscape
|
||||||
|
// phone. In that case centring it gives the player the largest visible area.
|
||||||
|
const preferred = {
|
||||||
|
x: clamp(centre.x, worldMinX, worldMaxX),
|
||||||
|
y: clamp(centre.y, worldMinY, worldMaxY),
|
||||||
|
}
|
||||||
|
if (visibleMinX > visibleMaxX || visibleMinY > visibleMaxY) return preferred
|
||||||
|
preferred.x = clamp(preferred.x, visibleMinX, visibleMaxX)
|
||||||
|
preferred.y = clamp(preferred.y, visibleMinY, visibleMaxY)
|
||||||
|
|
||||||
|
const gap = 28
|
||||||
|
const overlaps = (x: number, y: number) => exhibits.some(item =>
|
||||||
|
x < item.x + item.width + gap && x + size.width + gap > item.x &&
|
||||||
|
y < item.y + item.height + gap && y + size.height + gap > item.y)
|
||||||
|
const axisCandidates = (origin: number, minimum: number, maximum: number, step: number) => {
|
||||||
|
const values = [origin, minimum, maximum]
|
||||||
|
const rings = Math.ceil((maximum - minimum) / step) + 1
|
||||||
|
for (let ring = 1; ring <= rings; ring += 1) values.push(clamp(origin + ring * step, minimum, maximum), clamp(origin - ring * step, minimum, maximum))
|
||||||
|
return [...new Set(values.map(value => Math.round(value * 1000) / 1000))]
|
||||||
|
}
|
||||||
|
const xs = axisCandidates(preferred.x, visibleMinX, visibleMaxX, size.width + 46)
|
||||||
|
const ys = axisCandidates(preferred.y, visibleMinY, visibleMaxY, size.height + 46)
|
||||||
|
const candidates = xs.flatMap(x => ys.map(y => ({ x, y }))).sort((a, b) =>
|
||||||
|
Math.hypot(a.x - preferred.x, a.y - preferred.y) - Math.hypot(b.x - preferred.x, b.y - preferred.y))
|
||||||
|
return candidates.find(candidate => !overlaps(candidate.x, candidate.y)) || preferred
|
||||||
|
}
|
||||||
|
|
||||||
export function panViewport(origin: Viewport, screenDelta: { x: number; y: number }): Viewport {
|
export function panViewport(origin: Viewport, screenDelta: { x: number; y: number }): Viewport {
|
||||||
return { ...origin, x: origin.x + screenDelta.x, y: origin.y + screenDelta.y }
|
return { ...origin, x: origin.x + screenDelta.x, y: origin.y + screenDelta.y }
|
||||||
}
|
}
|
||||||
@@ -250,6 +317,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
|||||||
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
|
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
|
||||||
exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit,
|
exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit,
|
||||||
...(exhibit.type === 'document' ? { captureKind: documentCaptureKind(exhibit.captureKind) } : {}),
|
...(exhibit.type === 'document' ? { captureKind: documentCaptureKind(exhibit.captureKind) } : {}),
|
||||||
|
...(exhibit.type === 'note' ? { presentation: notePresentation(exhibit.presentation) } : {}),
|
||||||
...placement(exhibit as unknown as Record<string, unknown>, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })),
|
...placement(exhibit as unknown as Record<string, unknown>, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })),
|
||||||
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
|
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
|
||||||
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [],
|
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [],
|
||||||
@@ -280,7 +348,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
|||||||
if (type === 'folder') return { ...common, type: 'folder', isOpen: (item.config as Record<string, unknown> | undefined)?.open === true }
|
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 === '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) : [] }
|
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' }
|
return { ...common, type: 'note', presentation:notePresentation(item.presentation) }
|
||||||
})
|
})
|
||||||
const derivedRelations: ExhibitRelation[] = [
|
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) })),
|
...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) })),
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export const exhibitWidgetRegistry: Record<ExhibitType, ExhibitWidgetDefinition>
|
|||||||
...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 }] : []),
|
...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 },
|
] : [],searchText:searchable,Component:DocumentWidget },
|
||||||
note: { modelKind:'exhibit',visualType:'note',shell:'card',defaultSize:{width:108,height:154},capabilities:standardCapabilities,
|
note: { modelKind:'exhibit',visualType:'note',shell:'card',defaultSize:{width:108,height:154},capabilities:standardCapabilities,
|
||||||
heading:() => 'INVESTIGATOR / NOTE',connectionPorts:notePorts,temporalFacts:() => [],searchText:searchable,Component:NoteWidget },
|
heading:exhibit => exhibit.type === 'note' && exhibit.presentation === 'lined_sheet' ? 'FIELD NOTE / TORN PAGE' : 'INVESTIGATOR / NOTE',connectionPorts:notePorts,temporalFacts:() => [],searchText:searchable,Component:NoteWidget },
|
||||||
event: { modelKind:'exhibit',visualType:'event',shell:'card',defaultSize:{width:270,height:174},capabilities:standardCapabilities,
|
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
|
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 },
|
? [{ id:`${exhibit.id}:occurred`,exhibitId:exhibit.id,kind:'occurred',start:exhibit.eventDate,label:exhibit.content }] : [],searchText:searchable,Component:EventWidget },
|
||||||
@@ -144,7 +144,7 @@ export const documentCaptureRegistry:Record<DocumentCaptureKind,DocumentCaptureD
|
|||||||
unclassified:{ label:'Not sure',description:'Keep the standard evidence card for now.',defaultSize:{width:174,height:145} },
|
unclassified:{ label:'Not sure',description:'Keep the standard evidence card for now.',defaultSize:{width:174,height:145} },
|
||||||
photo:{ label:'Mugshot',description:'A portrait or identifying photograph.',defaultSize:{width:188,height:250} },
|
photo:{ label:'Mugshot',description:'A portrait or identifying photograph.',defaultSize:{width:188,height:250} },
|
||||||
scene:{ label:'Image',description:'A place, situation, object, or event is shown.',defaultSize:{width:244,height:200} },
|
scene:{ label:'Image',description:'A place, situation, object, or event is shown.',defaultSize:{width:244,height:200} },
|
||||||
clipping:{ label:'Clip',description:'An extract captured from a larger source.',defaultSize:{width:210,height:194} },
|
clipping:{ label:'Clip',description:'An extract mounted on a physical evidence card.',defaultSize:{width:230,height:290} },
|
||||||
full_page:{ label:'Document',description:'A complete page or formal document view.',defaultSize:{width:205,height:294} },
|
full_page:{ label:'Document',description:'A complete page or formal document view.',defaultSize:{width:205,height:294} },
|
||||||
}
|
}
|
||||||
export function documentCapture(kind:DocumentCaptureKind) { return documentCaptureRegistry[kind] || documentCaptureRegistry.unclassified }
|
export function documentCapture(kind:DocumentCaptureKind) { return documentCaptureRegistry[kind] || documentCaptureRegistry.unclassified }
|
||||||
|
|||||||
+37
-8
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { buildPhone, CLOSED_ANGLE, PhonePreview } from './phone'
|
import { buildPhone, CLOSED_ANGLE, PhonePreview, type PhoneSession } from './phone'
|
||||||
|
|
||||||
// A reusable "tools" inventory: a three.js rack you cycle through, one 3D tool at a
|
// A reusable "tools" inventory: a three.js rack you cycle through, one 3D tool at a
|
||||||
// time, then USE to open it. Tool-driven (see TOOLS below), so it isn't tied to any
|
// time, then USE to open it. Tool-driven (see TOOLS below), so it isn't tied to any
|
||||||
@@ -12,7 +12,7 @@ function buildPhoneModel() {
|
|||||||
const { group, hinge } = buildPhone()
|
const { group, hinge } = buildPhone()
|
||||||
hinge.rotation.x = CLOSED_ANGLE // sit closed on the rack
|
hinge.rotation.x = CLOSED_ANGLE // sit closed on the rack
|
||||||
group.scale.setScalar(1.15)
|
group.scale.setScalar(1.15)
|
||||||
group.position.y = -0.05
|
group.position.y = 0.5 // the phone's mass hangs below its pivot — lift it to centre in frame
|
||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,13 +85,14 @@ function ToolRack({ index }: { index: number }) {
|
|||||||
return <div ref={stageRef} className="inv-stage" />
|
return <div ref={stageRef} className="inv-stage" />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Inventory({ onClose }: { onClose?: () => void }) {
|
export function Inventory({ onClose, session, onTearToBoard }: { onClose?: () => void; session?: PhoneSession; onTearToBoard?: (text: string) => void }) {
|
||||||
const [index, setIndex] = useState(0)
|
const [index, setIndex] = useState(0)
|
||||||
const [active, setActive] = useState<string | null>(null)
|
const [active, setActive] = useState<string | null>(null)
|
||||||
|
|
||||||
if (active) return <div className="inv-tool">
|
if (active) return <div className="inv-tool">
|
||||||
<button className="inv-back" onClick={() => setActive(null)}>‹ TOOLS</button>
|
<button className="inv-back" onClick={() => setActive(null)}>‹ TOOLS</button>
|
||||||
{active === 'phone' ? <PhonePreview /> : <NotebookTool />}
|
{onClose && <button className="inv-exit" onClick={onClose}>EXIT ✕</button>}
|
||||||
|
{active === 'phone' ? <PhonePreview session={session} /> : <NotebookTool playthroughId={session?.playthroughId} onTear={onTearToBoard} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
return <div className="inv-backdrop">
|
return <div className="inv-backdrop">
|
||||||
@@ -108,13 +109,41 @@ export function Inventory({ onClose }: { onClose?: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placeholder notebook tool — a lined page you can scribble on (not yet persisted).
|
// Field notebook: the lines captured from NPCs during play. A page can be torn off
|
||||||
function NotebookTool() {
|
// onto the board as a note (when a board tear handler is available).
|
||||||
const [text, setText] = useState('')
|
function NotebookTool({ playthroughId, onTear }: { playthroughId?: string; onTear?: (text: string) => void }) {
|
||||||
|
const [pages, setPages] = useState<{ id: string; text: string }[]>([])
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playthroughId) return
|
||||||
|
fetch(`/api/playthroughs/${playthroughId}/notebook`).then(r => r.ok ? r.json() : []).then(setPages).catch(() => {})
|
||||||
|
}, [playthroughId])
|
||||||
|
const remove = async (id: string) => {
|
||||||
|
if (playthroughId) await fetch(`/api/playthroughs/${playthroughId}/notebook/${id}`, { method: 'DELETE' })
|
||||||
|
setPages(list => list.filter(page => page.id !== id))
|
||||||
|
}
|
||||||
|
const tear = (page: { id: string; text: string }) => { onTear?.(page.text); void remove(page.id) }
|
||||||
|
const add = async () => {
|
||||||
|
const text = draft.trim()
|
||||||
|
if (!text || !playthroughId) return
|
||||||
|
const res = await fetch(`/api/playthroughs/${playthroughId}/notebook`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) })
|
||||||
|
if (res.ok) { const page = await res.json(); setPages(list => [...list, { id: page.id, text: page.text }]); setDraft('') }
|
||||||
|
}
|
||||||
return <div className="notebook">
|
return <div className="notebook">
|
||||||
<div className="notebook-page">
|
<div className="notebook-page">
|
||||||
<div className="notebook-head">FIELD NOTEBOOK</div>
|
<div className="notebook-head">FIELD NOTEBOOK</div>
|
||||||
<textarea value={text} onChange={e => setText(e.target.value)} placeholder="Jot a lead…" spellCheck={false} />
|
{playthroughId && <div className="notebook-add">
|
||||||
|
<textarea value={draft} onChange={event => setDraft(event.target.value)} placeholder="Write your own note…" spellCheck={false} rows={2} />
|
||||||
|
<button disabled={!draft.trim()} onClick={add}>+ Add</button>
|
||||||
|
</div>}
|
||||||
|
{!pages.length && <p className="notebook-empty">Nothing noted yet. Write one above, or during a conversation use “✎ Note this”.</p>}
|
||||||
|
{pages.map(page => <div key={page.id} className="notebook-note">
|
||||||
|
<p>{page.text}</p>
|
||||||
|
<div className="notebook-note-actions">
|
||||||
|
{onTear && <button onClick={() => tear(page)} title="Tear off onto the board">✂ Tear to board</button>}
|
||||||
|
<button className="ghost" onClick={() => void remove(page.id)} title="Discard">×</button>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -81,7 +81,7 @@ export function CutsceneHost({ componentKey, label, onComplete }: { componentKey
|
|||||||
|
|
||||||
// Walk a dialogue node's utterance tree: play NPC lines, present player options at a
|
// 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.
|
// branch, follow a chosen option to the next line or out through its exit terminal.
|
||||||
export function DialoguePlayer({ node, onExit, onAward, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; onAward?: (utteranceId: string) => void; inline?: boolean; startId?: string | null }) {
|
export function DialoguePlayer({ node, onExit, onAward, onCapture, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; onAward?: (utteranceId: string) => void; onCapture?: (text: string, utteranceId: string) => void; inline?: boolean; startId?: string | null }) {
|
||||||
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
|
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
|
||||||
const [currentId, setCurrentId] = useState<string | null>(startId ?? node.rootId)
|
const [currentId, setCurrentId] = useState<string | null>(startId ?? node.rootId)
|
||||||
const onAwardRef = useRef(onAward)
|
const onAwardRef = useRef(onAward)
|
||||||
@@ -149,7 +149,9 @@ export function DialoguePlayer({ node, onExit, onAward, inline, startId }: { nod
|
|||||||
<div className="dialogue-scrim" aria-hidden />
|
<div className="dialogue-scrim" aria-hidden />
|
||||||
<div className="dialogue-box">
|
<div className="dialogue-box">
|
||||||
<div className="dialogue-panel">
|
<div className="dialogue-panel">
|
||||||
<div className="dialogue-speaker"><strong>{current.speaker.name}</strong>{current.speaker.role && <em>{current.speaker.role}</em>}</div>
|
<div className="dialogue-speaker"><strong>{current.speaker.name}</strong>{current.speaker.role && <em>{current.speaker.role}</em>}
|
||||||
|
{onCapture && !inline && current.utterer === 'npc' && done && <button className="dialogue-capture" title="Copy to notebook" onClick={event => { event.stopPropagation(); onCapture(current.text, current.id) }}>✎ Note this</button>}
|
||||||
|
</div>
|
||||||
<p className="dialogue-text">{fullText.slice(0, charCount)}<span className="dialogue-caret" aria-hidden>{done ? '' : '▍'}</span></p>
|
<p className="dialogue-text">{fullText.slice(0, charCount)}<span className="dialogue-caret" aria-hidden>{done ? '' : '▍'}</span></p>
|
||||||
{showChoices
|
{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-choices">{options.map(option => <button key={option.id} onClick={event => { event.stopPropagation(); pick(option) }}>{option.text || '(choice)'}</button>)}</div>
|
||||||
|
|||||||
+34
-13
@@ -264,16 +264,22 @@ function PhoneScreen({ visible, mode, dialed, callee }: { visible: boolean; mode
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PhonePreview() {
|
// In-game the phone runs against a live playthrough: it dials the connected phone
|
||||||
const [open, setOpen] = useState(false)
|
// node's directory and, on connect, hands back to the campaign runtime.
|
||||||
|
export type PhoneSession = { playthroughId: string; onConnect: () => void }
|
||||||
|
|
||||||
|
export function PhonePreview({ session }: { session?: PhoneSession } = {}) {
|
||||||
|
const [open, setOpen] = useState(Boolean(session)) // in-game the handset opens on its own
|
||||||
const [screenOn, setScreenOn] = useState(false)
|
const [screenOn, setScreenOn] = useState(false)
|
||||||
const [mode, setMode] = useState<Mode>('home')
|
const [mode, setMode] = useState<Mode>('home')
|
||||||
const [dialed, setDialed] = useState('')
|
const [dialed, setDialed] = useState('')
|
||||||
const [callee, setCallee] = useState('')
|
const [callee, setCallee] = useState('')
|
||||||
const [playthroughId, setPlaythroughId] = useState<string | null>(null)
|
const [playthroughId, setPlaythroughId] = useState<string | null>(session?.playthroughId ?? null)
|
||||||
const [achieved, setAchieved] = useState<Set<string>>(new Set())
|
const [achieved, setAchieved] = useState<Set<string>>(new Set())
|
||||||
|
const [directory, setDirectory] = useState<{ number: string; name: string }[]>([])
|
||||||
const [called, setCalled] = useState<Set<string>>(new Set())
|
const [called, setCalled] = useState<Set<string>>(new Set())
|
||||||
const callTimer = useRef<number | undefined>(undefined)
|
const callTimer = useRef<number | undefined>(undefined)
|
||||||
|
const digits = (value: string) => value.replace(/\D/g, '')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) { const t = setTimeout(() => setScreenOn(true), 560); return () => clearTimeout(t) }
|
if (open) { const t = setTimeout(() => setScreenOn(true), 560); return () => clearTimeout(t) }
|
||||||
@@ -284,10 +290,15 @@ export function PhonePreview() {
|
|||||||
const res = await fetch(`/api/playthroughs/${id}/achievements`)
|
const res = await fetch(`/api/playthroughs/${id}/achievements`)
|
||||||
if (res.ok) setAchieved(new Set(await res.json() as string[]))
|
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.
|
// Game session -> load the connected directory. Spike -> a demo playthrough + case-state.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
|
if (session) {
|
||||||
|
const res = await fetch(`/api/playthroughs/${session.playthroughId}/phone`)
|
||||||
|
if (!cancelled && res.ok) setDirectory((await res.json()).numbers || [])
|
||||||
|
return
|
||||||
|
}
|
||||||
let id: string | null = null
|
let id: string | null = null
|
||||||
const cur = await fetch('/api/playthroughs/current')
|
const cur = await fetch('/api/playthroughs/current')
|
||||||
if (cur.ok && cur.status !== 204) id = (await cur.json())?.playthrough?.id ?? null
|
if (cur.ok && cur.status !== 204) id = (await cur.json())?.playthrough?.id ?? null
|
||||||
@@ -300,11 +311,11 @@ export function PhonePreview() {
|
|||||||
await refreshAchievements(id)
|
await refreshAchievements(id)
|
||||||
})()
|
})()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [])
|
}, [session])
|
||||||
|
|
||||||
const connectable = (num: string) => { const c = DIRECTORY[num]; return c ? c.requires.every(f => achieved.has(f)) : false }
|
const connectable = (num: string) => session ? directory.some(d => digits(d.number) === num) : (DIRECTORY[num] ? DIRECTORY[num].requires.every(f => achieved.has(f)) : false)
|
||||||
// Glow the handset when an enabled, not-yet-called number is waiting.
|
// Glow the handset when a callable, not-yet-called number is waiting.
|
||||||
const glow = Object.keys(DIRECTORY).some(num => connectable(num) && !called.has(num))
|
const glow = session ? directory.some(d => !called.has(digits(d.number))) : Object.keys(DIRECTORY).some(num => connectable(num) && !called.has(num))
|
||||||
|
|
||||||
const grantElias = async () => {
|
const grantElias = async () => {
|
||||||
if (!playthroughId) return
|
if (!playthroughId) return
|
||||||
@@ -315,12 +326,20 @@ export function PhonePreview() {
|
|||||||
const resolveCall = (num: string) => {
|
const resolveCall = (num: string) => {
|
||||||
sfx.ring()
|
sfx.ring()
|
||||||
setMode('calling')
|
setMode('calling')
|
||||||
const contact = DIRECTORY[num]
|
|
||||||
window.clearTimeout(callTimer.current)
|
window.clearTimeout(callTimer.current)
|
||||||
callTimer.current = window.setTimeout(() => {
|
callTimer.current = window.setTimeout(async () => {
|
||||||
|
setCalled(prev => new Set(prev).add(num))
|
||||||
|
if (session) {
|
||||||
|
const res = await fetch(`/api/playthroughs/${session.playthroughId}/dial`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ number: num }) })
|
||||||
|
const result = res.ok ? await res.json() : { outcome: 'unknown' }
|
||||||
|
if (result.outcome === 'connect') { setCallee(result.name || ''); setMode('connected'); window.setTimeout(() => session.onConnect(), 950) }
|
||||||
|
else if (result.outcome === 'voicemail') { setCallee(result.name || ''); sfx.voicemail(); setMode('voicemail') }
|
||||||
|
else { sfx.unobtainable(); setMode('unknown') }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const contact = DIRECTORY[num]
|
||||||
if (!contact) { sfx.unobtainable(); setMode('unknown'); return }
|
if (!contact) { sfx.unobtainable(); setMode('unknown'); return }
|
||||||
setCallee(contact.name)
|
setCallee(contact.name)
|
||||||
setCalled(prev => new Set(prev).add(num))
|
|
||||||
if (connectable(num)) setMode('connected')
|
if (connectable(num)) setMode('connected')
|
||||||
else { sfx.voicemail(); setMode('voicemail') }
|
else { sfx.voicemail(); setMode('voicemail') }
|
||||||
}, 950)
|
}, 950)
|
||||||
@@ -355,11 +374,13 @@ export function PhonePreview() {
|
|||||||
<PhoneScreen visible={screenOn} mode={mode} dialed={dialed} callee={callee} />
|
<PhoneScreen visible={screenOn} mode={mode} dialed={dialed} callee={callee} />
|
||||||
</div>
|
</div>
|
||||||
<button className={`phone-open-btn${glow && !open ? ' glow' : ''}`} onClick={() => setOpen(o => !o)}>{open ? 'CLOSE' : 'OPEN'}</button>
|
<button className={`phone-open-btn${glow && !open ? ' glow' : ''}`} onClick={() => setOpen(o => !o)}>{open ? 'CLOSE' : 'OPEN'}</button>
|
||||||
<div className="phone-dev">
|
{session
|
||||||
|
? <p className="phone-hint">Key in a number, then press Call.</p>
|
||||||
|
: <div className="phone-dev">
|
||||||
<button className="phone-dev-btn" disabled={!playthroughId || achieved.has('elias_number_callable')} onClick={grantElias}>
|
<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'}
|
{achieved.has('elias_number_callable') ? '✓ elias_number_callable' : '▸ grant elias_number_callable'}
|
||||||
</button>
|
</button>
|
||||||
<p className="phone-hint">dial <code>55501</code> Elias (voicemail → connect once granted) · <code>55502</code> Voss (voicemail) · else unobtainable</p>
|
<p className="phone-hint">dial <code>55501</code> Elias (voicemail → connect once granted) · <code>55502</code> Voss (voicemail) · else unobtainable</p>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-9
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import { CutsceneHost, DialoguePlayer, MeritHost, type PlaythroughState } from './narrative'
|
import { CutsceneHost, DialoguePlayer, MeritHost, type PlaythroughState } from './narrative'
|
||||||
|
|
||||||
type MysterySummary = { slug: string; title: string }
|
type MysterySummary = { slug: string; title: string }
|
||||||
|
type Player = { id: string; handle: string; displayName: string; avatarUrl: string | null }
|
||||||
|
|
||||||
// The game's front door and campaign runtime. Shows the splash when there's no
|
// The game's front door and campaign runtime. Shows the splash when there's no
|
||||||
// active playthrough; otherwise walks the story graph — cutscene and dialogue
|
// active playthrough; otherwise walks the story graph — cutscene and dialogue
|
||||||
@@ -14,6 +15,15 @@ export function Play() {
|
|||||||
const [splash, setSplash] = useState(false)
|
const [splash, setSplash] = useState(false)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [status, setStatus] = useState('')
|
const [status, setStatus] = useState('')
|
||||||
|
const [player, setPlayer] = useState<Player | null>(null)
|
||||||
|
const [authChecked, setAuthChecked] = useState(false)
|
||||||
|
|
||||||
|
const openFrontDoor = useCallback(async () => {
|
||||||
|
const [cases, current] = await Promise.all([fetch('/api/mysteries'), fetch('/api/playthroughs/current')])
|
||||||
|
if (cases.ok) setMysteries(await cases.json())
|
||||||
|
if (current.ok && current.status !== 204) setResumable(await current.json())
|
||||||
|
setSplash(true); setStatus('SELECT A CASE FILE')
|
||||||
|
}, [])
|
||||||
|
|
||||||
const apply = useCallback((next: PlaythroughState | null) => {
|
const apply = useCallback((next: PlaythroughState | null) => {
|
||||||
if (!next) { setSplash(true); return }
|
if (!next) { setSplash(true); return }
|
||||||
@@ -41,17 +51,26 @@ export function Play() {
|
|||||||
if (!cancelled) res.ok ? apply(await res.json()) : setStatus('NODE NOT FOUND')
|
if (!cancelled) res.ok ? apply(await res.json()) : setStatus('NODE NOT FOUND')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The bare root is always the front door: list the playable cases, and offer
|
// ?resume=1 (e.g. handed back from the in-board phone) drops straight into the
|
||||||
// Resume on the one already in progress rather than auto-resuming into it.
|
// current node instead of the splash.
|
||||||
const [cases, current] = await Promise.all([fetch('/api/mysteries'), fetch('/api/playthroughs/current')])
|
if (new URLSearchParams(window.location.search).has('resume')) {
|
||||||
|
const res = await fetch('/api/playthroughs/current')
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (cases.ok) setMysteries(await cases.json())
|
if (res.ok && res.status !== 204) { apply(await res.json()); return }
|
||||||
if (current.ok && current.status !== 204) setResumable(await current.json())
|
}
|
||||||
setSplash(true)
|
// The bare root is the front door — but you must be signed in first.
|
||||||
setStatus('SELECT A CASE FILE')
|
const me = await fetch('/api/auth/me')
|
||||||
|
if (cancelled) return
|
||||||
|
setAuthChecked(true)
|
||||||
|
if (me.status !== 200) return // not signed in -> the enrolment / sign-in screen
|
||||||
|
setPlayer((await me.json()).user)
|
||||||
|
await openFrontDoor()
|
||||||
})()
|
})()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [apply])
|
}, [apply, openFrontDoor])
|
||||||
|
|
||||||
|
const onAuthed = (user: Player) => { setPlayer(user); void openFrontDoor() }
|
||||||
|
const signOut = async () => { await fetch('/api/auth/logout', { method: 'POST' }); window.location.assign('/') }
|
||||||
|
|
||||||
const newGame = async (slug: string) => {
|
const newGame = async (slug: string) => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
@@ -70,10 +89,14 @@ export function Play() {
|
|||||||
if (res.ok) apply(await res.json())
|
if (res.ok) apply(await res.json())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Front door requires a signed-in investigator (path A: GUPI-issued account).
|
||||||
|
if (!splash && !state && authChecked && !player) return <AuthScreen onAuthed={onAuthed} />
|
||||||
|
|
||||||
if (splash) return <div className="splash">
|
if (splash) return <div className="splash">
|
||||||
<div className="splash-plate">
|
<div className="splash-plate">
|
||||||
<div className="seal">GU</div>
|
<div className="seal">GU</div>
|
||||||
<h1 className="splash-title">PRINCIPAL INVESTIGATOR</h1>
|
<h1 className="splash-title">PRINCIPAL INVESTIGATOR</h1>
|
||||||
|
{player && <p className="splash-investigator"><span className="splash-avatar" title="Avatar coming soon">{player.displayName.slice(0, 1).toUpperCase()}</span>{player.displayName}<button className="splash-signout" onClick={signOut}>sign out</button></p>}
|
||||||
<p className="splash-sub">Glitch University · Case Files</p>
|
<p className="splash-sub">Glitch University · Case Files</p>
|
||||||
<div className="splash-cases">
|
<div className="splash-cases">
|
||||||
{mysteries.map(mystery => {
|
{mysteries.map(mystery => {
|
||||||
@@ -96,6 +119,50 @@ export function Play() {
|
|||||||
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} onComplete={() => { void advance() }} />
|
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} onComplete={() => { void advance() }} />
|
||||||
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }}
|
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }}
|
||||||
onExit={terminalKey => { void advance(terminalKey) }}
|
onExit={terminalKey => { void advance(terminalKey) }}
|
||||||
onAward={utteranceId => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/utterances/${utteranceId}/reach`, { method: 'POST' }) }} />
|
onAward={utteranceId => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/utterances/${utteranceId}/reach`, { method: 'POST' }) }}
|
||||||
|
onCapture={(text, utteranceId) => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/notebook`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, utteranceId }) }) }} />
|
||||||
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
|
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rudimentary character screen: enrol (display name + handle + password) or sign in.
|
||||||
|
// The avatar is a placeholder (initial) for now; extend later.
|
||||||
|
function AuthScreen({ onAuthed }: { onAuthed: (user: Player) => void }) {
|
||||||
|
const [mode, setMode] = useState<'register' | 'login'>('register')
|
||||||
|
const [handle, setHandle] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [displayName, setDisplayName] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setBusy(true); setError('')
|
||||||
|
try {
|
||||||
|
const url = mode === 'register' ? '/api/auth/register' : '/api/auth/login'
|
||||||
|
const body = mode === 'register' ? { handle, password, displayName } : { handle, password }
|
||||||
|
const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) { setError(data.error || 'Something went wrong'); return }
|
||||||
|
onAuthed(data.user)
|
||||||
|
} finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="splash">
|
||||||
|
<div className="splash-plate auth-plate">
|
||||||
|
<div className="seal">GU</div>
|
||||||
|
<h1 className="splash-title">PRINCIPAL INVESTIGATOR</h1>
|
||||||
|
<p className="splash-sub">Glitch University · {mode === 'register' ? 'Enrolment' : 'Sign in'}</p>
|
||||||
|
<div className="auth-tabs">
|
||||||
|
<button className={mode === 'register' ? 'on' : ''} onClick={() => setMode('register')}>New investigator</button>
|
||||||
|
<button className={mode === 'login' ? 'on' : ''} onClick={() => setMode('login')}>Sign in</button>
|
||||||
|
</div>
|
||||||
|
{mode === 'register' && <div className="auth-avatar" title="Avatar coming soon">{(displayName.trim() || '?').slice(0, 1).toUpperCase()}</div>}
|
||||||
|
<div className="auth-fields">
|
||||||
|
{mode === 'register' && <label>Display name<input value={displayName} onChange={event => setDisplayName(event.target.value)} placeholder="Investigator name" onKeyDown={e => e.key === 'Enter' && submit()} /></label>}
|
||||||
|
<label>Handle<input value={handle} onChange={event => setHandle(event.target.value.toLowerCase())} placeholder="handle" autoCapitalize="off" autoCorrect="off" spellCheck={false} onKeyDown={e => e.key === 'Enter' && submit()} /></label>
|
||||||
|
<label>Password<input type="password" value={password} onChange={event => setPassword(event.target.value)} onKeyDown={e => e.key === 'Enter' && submit()} /></label>
|
||||||
|
</div>
|
||||||
|
{error && <p className="auth-error">{error}</p>}
|
||||||
|
<button className="splash-button primary" disabled={busy} onClick={submit}>{busy ? 'PLEASE WAIT…' : mode === 'register' ? 'ENROL ▸' : 'SIGN IN ▸'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|||||||
+81
-23
@@ -166,7 +166,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.text-document-preview textarea::placeholder { color: #76786f; font-size: 9px; letter-spacing: .04em; }
|
.text-document-preview textarea::placeholder { color: #76786f; font-size: 9px; letter-spacing: .04em; }
|
||||||
.board-viewport.threading .text-document-preview textarea { pointer-events: none; }
|
.board-viewport.threading .text-document-preview textarea { pointer-events: none; }
|
||||||
.source-file-widget > strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }
|
.source-file-widget > strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }
|
||||||
.source-file-widget > time { display: block; margin-top: 3px; color: #86603b; font: 7px IBM Plex Mono; }
|
.source-file-footer > time { display: block; margin-top: 3px; color: #86603b; font: 7px IBM Plex Mono; }
|
||||||
.source-file-actions { display: flex; justify-content: space-between; margin-top: 6px; border-top: 1px dashed #969b94; padding-top: 4px; }
|
.source-file-actions { display: flex; justify-content: space-between; margin-top: 6px; border-top: 1px dashed #969b94; padding-top: 4px; }
|
||||||
.source-file-actions button { display: flex; align-items: center; gap: 3px; border: 0; background: transparent; padding: 2px; color: #4e5d57; cursor: pointer; font: 600 6px IBM Plex Mono; }
|
.source-file-actions button { display: flex; align-items: center; gap: 3px; border: 0; background: transparent; padding: 2px; color: #4e5d57; cursor: pointer; font: 600 6px IBM Plex Mono; }
|
||||||
|
|
||||||
@@ -214,32 +214,39 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.mugshot-caption span { position: relative; display: block; min-height: 1.1em; overflow: hidden; color: #171b18; text-overflow: clip; white-space: nowrap; letter-spacing: .01em; }
|
.mugshot-caption span { position: relative; display: block; min-height: 1.1em; overflow: hidden; color: #171b18; text-overflow: clip; white-space: nowrap; letter-spacing: .01em; }
|
||||||
.mugshot-caption.writing span::after { content: ''; display: inline-block; width: 4px; height: 3px; margin-left: 1px; border-radius: 50%; background: #171b18; box-shadow: 0 0 2px #171b18; transform: rotate(-18deg); animation: sharpie-nib .12s steps(2,end) infinite; }
|
.mugshot-caption.writing span::after { content: ''; display: inline-block; width: 4px; height: 3px; margin-left: 1px; border-radius: 50%; background: #171b18; box-shadow: 0 0 2px #171b18; transform: rotate(-18deg); animation: sharpie-nib .12s steps(2,end) infinite; }
|
||||||
@keyframes sharpie-nib { 50% { transform: translateY(-2px) rotate(-18deg);opacity:.72; } }
|
@keyframes sharpie-nib { 50% { transform: translateY(-2px) rotate(-18deg);opacity:.72; } }
|
||||||
.source-file-widget.capture-kind-photo > time { padding-right: 3px; text-align: right; }
|
.source-file-widget.capture-kind-photo .source-file-footer > time { padding-right: 3px; text-align: right; }
|
||||||
.source-file-widget.capture-kind-scene { padding: 9px 9px 10px; background: #e5e0d3; }
|
.source-file-widget.capture-kind-scene { padding: 9px 9px 10px; background: #e5e0d3; }
|
||||||
.source-file-widget.capture-kind-scene.open { transform: scale(1) rotate(.35deg); }
|
.source-file-widget.capture-kind-scene.open { transform: scale(1) rotate(.35deg); }
|
||||||
.source-file-widget.capture-kind-scene .source-file-preview { height: 120px; }
|
.source-file-widget.capture-kind-scene .source-file-preview { height: 120px; }
|
||||||
.source-file-widget.capture-kind-scene .source-file-preview img { object-fit: cover; filter: saturate(.86) contrast(1.05); }
|
.source-file-widget.capture-kind-scene .source-file-preview img { object-fit: cover; filter: saturate(.86) contrast(1.05); }
|
||||||
.source-file-widget.capture-kind-scene > strong { font-size: 11px; }
|
.source-file-widget.capture-kind-scene > strong { font-size: 11px; }
|
||||||
.source-file-widget.capture-kind-clipping { padding: 9px 12px 11px; background: linear-gradient(103deg,#e7e2d3,#d9d2bf); clip-path: polygon(1% 2%,99% 0,98% 13%,100% 28%,98% 43%,100% 61%,98% 78%,99% 98%,84% 99%,68% 97%,52% 100%,35% 98%,19% 100%,1% 98%,2% 79%,0 62%,2% 45%,0 27%); }
|
.source-file-widget.capture-kind-clipping { padding:14px 13px 10px;overflow:visible;background:linear-gradient(105deg,#e2dfcf,#d3d1c2);border:2px solid #b99a68;box-shadow:8px 10px 0 #020b0980,0 0 0 2px #526059; }
|
||||||
.source-file-widget.capture-kind-clipping.open { transform: scale(1) rotate(.8deg); }
|
.clip-stamped-pin { position:absolute;z-index:6;top:-7px;left:50%;width:24px;height:24px;translate:-50% 0;rotate:187deg;border:1px solid #5d5e59;border-radius:50%;background:conic-gradient(from 205deg,#73756f,#e5e4da 17%,#8c8e87 34%,#f5f2e5 49%,#74766f 67%,#c5c5bc 84%,#73756f);box-shadow:0 -3px 3px #0008,inset 1px 1px 1px #fff9,inset -2px -2px 2px #36393466;pointer-events:none; }
|
||||||
.source-file-widget.capture-kind-clipping .source-file-preview { height: 105px; border-color: #b2a996; box-shadow: none; }
|
.clip-stamped-pin::before { content:'';position:absolute;left:7px;top:5px;width:10px;height:12px;background:#d9d6c7;clip-path:polygon(50% 100%,0 0,100% 0);filter:drop-shadow(0 1px 1px #5b584d88); }
|
||||||
.source-file-widget.capture-kind-clipping .source-file-preview img { object-fit: cover; filter: grayscale(.12) contrast(1.08); }
|
.source-file-widget.capture-kind-clipping.open { transform:scale(1); }
|
||||||
.source-file-widget.capture-kind-clipping > strong { font-size: 11px; }
|
.source-file-widget.capture-kind-clipping .source-file-preview { box-sizing:border-box;width:100%;height:210px;margin:3px 0 8px;border:2px solid #f4efe1;background:#c8c2b3;box-shadow:2px 3px 3px #10151170;transform:rotate(var(--clip-inset-rotation,.7deg));transform-origin:50% 48%; }
|
||||||
|
.source-file-widget.capture-kind-clipping .source-file-preview img { object-fit:contain;filter:grayscale(.12) contrast(1.08); }
|
||||||
|
.source-file-widget.capture-kind-clipping .source-file-footer { display:grid;grid-template-columns:auto minmax(0,1fr);align-items:end;gap:3px 8px; }
|
||||||
|
.source-file-widget.capture-kind-clipping .clip-provenance { grid-column:1/-1;justify-self:start;padding:2px 4px;border:1px solid #99554d;color:#873e37;background:#dcc7ba99;font:600 5px IBM Plex Mono;letter-spacing:.08em;transform:rotate(-.7deg);opacity:.82; }
|
||||||
|
.source-file-widget.capture-kind-clipping .clip-provenance.complete { border-color:#557565;color:#3d6652;background:#c9d5c999;transform:rotate(.45deg); }
|
||||||
|
.source-file-widget.capture-kind-clipping .source-file-footer > time { flex:0 0 auto;margin:0 0 2px;color:#775332; }
|
||||||
|
.source-file-widget.capture-kind-clipping .source-file-actions { flex:1;justify-content:flex-end;gap:9px;margin-top:0; }
|
||||||
|
.source-file-widget.capture-kind-clipping .source-file-actions button { color:#4e5d57;text-shadow:none; }
|
||||||
.source-file-widget.capture-kind-full_page { padding: 9px 10px 11px; background: #ebe7da; box-shadow: 5px 7px 3px #02090780, inset 0 0 22px #8e887244; }
|
.source-file-widget.capture-kind-full_page { padding: 9px 10px 11px; background: #ebe7da; box-shadow: 5px 7px 3px #02090780, inset 0 0 22px #8e887244; }
|
||||||
.source-file-widget.capture-kind-full_page.open { transform: scale(1) rotate(-.25deg); }
|
.source-file-widget.capture-kind-full_page.open { transform: scale(1) rotate(-.25deg); }
|
||||||
.source-file-widget.capture-kind-full_page .source-file-preview { height: 211px; background: #f5f2e9; border-color: #b3ae9f; box-shadow: none; }
|
.source-file-widget.capture-kind-full_page .source-file-preview { height: 211px; background: #f5f2e9; border-color: #b3ae9f; box-shadow: none; }
|
||||||
.source-file-widget.capture-kind-full_page .source-file-preview img { object-fit: contain; }
|
.source-file-widget.capture-kind-full_page .source-file-preview img { object-fit: contain; }
|
||||||
.source-file-widget.capture-kind-full_page > strong { font-size: 10px; }
|
.source-file-widget.capture-kind-full_page > strong { font-size: 10px; }
|
||||||
.evidence-card.note { width: 108px !important; height: 154px; min-height: 154px; padding: 27px 10px 11px; rotate: -2deg !important; z-index: 2; }
|
.evidence-card.note.luggage-tag { width: 108px !important; height: 154px; min-height: 154px; padding: 27px 10px 11px; rotate: -2deg !important; z-index: 2; }
|
||||||
.evidence-card.note header { position: absolute; left: 9px; right: 9px; top: 21px; padding-bottom: 3px; font-size: 6px; color: #5b472d; border-color: #7e6542; }
|
.evidence-card.note.luggage-tag header { position: absolute; left: 9px; right: 9px; top: 21px; padding-bottom: 3px; font-size: 6px; color: #5b472d; border-color: #7e6542; }
|
||||||
.evidence-card.note header i { display: none; }
|
.evidence-card.note.luggage-tag header i { display: none; }
|
||||||
.evidence-card.note .card-content { height: 108px; padding-top: 10px; overflow: hidden; transition: transform .22s ease; }
|
.evidence-card.note.luggage-tag .card-content { height: 108px; padding-top: 10px; overflow: hidden; transition: transform .22s ease; }
|
||||||
.evidence-card.note h3 { color: #5b3c24; font-size: 7px; margin: 2px 0 5px; }
|
.evidence-card.note.luggage-tag h3 { color: #5b3c24; font-size: 7px; margin: 2px 0 5px; }
|
||||||
.evidence-card.note p { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 4; -webkit-box-orient: vertical; font-family: Special Elite; font-size: 12px; line-height: 1.25; }
|
.evidence-card.note.luggage-tag p { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 4; -webkit-box-orient: vertical; font-family: Special Elite; font-size: 12px; line-height: 1.25; }
|
||||||
.evidence-card.note.selected { z-index: 12; outline: 1px dashed #e6b168; outline-offset: 5px; transform: rotate(90deg) scale(1.55); filter: drop-shadow(12px 8px 5px #0008); }
|
.evidence-card.note.luggage-tag.selected { z-index: 12; outline: 1px dashed #e6b168; outline-offset: 5px; transform: rotate(90deg) scale(1.55); filter: drop-shadow(12px 8px 5px #0008); }
|
||||||
.evidence-card.note.selected .card-content { width: 142px; height: 94px; margin: 4px 0 0 -17px; overflow: visible; transform: rotate(-90deg); }
|
.evidence-card.note.luggage-tag.selected .card-content { width: 142px; height: 94px; margin: 4px 0 0 -17px; overflow: visible; transform: rotate(-90deg); }
|
||||||
.evidence-card.note.selected p { display: block; overflow: visible; font-size: 13px; line-height: 1.32; }
|
.evidence-card.note.luggage-tag.selected p { display: block; overflow: visible; font-size: 13px; line-height: 1.32; }
|
||||||
.evidence-card.note.selected h3 { font-size: 7px; }
|
.evidence-card.note.luggage-tag.selected h3 { font-size: 7px; }
|
||||||
.board-actions { position: absolute; z-index: 4; bottom: 17px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; height: 43px; background: #102a24ee; border: 1px solid #3c564e; box-shadow: 0 8px 24px #0009; padding: 4px; }
|
.board-actions { position: absolute; z-index: 4; bottom: 17px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; height: 43px; background: #102a24ee; border: 1px solid #3c564e; box-shadow: 0 8px 24px #0009; padding: 4px; }
|
||||||
.board-actions button { height: 33px; border: 0; background: transparent; padding: 0 10px; display: flex; align-items: center; gap: 7px; font: 9px IBM Plex Mono; cursor: pointer; color: #a8b8b2; }
|
.board-actions button { height: 33px; border: 0; background: transparent; padding: 0 10px; display: flex; align-items: center; gap: 7px; font: 9px IBM Plex Mono; cursor: pointer; color: #a8b8b2; }
|
||||||
.board-actions button:hover, .board-actions button.active { background: #27443c; color: #e4a35e; }
|
.board-actions button:hover, .board-actions button.active { background: #27443c; color: #e4a35e; }
|
||||||
@@ -351,6 +358,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.file-editor-body { padding: 24px 27px 22px; }
|
.file-editor-body { padding: 24px 27px 22px; }
|
||||||
.file-editor-body > small { color: #805027; font: 600 8px IBM Plex Mono; letter-spacing: .16em; }
|
.file-editor-body > small { color: #805027; font: 600 8px IBM Plex Mono; letter-spacing: .16em; }
|
||||||
.file-editor-grid { display: grid; grid-template-columns: 1fr 180px; gap: 12px; }
|
.file-editor-grid { display: grid; grid-template-columns: 1fr 180px; gap: 12px; }
|
||||||
|
.file-editor-grid.published-fields { grid-template-columns:minmax(0,1fr) 180px; }
|
||||||
.field > span { display: flex; align-items: center; gap: 6px; }
|
.field > span { display: flex; align-items: center; gap: 6px; }
|
||||||
.metadata-heading { margin-top: 20px; padding-bottom: 8px; border-bottom: 2px solid #59625d; display: flex; justify-content: space-between; align-items: end; }
|
.metadata-heading { margin-top: 20px; padding-bottom: 8px; border-bottom: 2px solid #59625d; display: flex; justify-content: space-between; align-items: end; }
|
||||||
.metadata-heading > div { display: grid; gap: 3px; }
|
.metadata-heading > div { display: grid; gap: 3px; }
|
||||||
@@ -380,8 +388,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.report-evidence { margin-top:14px; padding:14px 16px 16px; border-left:4px solid #8b5b38; background:#e1ddcfcc; box-shadow:0 1px #fff8; }.report-evidence.verified { border-left-color:#46705c; }.report-evidence.rejected { border-left-color:#913d35;background:#e5d2c7cc; }
|
.report-evidence { margin-top:14px; padding:14px 16px 16px; border-left:4px solid #8b5b38; background:#e1ddcfcc; box-shadow:0 1px #fff8; }.report-evidence.verified { border-left-color:#46705c; }.report-evidence.rejected { border-left-color:#913d35;background:#e5d2c7cc; }
|
||||||
.report-evidence-heading { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:10px; margin-bottom:12px; }.report-evidence-heading b { color:#25332d; font:14px Special Elite; }.report-evidence-heading span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#646c67; font:8px IBM Plex Mono; text-transform:uppercase; }.report-evidence-heading em { padding:3px 5px; border:1px solid #577665; color:#436753; font:600 6px IBM Plex Mono; letter-spacing:.08em; font-style:normal; }
|
.report-evidence-heading { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:10px; margin-bottom:12px; }.report-evidence-heading b { color:#25332d; font:14px Special Elite; }.report-evidence-heading span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#646c67; font:8px IBM Plex Mono; text-transform:uppercase; }.report-evidence-heading em { padding:3px 5px; border:1px solid #577665; color:#436753; font:600 6px IBM Plex Mono; letter-spacing:.08em; font-style:normal; }
|
||||||
.report-evidence-heading em.rejected { border-color:#98534b;color:#813a34; }.report-evidence-diagnostic { margin:-3px 0 13px;padding:9px 10px;border:1px solid #b78376;background:#ead8cc;color:#6f302d;font:9px/1.5 IBM Plex Mono; }
|
.report-evidence-heading em.rejected { border-color:#98534b;color:#813a34; }.report-evidence-diagnostic { margin:-3px 0 13px;padding:9px 10px;border:1px solid #b78376;background:#ead8cc;color:#6f302d;font:9px/1.5 IBM Plex Mono; }
|
||||||
.report-evidence label,.report-investigator { display:grid; gap:4px; margin-top:9px; }.report-evidence label > span,.report-investigator > span { color:#5c655f; font:600 7px IBM Plex Mono; letter-spacing:.11em; }.report-evidence input,.report-evidence textarea,.report-investigator input { width:100%; min-width:0; padding:7px 8px; border:0; border-bottom:1px solid #747b75; outline:0; background:#f2eee0a8; color:#1d2622; font:12px/1.45 Special Elite; resize:vertical; }.report-evidence input:focus,.report-evidence textarea:focus,.report-investigator input:focus { border-bottom-color:#9b582d; background:#f6f0dc; box-shadow:inset 3px 0 #b2784a; }
|
.report-reference { display:grid; gap:4px; margin-top:9px; }.report-reference > span { color:#5c655f; font:600 7px IBM Plex Mono; letter-spacing:.11em; }.report-reference > p,.report-reference > a { width:100%; min-width:0; min-height:31px; margin:0; padding:7px 8px; border-bottom:1px solid #747b75; background:#f2eee0a8; color:#1d2622; font:12px/1.45 Special Elite; overflow-wrap:anywhere; }.report-reference > a { color:#315b52; text-decoration-thickness:1px; text-underline-offset:3px; }
|
||||||
.report-fields { display:grid; grid-template-columns:155px minmax(0,1fr); gap:13px; }.report-investigator { margin-top:30px; padding-top:13px; border-top:1px solid #4c554f; }.report-investigator input { max-width:390px; }
|
.report-fields { display:grid; grid-template-columns:155px minmax(0,1fr); gap:13px; }.report-investigator { margin-top:30px; padding-top:13px; border-top:1px solid #4c554f; }.report-investigator p { max-width:390px; }
|
||||||
.report-verdict { margin-top:28px; padding:17px 19px; border:2px solid #874339; background:#e0c7b9; transform:rotate(-.25deg); }.report-verdict small { color:#7c352f; font:700 8px IBM Plex Mono; letter-spacing:.13em; }.report-verdict p { margin:9px 0 0; font:15px/1.5 Special Elite; }.report-verdict.accepted { border-color:#47705c; background:#d2ddcf; }.report-verdict.accepted small { color:#315b48; }
|
.report-verdict { margin-top:28px; padding:17px 19px; border:2px solid #874339; background:#e0c7b9; transform:rotate(-.25deg); }.report-verdict small { color:#7c352f; font:700 8px IBM Plex Mono; letter-spacing:.13em; }.report-verdict p { margin:9px 0 0; font:15px/1.5 Special Elite; }.report-verdict.accepted { border-color:#47705c; background:#d2ddcf; }.report-verdict.accepted small { color:#315b48; }
|
||||||
.case-report-actions { position:sticky; bottom:-34px; display:flex; justify-content:flex-end; gap:9px; margin:30px -12px -20px; padding:14px 12px 20px; background:linear-gradient(transparent,#ebe7d8 25%); }.case-report-actions button { border:1px outset #8c948e; padding:10px 13px; color:#34443e; background:#c8cbc4; cursor:pointer; font:8px IBM Plex Mono; letter-spacing:.06em; }.case-report-actions .primary { float:none; color:#fff; background:#174337; }.case-report-actions button:disabled { opacity:.55;cursor:progress; }
|
.case-report-actions { position:sticky; bottom:-34px; display:flex; justify-content:flex-end; gap:9px; margin:30px -12px -20px; padding:14px 12px 20px; background:linear-gradient(transparent,#ebe7d8 25%); }.case-report-actions button { border:1px outset #8c948e; padding:10px 13px; color:#34443e; background:#c8cbc4; cursor:pointer; font:8px IBM Plex Mono; letter-spacing:.06em; }.case-report-actions .primary { float:none; color:#fff; background:#174337; }.case-report-actions button:disabled { opacity:.55;cursor:progress; }
|
||||||
.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; }
|
||||||
@@ -716,11 +724,11 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.audio-toggle.muted { color: #5f7b73; text-decoration: line-through; }
|
.audio-toggle.muted { color: #5f7b73; text-decoration: line-through; }
|
||||||
|
|
||||||
/* === 90s handset spike (src/phone.tsx) ============================ */
|
/* === 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;
|
.phone-backdrop { position: fixed; inset: 0; z-index: 500; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; gap: 12px; padding-top: 3vh;
|
||||||
background: radial-gradient(120% 90% at 50% 30%, #10201c 0%, #060b0a 70%, #020403 100%); }
|
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
|
/* 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. */
|
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-stage { position: relative; height: min(72vh, 600px); aspect-ratio: 1 / 2; max-width: 92vw; }
|
||||||
.phone-canvas { position: absolute; inset: 0; }
|
.phone-canvas { position: absolute; inset: 0; }
|
||||||
/* The interactive screen, positioned in % of the stage over the 3D glass. */
|
/* 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;
|
.phone-screen { position: absolute; box-sizing: border-box; padding: 4% 6%; overflow: hidden;
|
||||||
@@ -786,8 +794,58 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.inv-tool { position: fixed; inset: 0; z-index: 500; }
|
.inv-tool { position: fixed; inset: 0; z-index: 500; }
|
||||||
.inv-back { position: absolute; top: 16px; left: 18px; z-index: 520; border: 1px solid #6f8f85; background: #0a211de6; color: #cdea6a; font-family: ui-monospace, monospace; letter-spacing: 1px; padding: 6px 14px; cursor: pointer; }
|
.inv-back { position: absolute; top: 16px; left: 18px; z-index: 520; border: 1px solid #6f8f85; background: #0a211de6; color: #cdea6a; font-family: ui-monospace, monospace; letter-spacing: 1px; padding: 6px 14px; cursor: pointer; }
|
||||||
.inv-back:hover { border-color: #cdea6a; }
|
.inv-back:hover { border-color: #cdea6a; }
|
||||||
|
.inv-exit { position: absolute; top: 16px; right: 18px; z-index: 520; border: 1px solid #6f8f85; background: #0a211de6; color: #cfe8df; font-family: ui-monospace, monospace; letter-spacing: 1px; padding: 6px 14px; cursor: pointer; }
|
||||||
|
.inv-exit:hover { border-color: #e7b57e; color: #e7b57e; }
|
||||||
/* notebook tool (placeholder) */
|
/* notebook tool (placeholder) */
|
||||||
.notebook { position: fixed; inset: 0; z-index: 500; display: grid; place-items: center; background: radial-gradient(120% 90% at 50% 30%, #1c1a12 0%, #0a0906 75%); }
|
.notebook { position: fixed; inset: 0; z-index: 500; display: grid; place-items: center; background: radial-gradient(120% 90% at 50% 30%, #1c1a12 0%, #0a0906 75%); }
|
||||||
.notebook-page { width: min(560px, 88vw); height: min(72vh, 720px); background: repeating-linear-gradient(#f4ecd6 0 30px, #e3d8b8 30px 31px); box-shadow: 0 20px 60px #0009; padding: 26px 30px; display: flex; flex-direction: column; gap: 14px; }
|
.notebook-page { width: min(460px, 84vw); height: min(64vh, 600px); background: repeating-linear-gradient(#f4ecd6 0 30px, #e3d8b8 30px 31px); box-shadow: 0 20px 60px #0009; padding: 22px 26px; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; }
|
||||||
.notebook-head { font: 700 13px IBM Plex Mono, monospace; letter-spacing: 3px; color: #6a5a3a; }
|
.notebook-head { font: 700 13px IBM Plex Mono, monospace; letter-spacing: 3px; color: #6a5a3a; }
|
||||||
.notebook-page textarea { flex: 1; background: transparent; border: none; outline: none; resize: none; font: 16px/31px "Courier New", monospace; color: #3a3020; }
|
.notebook-empty { font: 22px/1.3 "Reenie Beanie", cursive; color: #8a7a52; }
|
||||||
|
.notebook-add { display: flex; gap: 8px; align-items: flex-end; border-bottom: 1px dashed #c8b98f; padding-bottom: 10px; }
|
||||||
|
.notebook-add textarea { flex: 1; background: transparent; border: none; outline: none; resize: none; font: 24px/26px "Reenie Beanie", cursive; color: #26356b; }
|
||||||
|
.notebook-add button { font: 11px IBM Plex Mono, monospace; letter-spacing: 1px; background: #e8dcbc; border: 1px solid #c0ad7f; color: #5a4a2a; padding: 4px 10px; cursor: pointer; white-space: nowrap; }
|
||||||
|
.notebook-add button:disabled { opacity: .45; cursor: default; }
|
||||||
|
.notebook-add button:not(:disabled):hover { border-color: #6a5a3a; }
|
||||||
|
.notebook-note { border-bottom: 1px dashed #c8b98f; padding-bottom: 8px; }
|
||||||
|
.notebook-note p { margin: 0 0 6px; font: 27px/30px "Reenie Beanie", cursive; color: #26356b; }
|
||||||
|
.notebook-note-actions { display: flex; gap: 8px; }
|
||||||
|
.notebook-note-actions button { font: 11px IBM Plex Mono, monospace; letter-spacing: 1px; background: #e8dcbc; border: 1px solid #c0ad7f; color: #5a4a2a; padding: 3px 9px; cursor: pointer; }
|
||||||
|
.notebook-note-actions button.ghost { background: transparent; border-color: transparent; color: #a08a5a; }
|
||||||
|
.notebook-note-actions button:hover { border-color: #6a5a3a; }
|
||||||
|
.dialogue-capture { margin-left: 12px; font: 10px IBM Plex Mono, monospace; letter-spacing: 1px; background: #0a211de0; border: 1px solid #6f8f85; color: #cdea6a; padding: 2px 8px; cursor: pointer; vertical-align: middle; }
|
||||||
|
.dialogue-capture:hover { border-color: #cdea6a; color: #eafaa0; }
|
||||||
|
/* Ordinary board notes retain the small luggage-tag treatment. */
|
||||||
|
.evidence-card.note.luggage-tag .card-content p { font-family: "Reenie Beanie", cursive; font-size: 19px; line-height: 1.05; color: #26356b; }
|
||||||
|
/* Notebook pages stay recognizably part of the notebook after being torn out. */
|
||||||
|
.evidence-card.note.lined-sheet { box-sizing:border-box; min-height:270px; padding:18px 20px 20px 43px; overflow:hidden; color:#26356b;
|
||||||
|
border:1px solid #e8dec5; background:
|
||||||
|
linear-gradient(90deg,transparent 0 29px,#c8787880 30px,#c8787880 31px,transparent 32px),
|
||||||
|
repeating-linear-gradient(180deg,#f4ecd6 0 26px,#b3c1c777 27px,#f4ecd6 28px);
|
||||||
|
clip-path:polygon(0 0,100% 0,100% 97.5%,97% 99%,93% 98%,89% 100%,84% 98.5%,79% 100%,74% 98%,68% 99.5%,62% 98%,56% 100%,50% 98.5%,44% 100%,38% 98%,32% 99.5%,26% 98%,20% 100%,14% 98.5%,8% 100%,3% 98%,0 99%);
|
||||||
|
box-shadow:8px 10px 5px #020b0980,0 0 0 1px #6c756f; }
|
||||||
|
.evidence-card.note.lined-sheet::before { content:'';position:absolute;left:8px;top:19px;bottom:23px;width:10px;background:radial-gradient(circle at 50% 8px,#34433e 0 3px,#d7ceb8 3.5px 5px,transparent 5.5px) 0 0/10px 34px repeat-y;opacity:.82; }
|
||||||
|
.evidence-card.note.lined-sheet::after { display:none; }
|
||||||
|
.evidence-card.note.lined-sheet header { height:20px;padding-bottom:4px;border-color:#9ba8aa;color:#6a706b;font-size:7px; }
|
||||||
|
.evidence-card.note.lined-sheet header i { display:none; }
|
||||||
|
.evidence-card.note.lined-sheet .card-content { height:214px;overflow:hidden; }
|
||||||
|
.evidence-card.note.lined-sheet h3 { margin:8px 0 5px;color:#805748;font:600 7px IBM Plex Mono;letter-spacing:.12em; }
|
||||||
|
.evidence-card.note.lined-sheet .card-content p { display:block;margin:0;overflow:hidden;color:#26356b;font:24px/28px "Reenie Beanie",cursive;white-space:pre-wrap; }
|
||||||
|
.evidence-card.note.lined-sheet.selected { outline:2px solid #e49a4a;outline-offset:5px;filter:drop-shadow(7px 9px 4px #0007); }
|
||||||
|
|
||||||
|
/* === Player auth + character screen (src/play.tsx) ================= */
|
||||||
|
.auth-plate { gap: 4px; }
|
||||||
|
.auth-tabs { display: flex; gap: 8px; margin: 22px 0 6px; }
|
||||||
|
.auth-tabs button { background: #0e2a24; border: 1px solid #3c5a52; color: #9bb0a9; font: 10px IBM Plex Mono, monospace; letter-spacing: .12em; padding: 7px 14px; cursor: pointer; }
|
||||||
|
.auth-tabs button.on { border-color: #d58a46; color: #f6ead9; }
|
||||||
|
.auth-avatar { width: 64px; height: 64px; margin: 12px 0 6px; display: grid; place-items: center; border: 2px dashed #6f8f85; border-radius: 50%; color: #cfe8df; font: 700 26px Special Elite, serif; background: #0a211d; }
|
||||||
|
.auth-fields { display: flex; flex-direction: column; gap: 12px; width: min(320px, 84vw); margin: 8px 0 4px; }
|
||||||
|
.auth-fields label { display: flex; flex-direction: column; gap: 5px; font: 9px IBM Plex Mono, monospace; letter-spacing: .18em; color: #7f9a92; text-transform: uppercase; }
|
||||||
|
.auth-fields input { background: #08201b; border: 1px solid #3c5a52; color: #e4e9e4; font: 14px IBM Plex Mono, monospace; padding: 9px 11px; outline: none; }
|
||||||
|
.auth-fields input:focus { border-color: #6f8f85; }
|
||||||
|
.auth-error { margin: 4px 0 0; color: #e88a4a; font: 11px IBM Plex Mono, monospace; }
|
||||||
|
.auth-plate .splash-button { margin-top: 14px; }
|
||||||
|
/* investigator identity on the case-file splash */
|
||||||
|
.splash-investigator { display: flex; align-items: center; gap: 10px; margin: 10px 0 0; color: #cfe8df; font: 13px IBM Plex Mono, monospace; letter-spacing: .1em; }
|
||||||
|
.splash-avatar { width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid #6f8f85; border-radius: 50%; font: 700 13px Special Elite, serif; color: #e7b57e; }
|
||||||
|
.splash-signout { margin-left: 6px; background: none; border: none; color: #6f8f85; font: 10px IBM Plex Mono, monospace; letter-spacing: .1em; cursor: pointer; text-decoration: underline; }
|
||||||
|
.splash-signout:hover { color: #d58a46; }
|
||||||
|
|||||||
+3
-1
@@ -67,9 +67,12 @@ export interface UploadedCaseDocument extends DocumentExhibit {
|
|||||||
analysis: DocumentUploadAnalysis
|
analysis: DocumentUploadAnalysis
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NotePresentation = 'luggage' | 'lined_sheet'
|
||||||
|
|
||||||
export interface NoteExhibit extends ExhibitBase {
|
export interface NoteExhibit extends ExhibitBase {
|
||||||
type: 'note'
|
type: 'note'
|
||||||
content: string
|
content: string
|
||||||
|
presentation: NotePresentation
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventExhibit extends ExhibitBase {
|
export interface EventExhibit extends ExhibitBase {
|
||||||
@@ -221,7 +224,6 @@ export interface CaseReport {
|
|||||||
|
|
||||||
export interface CaseReportSubmissionInput {
|
export interface CaseReportSubmissionInput {
|
||||||
investigatorName: string
|
investigatorName: string
|
||||||
evidence: Array<Pick<CaseReportEvidence, 'connectionId' | 'documentExhibitId' | 'relationText' | 'publishedAt' | 'sourceCitation' | 'sourceUri'>>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CaseState {
|
export interface CaseState {
|
||||||
|
|||||||
Reference in New Issue
Block a user