feat: author first playable mystery
This commit is contained in:
@@ -54,6 +54,8 @@ npm run test:e2e
|
|||||||
|
|
||||||
The smoke harness builds the application, creates a disposable PostgreSQL database and level, exercises move, hand-pan, board-only pinch zoom, folder expansion, file movement, and reload persistence, then removes the test database.
|
The smoke harness builds the application, creates a disposable PostgreSQL database and level, exercises move, hand-pan, board-only pinch zoom, folder expansion, file movement, and reload persistence, then removes the test database.
|
||||||
|
|
||||||
|
The browser suite also imports and solves the bundled Glass Harbor mystery against that disposable database. Mystery manifests are database-authoring content rather than compiled frontend cases; see [`mysteries/README.md`](mysteries/README.md).
|
||||||
|
|
||||||
## Data and API
|
## Data and API
|
||||||
|
|
||||||
The server stores its tables and migration ledger in the dedicated `osint` schema of PostgreSQL. `DATABASE_URL` is required.
|
The server stores its tables and migration ledger in the dedicated `osint` schema of PostgreSQL. `DATABASE_URL` is required.
|
||||||
|
|||||||
+5
-5
@@ -55,8 +55,8 @@ This is the ordered implementation roadmap following the accepted exhibit model.
|
|||||||
|
|
||||||
## Milestone 4: first playable mystery
|
## Milestone 4: first playable mystery
|
||||||
|
|
||||||
- [ ] Design a small mystery that exercises documents, folders, notes, events, people, organizations, connections, and the timeline.
|
- [x] Design a small mystery that exercises documents, folders, notes, events, people, organizations, connections, and the timeline.
|
||||||
- [ ] Create it as an immutable level-template version using the same supported operations available to an author.
|
- [x] Create it as an immutable level-template version using the same supported operations available to an author.
|
||||||
- [ ] Instantiate and solve a cloned level without modifying the template or relying on hard-coded case behavior.
|
- [x] Instantiate and solve a cloned level without modifying the template or relying on hard-coded case behavior.
|
||||||
- [ ] Turn the successful solve path into an end-to-end acceptance test.
|
- [x] Turn the successful solve path into an end-to-end acceptance test.
|
||||||
- [ ] Perform a manual playability and visual-polish pass before production deployment.
|
- [x] Perform a manual playability and visual-polish pass before production deployment.
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
async function waitForSave(page: Page, action: () => Promise<void>) {
|
||||||
|
const response = page.waitForResponse(candidate => candidate.request().method() === 'PUT' && candidate.url().includes('/api/levels/') && candidate.ok())
|
||||||
|
await action()
|
||||||
|
await response
|
||||||
|
}
|
||||||
|
|
||||||
|
async function classify(page: Page, name: string, kind: 'PERSON' | 'ORGANIZATION', summary: string) {
|
||||||
|
await page.getByRole('button', { name: 'BRIEF', exact: true }).click()
|
||||||
|
const concept = page.locator('.brief-concepts section').filter({ hasText: name })
|
||||||
|
await concept.getByRole('button', { name: kind, exact: true }).click()
|
||||||
|
await page.getByLabel('Party summary').fill(summary)
|
||||||
|
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEvent(page: Page, title: string, narrative: string, occurredAt: string, sources: string[]) {
|
||||||
|
await page.getByRole('button', { name: 'NEW EVENT', exact: true }).click()
|
||||||
|
await page.getByLabel('Event title').fill(title)
|
||||||
|
await page.getByLabel('Event narrative').fill(narrative)
|
||||||
|
await page.getByLabel('Occurred at').fill(occurredAt)
|
||||||
|
for (const source of sources) {
|
||||||
|
await page.locator('.event-support-list .folder-member').filter({ hasText: source }).locator('input[type="checkbox"]').check()
|
||||||
|
}
|
||||||
|
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE EVENT', exact: true }).click())
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a cloned Glass Harbor level can be solved without modifying its template', async ({ page, request }) => {
|
||||||
|
const levels = await (await request.get('/api/levels')).json() as { id: string }[]
|
||||||
|
const playable = levels.find(level => level.id.startsWith('glass-harbor-case-'))
|
||||||
|
if (!playable) throw new Error('Glass Harbor playable clone was not seeded')
|
||||||
|
await page.goto(`/?level=${playable.id}`)
|
||||||
|
await expect(page.getByRole('heading', { name: 'The Glass Harbor Diversion' })).toBeVisible()
|
||||||
|
await expect(page.locator('.evidence-card.folder')).toHaveCount(3)
|
||||||
|
await expect(page.locator('.timeline .marker')).toHaveCount(8)
|
||||||
|
|
||||||
|
await classify(page, 'Mara Voss', 'PERSON', 'Restoration project officer who sponsored the Warehouse 3 access pass.')
|
||||||
|
await classify(page, 'Elias Vale', 'PERSON', 'Driver of H&F 14; transported CO-771 to Warehouse 3.')
|
||||||
|
await classify(page, 'Greyhaven Preservation Society', 'ORGANIZATION', 'Commissioned the North Quay restoration and owned the missing lens.')
|
||||||
|
await classify(page, 'Calder Optical Works', 'ORGANIZATION', 'Supplier that released sealed crate CO-771 to the approved carrier.')
|
||||||
|
await classify(page, 'Harbor & Fell Logistics', 'ORGANIZATION', 'Approved carrier whose vehicle delivered the crate to the wrong address.')
|
||||||
|
await classify(page, 'Voss Antiquities Ltd', 'ORGANIZATION', 'Mara Voss’s company, based at Warehouse 3 and named as auction consignor.')
|
||||||
|
|
||||||
|
await createEvent(page, 'CO-771 left Calder for North Quay', 'Calder released the sealed lens to Elias Vale in H&F 14 with no alternate delivery authority.', '1987-10-16T16:40', ['Carrier Dispatch Manifest'])
|
||||||
|
await createEvent(page, 'CO-771 was diverted to Warehouse 3', 'Mara Voss sponsored access; H&F 14 entered loaded and left empty. The next-day memorandum was retrospective.', '1987-10-17T22:08', ['Old Glass Harbor Gate Ledger', 'Warehouse 3 Security Photograph', 'Delivery Redirection Memorandum'])
|
||||||
|
await createEvent(page, 'Voss Antiquities offered the lens for sale', 'The auction description and seller reference identify the missing assembly and the company that stood to profit.', '1987-10-24T12:00', ['Company Register Extract', 'Meridian Maritime Auction'])
|
||||||
|
|
||||||
|
page.once('dialog', dialog => dialog.accept('The redirection memorandum was written after the crate had already moved.'))
|
||||||
|
await waitForSave(page, () => page.getByRole('button', { name: 'NEW NOTE', exact: true }).click())
|
||||||
|
|
||||||
|
const mara = page.locator('.evidence-card.party').filter({ has: page.getByRole('heading', { name: 'Mara Voss', exact: true }) })
|
||||||
|
const movement = page.locator('.evidence-card.folder').filter({ hasText: 'MOVEMENT RECORDS' })
|
||||||
|
await mara.click()
|
||||||
|
await page.getByRole('button', { name: 'CONNECT', exact: true }).click()
|
||||||
|
await waitForSave(page, () => movement.click())
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.locator('.evidence-card.party')).toHaveCount(6)
|
||||||
|
await expect(page.locator('.evidence-card.event')).toHaveCount(3)
|
||||||
|
await expect(page.locator('.evidence-card.note')).toHaveCount(1)
|
||||||
|
await expect(page.locator('.story-strip')).toContainText('CO-771 was diverted to Warehouse 3')
|
||||||
|
await expect(page.locator('.event-support-lines line')).toHaveCount(6)
|
||||||
|
await expect(page.locator('.connections path')).toHaveCount(1)
|
||||||
|
|
||||||
|
const template = await (await request.get('/api/templates')).json() as { slug: string; currentVersion: number }[]
|
||||||
|
expect(template).toContainEqual(expect.objectContaining({ slug: 'glass-harbor', currentVersion: 1 }))
|
||||||
|
const authoringLevels = levels.filter(level => level.id.startsWith('glass-harbor-authoring-'))
|
||||||
|
expect(authoringLevels).toHaveLength(1)
|
||||||
|
const untouched = await (await request.get(`/api/levels/${authoringLevels[0].id}?edit=1`)).json()
|
||||||
|
expect(untouched.evidence.filter((item: { type: string }) => item.type === 'party')).toHaveLength(0)
|
||||||
|
expect(untouched.evidence.filter((item: { type: string }) => item.type === 'event')).toHaveLength(0)
|
||||||
|
expect(untouched.brief.concepts.every((concept: { resolvedPartyExhibitId?: string }) => !concept.resolvedPartyExhibitId)).toBe(true)
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Mystery content packages
|
||||||
|
|
||||||
|
Mysteries live outside the React application as authoring manifests and immutable source assets. Importing a manifest exercises the same public operations as the level editor: it creates a mutable authoring level, uploads assets, saves normalized exhibits, freezes an immutable template version, and instantiates a separate playable level.
|
||||||
|
|
||||||
|
With the local Docker stack running and editing enabled:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run mystery:import -- mysteries/glass-harbor/mystery.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The command prints the template version, authoring-level ID, playable-level ID, and player URL. Importing the same manifest again creates a new immutable version; it does not rewrite an earlier version.
|
||||||
|
|
||||||
|
## The Glass Harbor Diversion
|
||||||
|
|
||||||
|
This compact first mystery contains eight dated source documents in three folders, six unresolved Party concepts, and one fictional archival photograph. It intentionally starts without Party or Event exhibits: classification, dossier association, event reconstruction, working notes, and red-thread theory are the player’s work.
|
||||||
|
|
||||||
|
The source image was generated with the built-in image-generation tool for this fictional case. Its final production prompt asked for a degraded 1987 harbor-security photograph of truck `H&F 14` unloading a `CALDER OPTICAL / FRAGILE` crate at Warehouse 3, with period-correct details and no real people or brands.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
@@ -0,0 +1,154 @@
|
|||||||
|
{
|
||||||
|
"slug": "glass-harbor",
|
||||||
|
"name": "The Glass Harbor Diversion",
|
||||||
|
"title": "The Glass Harbor Diversion",
|
||||||
|
"subtitle": "Greyhaven file 87-10 · missing lighthouse optics",
|
||||||
|
"brief": {
|
||||||
|
"body": "A replacement Fresnel lens purchased for North Quay Lighthouse vanished between dispatch and installation. Determine who arranged the diversion, which organization stood to benefit, and when the shipment changed course. Classify every named concept, associate each party with the evidence that supports its dossier, reconstruct the decisive events, and make your conclusion visible with red thread. The terminal will not announce a winner: a solved board is a defensible account of what happened.",
|
||||||
|
"concepts": [
|
||||||
|
{ "label": "Mara Voss", "context": "Named in restoration correspondence and harbor records.", "expectedPartyKind": "person" },
|
||||||
|
{ "label": "Elias Vale", "context": "Named on the carrier dispatch manifest.", "expectedPartyKind": "person" },
|
||||||
|
{ "label": "Greyhaven Preservation Society", "context": "Commissioned the North Quay restoration.", "expectedPartyKind": "organization" },
|
||||||
|
{ "label": "Calder Optical Works", "context": "Supplied the missing Fresnel assembly.", "expectedPartyKind": "organization" },
|
||||||
|
{ "label": "Harbor & Fell Logistics", "context": "Transported the consignment from Calder.", "expectedPartyKind": "organization" },
|
||||||
|
{ "label": "Voss Antiquities Ltd", "context": "Appears in company and auction records.", "expectedPartyKind": "organization" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"key": "company-register",
|
||||||
|
"title": "Company Register Extract · Voss Antiquities Ltd",
|
||||||
|
"fileType": "filing",
|
||||||
|
"publishedAt": "1987-01-08T10:00:00.000Z",
|
||||||
|
"body": [
|
||||||
|
"GREYHAVEN COMPANIES REGISTRY — CERTIFIED EXTRACT 87/0118",
|
||||||
|
"VOSS ANTIQUITIES LTD · dealer in architectural and maritime salvage.",
|
||||||
|
"Sole director and beneficial owner: Mara Elise Voss.",
|
||||||
|
"Registered trading premises: Warehouse 3, Old Glass Harbor, Greyhaven.",
|
||||||
|
"Company status: active. Last annual return filed 8 January 1987."
|
||||||
|
],
|
||||||
|
"metadata": { "registry_number": "GH-44109", "certified_by": "Greyhaven Companies Registry" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "contract-award",
|
||||||
|
"title": "North Quay Restoration · Contract Award",
|
||||||
|
"fileType": "text",
|
||||||
|
"publishedAt": "1987-10-02T14:00:00.000Z",
|
||||||
|
"body": [
|
||||||
|
"GREYHAVEN PRESERVATION SOCIETY — CONTRACT 31/NQ",
|
||||||
|
"Calder Optical Works shall supply one restored third-order Fresnel assembly, serial CO-771, for permanent installation at North Quay Lighthouse.",
|
||||||
|
"Harbor & Fell Logistics is the approved carrier. Delivery is to the North Quay works compound no later than 20 October 1987.",
|
||||||
|
"The Society's project officer, Mara Voss, may acknowledge delivery but may not alter the delivery address without a countersignature from the Society treasurer.",
|
||||||
|
"Insured replacement value: 48,000 kroner."
|
||||||
|
],
|
||||||
|
"metadata": { "contract": "31/NQ", "serial": "CO-771" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "dispatch-manifest",
|
||||||
|
"title": "Carrier Dispatch Manifest · H&F 14",
|
||||||
|
"fileType": "text",
|
||||||
|
"publishedAt": "1987-10-16T16:40:00.000Z",
|
||||||
|
"body": [
|
||||||
|
"HARBOR & FELL LOGISTICS — OUTBOUND MANIFEST 16-440",
|
||||||
|
"Vehicle: H&F 14. Driver: Elias Vale. Cargo: one sealed Calder Optical crate, serial CO-771.",
|
||||||
|
"Collected from Calder Optical Works at 16:40 on 16 October 1987.",
|
||||||
|
"Consignee and destination: Greyhaven Preservation Society, North Quay Lighthouse works compound.",
|
||||||
|
"Special instructions: hold sealed; no alternate delivery authority lodged at time of dispatch."
|
||||||
|
],
|
||||||
|
"metadata": { "vehicle": "H&F 14", "driver": "Elias Vale", "serial": "CO-771" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "gate-ledger",
|
||||||
|
"title": "Old Glass Harbor Gate Ledger · Page 214",
|
||||||
|
"fileType": "text",
|
||||||
|
"publishedAt": "1987-10-17T22:08:00.000Z",
|
||||||
|
"body": [
|
||||||
|
"OLD GLASS HARBOR — NIGHT GATE LEDGER · 17 OCTOBER 1987",
|
||||||
|
"22:08 — Vehicle H&F 14 admitted. Driver signed E. Vale.",
|
||||||
|
"Declared load: Calder Optical crate CO-771. Destination inside harbor: Warehouse 3.",
|
||||||
|
"Temporary access sponsor: M. Voss. Telephone authorization logged 21:54.",
|
||||||
|
"22:31 — H&F 14 departed. Vehicle recorded empty."
|
||||||
|
],
|
||||||
|
"metadata": { "ledger_page": "214", "gate_officer": "T. Soren", "load_serial": "CO-771" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "warehouse-photo",
|
||||||
|
"title": "Warehouse 3 Security Photograph",
|
||||||
|
"fileType": "image",
|
||||||
|
"publishedAt": "1987-10-17T22:12:00.000Z",
|
||||||
|
"asset": "assets/warehouse-3-security.png",
|
||||||
|
"metadata": { "camera": "Old Glass Harbor C-3", "frame_time": "1987-10-17 22:12", "negative": "C3-871017-44" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "redirect-memo",
|
||||||
|
"title": "Delivery Redirection Memorandum",
|
||||||
|
"fileType": "email",
|
||||||
|
"publishedAt": "1987-10-18T09:15:00.000Z",
|
||||||
|
"body": [
|
||||||
|
"TO: Harbor & Fell Logistics / Calder Optical Works",
|
||||||
|
"FROM: Mara Voss, North Quay project officer",
|
||||||
|
"Owing to overnight water ingress at the lighthouse works compound, consignment CO-771 is to be held temporarily at Warehouse 3, Old Glass Harbor.",
|
||||||
|
"The driver was advised of this change before departure from Calder Optical Works. Formal notice follows for your files.",
|
||||||
|
"Signed M. Voss · 18 October 1987 · 09:15. No Society treasurer countersignature appears on this copy."
|
||||||
|
],
|
||||||
|
"metadata": { "received_stamp": "18 OCT 1987 09:15", "countersignature": "absent" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "inventory-notice",
|
||||||
|
"title": "North Quay Missing Inventory Notice",
|
||||||
|
"fileType": "text",
|
||||||
|
"publishedAt": "1987-10-21T08:30:00.000Z",
|
||||||
|
"body": [
|
||||||
|
"GREYHAVEN PRESERVATION SOCIETY — EXCEPTION NOTICE",
|
||||||
|
"The Fresnel assembly CO-771 was not present when the North Quay installation team assembled on 20 October.",
|
||||||
|
"Calder Optical Works confirms collection by the approved carrier. Harbor & Fell confirms completion of the driver's assigned shift but holds no signed North Quay receipt.",
|
||||||
|
"Warehouse 3 was inspected at 08:00 on 21 October. No Calder crate or optical assembly was found.",
|
||||||
|
"Project officer Mara Voss reported that she believed delivery remained pending."
|
||||||
|
],
|
||||||
|
"metadata": { "case_reference": "GPS/NQ/EX-4", "serial": "CO-771" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "auction-catalogue",
|
||||||
|
"title": "Meridian Maritime Auction · Lot 117",
|
||||||
|
"fileType": "article",
|
||||||
|
"publishedAt": "1987-10-24T12:00:00.000Z",
|
||||||
|
"body": [
|
||||||
|
"MERIDIAN MARITIME AUCTION — ADVANCE CATALOGUE · 24 OCTOBER 1987",
|
||||||
|
"LOT 117 — Restored third-order Fresnel lighthouse assembly, late nineteenth century, complete in fitted transit frame.",
|
||||||
|
"Consignor: Voss Antiquities Ltd. Seller reference: MV-3/771.",
|
||||||
|
"Insurance estimate: 45,000–52,000 kroner. Private preview by appointment before export sale.",
|
||||||
|
"Catalogue correction: lot withdrawn from public view on 26 October at consignor's request."
|
||||||
|
],
|
||||||
|
"metadata": { "lot": "117", "seller_reference": "MV-3/771", "estimate": "45,000–52,000 NOK" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"key": "procurement-file",
|
||||||
|
"title": "PROCUREMENT & OWNERSHIP",
|
||||||
|
"content": "Authority, ownership, destination, and declared value.",
|
||||||
|
"x": 270,
|
||||||
|
"y": 250,
|
||||||
|
"width": 270,
|
||||||
|
"members": ["company-register", "contract-award"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "movement-file",
|
||||||
|
"title": "MOVEMENT RECORDS",
|
||||||
|
"content": "Dispatch, harbor access, image capture, and later explanation.",
|
||||||
|
"x": 680,
|
||||||
|
"y": 320,
|
||||||
|
"width": 280,
|
||||||
|
"members": ["dispatch-manifest", "gate-ledger", "warehouse-photo", "redirect-memo"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "aftermath-file",
|
||||||
|
"title": "AFTERMATH",
|
||||||
|
"content": "The missing inventory report and the first attempted resale.",
|
||||||
|
"x": 1090,
|
||||||
|
"y": 250,
|
||||||
|
"width": 270,
|
||||||
|
"members": ["inventory-notice", "auction-catalogue"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"e2e:serve": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgres://osint:osint_secret@localhost:5433/osint_dev} tsx server/e2eHarness.ts",
|
"e2e:serve": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgres://osint:osint_secret@localhost:5433/osint_dev} tsx server/e2eHarness.ts",
|
||||||
"start": "NODE_ENV=production tsx server/index.ts",
|
"start": "NODE_ENV=production tsx server/index.ts",
|
||||||
"migrate:up": "tsx server/migrate.ts",
|
"migrate:up": "tsx server/migrate.ts",
|
||||||
|
"mystery:import": "tsx scripts/importMysteryTemplate.ts",
|
||||||
"test": "vitest run --exclude '**/*.integration.test.ts' --exclude 'e2e/**'",
|
"test": "vitest run --exclude '**/*.integration.test.ts' --exclude 'e2e/**'",
|
||||||
"test:integration": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgres://osint:osint_secret@localhost:5433/osint_dev} vitest run server/*.integration.test.ts",
|
"test:integration": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgres://osint:osint_secret@localhost:5433/osint_dev} vitest run server/*.integration.test.ts",
|
||||||
"test:e2e": "npm run build && playwright test"
|
"test:e2e": "npm run build && playwright test"
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import type { CaseDocument, CaseState, PartyKind, SourceFileType } from '../src/types.js'
|
||||||
|
|
||||||
|
type MysteryDocument = {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
fileType: SourceFileType
|
||||||
|
publishedAt: string
|
||||||
|
body?: string[]
|
||||||
|
metadata?: Record<string, string>
|
||||||
|
asset?: string
|
||||||
|
}
|
||||||
|
type MysteryManifest = {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
brief: { body: string; concepts: { label: string; context: string; expectedPartyKind: PartyKind }[] }
|
||||||
|
documents: MysteryDocument[]
|
||||||
|
folders: { key: string; title: string; content: string; x: number; y: number; width: number; members: string[] }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireOk(response: Response, action: string) {
|
||||||
|
if (response.ok) return response
|
||||||
|
return response.text().then(body => { throw new Error(`${action} failed (${response.status}): ${body}`) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentKind(type: SourceFileType) {
|
||||||
|
return type === 'web_capture' ? 'WEB CAPTURE' : type.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string, document: MysteryDocument) {
|
||||||
|
if (!document.asset) return undefined
|
||||||
|
const assetPath = path.resolve(manifestDir, document.asset)
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', new Blob([await readFile(assetPath)]), path.basename(assetPath))
|
||||||
|
const response = await requireOk(await fetch(`${baseUrl}/api/levels/${levelId}/documents?edit=1`, { method: 'POST', body: form }), `Upload ${document.asset}`)
|
||||||
|
return await response.json() as CaseDocument
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importMysteryTemplate(manifestPath: string, baseUrl = 'http://localhost:8787') {
|
||||||
|
const absoluteManifest = path.resolve(manifestPath)
|
||||||
|
const manifest = JSON.parse(await readFile(absoluteManifest, 'utf8')) as MysteryManifest
|
||||||
|
const manifestDir = path.dirname(absoluteManifest)
|
||||||
|
const authoringId = `${manifest.slug}-authoring-${Date.now()}`
|
||||||
|
const createdResponse = await requireOk(await fetch(`${baseUrl}/api/levels`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: authoringId, title: manifest.title, subtitle: manifest.subtitle }),
|
||||||
|
}), 'Create authoring level')
|
||||||
|
const state = await createdResponse.json() as CaseState
|
||||||
|
|
||||||
|
const documents = new Map<string, CaseDocument>()
|
||||||
|
for (const source of manifest.documents) {
|
||||||
|
const uploaded = await uploadAsset(baseUrl, state.id, manifestDir, source)
|
||||||
|
documents.set(source.key, {
|
||||||
|
id: uploaded?.id || randomUUID(), title: source.title, kind: documentKind(source.fileType),
|
||||||
|
date: source.publishedAt.slice(0, 10), publishedAt: source.publishedAt,
|
||||||
|
body: source.body || [], regions: [], assetId: uploaded?.assetId,
|
||||||
|
fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize,
|
||||||
|
fileType: source.fileType, metadata: source.metadata || {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderIds = new Map(manifest.folders.map(folder => [folder.key, randomUUID()]))
|
||||||
|
state.brief = { body: manifest.brief.body, concepts: manifest.brief.concepts.map(concept => ({ id: randomUUID(), ...concept })) }
|
||||||
|
state.documents = [...documents.values()]
|
||||||
|
state.evidence = manifest.folders.map(folder => ({
|
||||||
|
id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content,
|
||||||
|
x: folder.x, y: folder.y, width: folder.width, config: { open: false },
|
||||||
|
containedDocumentIds: folder.members.map(key => documents.get(key)!.id),
|
||||||
|
}))
|
||||||
|
state.relations = manifest.folders.flatMap((folder, folderIndex) => folder.members.map((key, memberIndex) => {
|
||||||
|
const document = documents.get(key)
|
||||||
|
if (!document) throw new Error(`Folder ${folder.key} refers to unknown document ${key}`)
|
||||||
|
return {
|
||||||
|
id: `contains:${folderIds.get(folder.key)}:${document.id}`,
|
||||||
|
fromWidgetId: folderIds.get(folder.key)!, toWidgetId: document.id, type: 'contains', sortOrder: memberIndex,
|
||||||
|
config: { x: folder.x + 70 + memberIndex * 205, y: folder.y + 230 + folderIndex * 35 },
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
state.connections = []
|
||||||
|
state.viewport = { x: 0, y: 28, zoom: 0.7 }
|
||||||
|
|
||||||
|
await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||||
|
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(state),
|
||||||
|
}), 'Save authored mystery')
|
||||||
|
const templateResponse = await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slug: manifest.slug, name: manifest.name }),
|
||||||
|
}), 'Freeze mystery template')
|
||||||
|
const template = await templateResponse.json() as { slug: string; currentVersion: number }
|
||||||
|
const playableId = `${manifest.slug}-case-${Date.now()}`
|
||||||
|
const playableResponse = await requireOk(await fetch(`${baseUrl}/api/templates/${manifest.slug}/levels?edit=1`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: playableId, title: manifest.title }),
|
||||||
|
}), 'Instantiate playable mystery')
|
||||||
|
const playable = await playableResponse.json() as CaseState
|
||||||
|
return { manifest, template, authoringLevelId: state.id, playableLevel: playable }
|
||||||
|
}
|
||||||
|
|
||||||
|
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ''
|
||||||
|
if (invokedPath === fileURLToPath(import.meta.url)) {
|
||||||
|
const manifestPath = process.argv[2]
|
||||||
|
if (!manifestPath) throw new Error('Usage: npm run mystery:import -- <manifest.json>')
|
||||||
|
const result = await importMysteryTemplate(manifestPath, process.env.OSINT_BOARD_URL)
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
template: `${result.template.slug}@v${result.template.currentVersion}`,
|
||||||
|
authoringLevelId: result.authoringLevelId,
|
||||||
|
playableLevelId: result.playableLevel.id,
|
||||||
|
playUrl: `${process.env.OSINT_BOARD_URL || 'http://localhost:8787'}/?level=${encodeURIComponent(result.playableLevel.id)}`,
|
||||||
|
}, null, 2))
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import path from 'node:path'
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import pg from 'pg'
|
import pg from 'pg'
|
||||||
import type { CaseState } from '../src/types.js'
|
import type { CaseState } from '../src/types.js'
|
||||||
|
import { importMysteryTemplate } from '../scripts/importMysteryTemplate.js'
|
||||||
import { runMigrations } from './migrations.js'
|
import { runMigrations } from './migrations.js'
|
||||||
|
|
||||||
const { Client } = pg
|
const { Client } = pg
|
||||||
@@ -65,6 +66,8 @@ const saved = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
|||||||
})
|
})
|
||||||
if (!saved.ok) throw new Error(`Could not seed browser test level: ${saved.status}`)
|
if (!saved.ok) throw new Error(`Could not seed browser test level: ${saved.status}`)
|
||||||
|
|
||||||
|
await importMysteryTemplate(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'mysteries', 'glass-harbor', 'mystery.json'), baseUrl)
|
||||||
|
|
||||||
let shuttingDown = false
|
let shuttingDown = false
|
||||||
async function shutdown(exitCode: number) {
|
async function shutdown(exitCode: number) {
|
||||||
if (shuttingDown) return
|
if (shuttingDown) return
|
||||||
|
|||||||
+10
-5
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||||
import type { BriefConcept, CaseDocument, CaseState, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, WidgetRelation } from './types'
|
import type { BriefConcept, CaseDocument, CaseState, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, WidgetRelation } from './types'
|
||||||
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromWheel } from './boardDomain'
|
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromWheel } from './boardDomain'
|
||||||
import { documentWidget, exhibitWidget } from './exhibitRegistry'
|
import { documentWidget, exhibitWidget } from './exhibitRegistry'
|
||||||
|
|
||||||
const BOARD_W = 2400
|
const BOARD_W = 2400
|
||||||
@@ -107,16 +107,17 @@ export function App() {
|
|||||||
const content = window.prompt('What do you think this evidence means?')?.trim()
|
const content = window.prompt('What do you think this evidence means?')?.trim()
|
||||||
if (!content || !caseState) return
|
if (!content || !caseState) return
|
||||||
const { viewport } = caseState
|
const { viewport } = caseState
|
||||||
const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom), width: 108 }
|
const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
|
||||||
|
const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...position, width: 108 }
|
||||||
update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id)
|
update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addEvent = () => {
|
const addEvent = () => {
|
||||||
if (!caseState) return
|
if (!caseState) return
|
||||||
const { viewport } = caseState
|
const { viewport } = caseState
|
||||||
|
const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (620 - viewport.x) / viewport.zoom), y: Math.max(100, (290 - viewport.y) / viewport.zoom) }, { width: 270 })
|
||||||
const event: Evidence = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.',
|
const event: Evidence = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.',
|
||||||
eventDate: new Date().toISOString(), supportingEvidenceIds: [], x: Math.max(100, (620 - viewport.x) / viewport.zoom),
|
eventDate: new Date().toISOString(), supportingEvidenceIds: [], ...position, width: 270 }
|
||||||
y: Math.max(100, (290 - viewport.y) / viewport.zoom), width: 270 }
|
|
||||||
update(state => ({ ...state, evidence: [...state.evidence, event] }))
|
update(state => ({ ...state, evidence: [...state.evidence, event] }))
|
||||||
setSelected(event.id); setEditingEventId(event.id)
|
setSelected(event.id); setEditingEventId(event.id)
|
||||||
}
|
}
|
||||||
@@ -128,9 +129,13 @@ export function App() {
|
|||||||
const existingId = concept.resolvedPartyExhibitId
|
const existingId = concept.resolvedPartyExhibitId
|
||||||
const partyId = existingId || uid('party')
|
const partyId = existingId || uid('party')
|
||||||
const { viewport } = caseState
|
const { viewport } = caseState
|
||||||
|
const existingParty = caseState.evidence.find(item => item.id === existingId)
|
||||||
|
const position = existingParty ? { x: existingParty.x, y: existingParty.y } : nextOpenBoardPosition(caseState.evidence, {
|
||||||
|
x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom),
|
||||||
|
}, { width: 280 })
|
||||||
const party: Evidence = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined,
|
const party: Evidence = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined,
|
||||||
title: concept.label, content: concept.context, aliases: [], relatedEvidenceIds: [],
|
title: concept.label, content: concept.context, aliases: [], relatedEvidenceIds: [],
|
||||||
x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom), width: 280 }
|
...position, width: 280 }
|
||||||
update(state => ({ ...state,
|
update(state => ({ ...state,
|
||||||
evidence: existingId ? state.evidence.map(item => item.id === existingId ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.evidence, party],
|
evidence: existingId ? state.evidence.map(item => item.id === existingId ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.evidence, party],
|
||||||
brief: { ...state.brief, concepts: state.brief.concepts.map(item => item.id === conceptId ? { ...item, resolvedPartyExhibitId: partyId } : item) },
|
brief: { ...state.brief, concepts: state.brief.concepts.map(item => item.id === conceptId ? { ...item, resolvedPartyExhibitId: partyId } : item) },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
containedIds,
|
containedIds,
|
||||||
folderIsOpen,
|
folderIsOpen,
|
||||||
moveBoardPoint,
|
moveBoardPoint,
|
||||||
|
nextOpenBoardPosition,
|
||||||
normalizeCase,
|
normalizeCase,
|
||||||
panViewport,
|
panViewport,
|
||||||
relationPosition,
|
relationPosition,
|
||||||
@@ -58,6 +59,16 @@ describe('board coordinate math', () => {
|
|||||||
expect(zoomFromWheel(1, 10_000)).toBe(MIN_BOARD_ZOOM)
|
expect(zoomFromWheel(1, 10_000)).toBe(MIN_BOARD_ZOOM)
|
||||||
expect(zoomFromWheel(1, -10_000)).toBe(MAX_BOARD_ZOOM)
|
expect(zoomFromWheel(1, -10_000)).toBe(MAX_BOARD_ZOOM)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('places new exhibits in deterministic open slots', () => {
|
||||||
|
const preferred = { x: 500, y: 400 }
|
||||||
|
expect(nextOpenBoardPosition([], preferred, { width: 280 })).toEqual(preferred)
|
||||||
|
expect(nextOpenBoardPosition([{ x: 500, y: 400, width: 280 }], preferred, { width: 280 })).toEqual({ x: 826, y: 400 })
|
||||||
|
expect(nextOpenBoardPosition([
|
||||||
|
{ x: 500, y: 400, width: 280 },
|
||||||
|
{ x: 826, y: 400, width: 280 },
|
||||||
|
], preferred, { width: 280 })).toEqual({ x: 174, y: 400 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('timeline projection', () => {
|
describe('timeline projection', () => {
|
||||||
|
|||||||
@@ -16,6 +16,29 @@ export function moveBoardPoint(origin: { x: number; y: number }, screenDelta: {
|
|||||||
return { x: origin.x + screenDelta.x / zoom, y: origin.y + screenDelta.y / zoom }
|
return { x: origin.x + screenDelta.x / zoom, y: origin.y + screenDelta.y / zoom }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function nextOpenBoardPosition(
|
||||||
|
evidence: Pick<Evidence, 'x' | 'y' | 'width'>[],
|
||||||
|
preferred: { x: number; y: number },
|
||||||
|
size: { width: number; height?: number },
|
||||||
|
bounds = { width: 2400, height: 1500 },
|
||||||
|
) {
|
||||||
|
const height = size.height || 160
|
||||||
|
const gap = 28
|
||||||
|
const overlaps = (x: number, y: number) => evidence.some(item =>
|
||||||
|
x < item.x + item.width + gap && x + size.width + gap > item.x &&
|
||||||
|
y < item.y + 160 + gap && y + height + gap > item.y)
|
||||||
|
const xStep = size.width + 46
|
||||||
|
const yStep = height + 46
|
||||||
|
for (let row = 0; row < 7; row += 1) {
|
||||||
|
for (const column of [0, 1, -1, 2, -2, 3, -3]) {
|
||||||
|
const x = Math.max(80, Math.min(bounds.width - size.width - 80, preferred.x + column * xStep))
|
||||||
|
const y = Math.max(100, Math.min(bounds.height - height - 80, preferred.y + row * yStep))
|
||||||
|
if (!overlaps(x, y)) return { x, y }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { x: Math.max(80, Math.min(bounds.width - size.width - 80, preferred.x)), y: Math.min(bounds.height - height - 80, preferred.y + evidence.length * 24) }
|
||||||
|
}
|
||||||
|
|
||||||
export function panViewport(origin: Viewport, screenDelta: { x: number; y: number }): Viewport {
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -9,5 +9,5 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts", "playwright.config.ts", "server/**/*.ts", "e2e/**/*.ts", "src/types.ts"]
|
"include": ["vite.config.ts", "playwright.config.ts", "server/**/*.ts", "scripts/**/*.ts", "e2e/**/*.ts", "src/types.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user