Adding fixes that lets us pull a myster in the db into a json file

This commit is contained in:
2026-08-23 21:37:06 +02:00
parent 3c099a5daa
commit 7dec2994b4
8 changed files with 719 additions and 290 deletions
+21 -3
View File
@@ -1,14 +1,32 @@
# 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.
A mystery is a self-contained, version-controlled folder: `mysteries/<slug>/mystery.json` plus a `mysteries/<slug>/assets/` sidecar for any binary assets it references by filename. A single `mystery.json` holds the whole story — the flow `narrative` (NPC cast + graph) and every `levels[]` board the graph embeds — so one directory reproduces one complete mystery.
Importing exercises the same public operations as the level editor: for each embedded level it creates a mutable authoring level, uploads assets, saves normalized exhibits, freezes an immutable template version, and instantiates a playable level; then it authors the mystery and seeds its story graph.
With the local Docker stack running and editing enabled:
```bash
npm run mystery:import -- mysteries/glass-harbor/mystery.json
npm run mystery:import -- mysteries/barricelli-files # a folder (self-contained)
npm run mystery:import -- mysteries/glass-harbor/mystery.json # or a single manifest
```
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.
Importing the same content again creates a new immutable template version; it does not rewrite an earlier version.
## Syncing back from a running instance
`mystery:pull` serializes a mystery from a running instance's database into the folder format above, so you can edit on a server and bring the result back into git. It defaults to the local dev instance; pass `--prod` to pull from https://gupi.glitch.university (which requires an `OSINT_ADMIN_JWT` minted inside the `gnommo-osint-board` container).
```bash
npm run mystery:pull -- barricelli-files # dev → mysteries/barricelli-files/
OSINT_ADMIN_JWT=<token> npm run mystery:pull -- barricelli-files --prod
```
The pull is deterministic and round-trips: importing a pulled folder into a clean database and pulling again yields an identical `mystery.json`. Node/utterance keys are synthesized from labels, so a re-exported file is stable but need not match hand-written keys byte-for-byte — the exported file is the source of truth. (Known gaps: node music and NPC pose artwork are not yet carried through the pull.)
## Legacy single-manifest format
`glass-harbor/` and `barricelli-scene-7/` are older single-manifest packages (one level plus an optional inline `narrative`) kept as importer test fixtures — `barricelli-scene-7/fixtures/` holds OCR text used by the integration tests. `mystery:import` still accepts this shape (a file with no top-level `levels[]`); new mysteries should use the self-contained folder format.
## The Glass Harbor Diversion
+415 -275
View File
@@ -1,6 +1,120 @@
{
"slug": "barricelli-files",
"title": "The Barricelli Files",
"levels": [
{
"slug": "barricelli-inventor-proof",
"name": "Scene 7 · Prove Barricelli was an inventor",
"title": "The Barricelli Files",
"subtitle": "Scene 7 · Demonstrate OSINT skill",
"brief": {
"body": "DEMONSTRATE OSINT SKILL\n\nProve that Nils Aall Barricelli was an inventor. Find a reliable source online, take a screenshot, and paste it directly onto this board. Connect the source to the authored claim with red thread, explain what the evidence proves, and submit the Case Report.",
"concepts": []
},
"claims": [
{
"key": "nils-aall-barricelli-was-an-inventor",
"statement": "Nils Aall Barricelli was an inventor.",
"x": 940,
"y": 360,
"width": 330,
"height": 190
}
],
"report": {
"title": "Barricelli Inventor Finding",
"requiredForCompletion": true
},
"goals": [
{
"key": "barricelli.inventor-proof",
"title": "Prove Nils Aall Barricelli was an inventor",
"instructions": "Paste a reliable screenshot, connect it to the claim, and submit a properly cited Case Report.",
"completionMessage": "CASE REPORT ACCEPTED — NILS AALL BARRICELLI: INVENTOR",
"enabled": true,
"requiredFlags": [
"scene7.nils_inventor_proved"
]
}
],
"evidenceMatchRules": [
{
"name": "Nasjonalbiblioteket · Patent 93585",
"sourceLabel": "Nasjonalbiblioteket · Patent 93585 · Koffert-kommode",
"sourceUri": "https://www.nb.no/items/921545b51acef0b05054cfc1f4666975?page=9&searchText=baricelli",
"flagKey": "scene7.nils_inventor_proved",
"minimumAnchorMatches": 2,
"enabled": true,
"anchors": [
{
"phrase": "Nr 75 348 Kl 33 b-9 Fra 31 mai 1948 93585 Koffert-kommode",
"minimumSimilarity": 0.68
},
{
"phrase": "Niels Aall Baricelli Oslo",
"minimumSimilarity": 0.72
},
{
"phrase": "Patentpaastand Kommode som er satt sammen av flere enkeltdeler som hver er utfort som koffert",
"minimumSimilarity": 0.62
}
]
},
{
"name": "Google Patents · GB695913A",
"sourceLabel": "Google Patents · GB695913A · Improved chest of drawers",
"sourceUri": "https://patents.google.com/patent/GB695913A/en",
"flagKey": "scene7.nils_inventor_proved",
"minimumAnchorMatches": 2,
"enabled": true,
"anchors": [
{
"phrase": "GB695913A Improved chest of drawers",
"minimumSimilarity": 0.7
},
{
"phrase": "Nils Aall Barricelli",
"minimumSimilarity": 0.72
},
{
"phrase": "695913 Chests of drawers BARRICELLI N A May 31 1951",
"minimumSimilarity": 0.68
}
]
}
],
"evidenceSemanticRules": [
{
"goalKey": "barricelli.inventor-proof",
"name": "Barricelli inventor claim",
"targetSubject": "Nils Aall Barricelli",
"relatedSubject": "Nils Aall Barricelli's father",
"assertion": "The source states or directly demonstrates that Nils Aall Barricelli was an inventor or a named patent applicant for an invention.",
"successFlagKey": "scene7.nils_inventor_proved",
"relatedFlagKey": "scene7.father_inventor_discovered",
"minimumConfidence": 0.88,
"evaluatorVersion": "barricelli_inventor_v1",
"enabled": true
}
]
},
{
"slug": "barricelli-phone-note",
"name": "Scene 6 · Phone note",
"title": "Phone note",
"notes": [
{
"title": "Note",
"content": "PHONE FOR GLITCH HUNTER\n\nCall: 5550100",
"presentation": "luggage",
"x": 420,
"y": 260,
"width": 230,
"height": 180
}
]
}
],
"narrative": {
"cast": [
{
@@ -19,260 +133,10 @@
}
],
"graph": {
"entry": "dobby-intro",
"entry": "scene-5-dobby",
"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",
"key": "scene-8-glitch-hunter",
"type": "dialogue",
"label": "Scene 8 · Glitch Hunter",
"x": 200,
@@ -281,18 +145,21 @@
{
"key": "continue",
"label": "Continue",
"to": "luggage"
"to": "scene-9-the-barricelli-luggage"
}
],
"utterances": [
{
"key": "u0",
"terminal": "continue",
"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."
"text": "Ha — there it is. Using a suitcase as drawers. You can just take the drawer with you. Told you he was a genius.",
"utterer": "npc"
}
]
},
{
"key": "luggage",
"key": "scene-9-the-barricelli-luggage",
"type": "merit",
"label": "Scene 9 · The Barricelli Luggage",
"x": 200,
@@ -302,12 +169,12 @@
{
"key": "continue",
"label": "Continue",
"to": "hunter-deepweb"
"to": "scene-10-the-deep-web"
}
]
},
{
"key": "hunter-deepweb",
"key": "scene-10-the-deep-web",
"type": "dialogue",
"label": "Scene 10 · The deep web",
"x": 200,
@@ -315,41 +182,314 @@
"terminals": [
{
"key": "done",
"label": "End",
"to": null
"label": "End"
}
],
"utterances": [
{
"key": "w0",
"key": "u0",
"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."
"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.",
"utterer": "npc"
},
{
"key": "w-who",
"parent": "w0",
"utterer": "player",
"text": "Who are you, exactly?"
"key": "u1",
"parent": "u0",
"text": "Who are you, exactly?",
"utterer": "player"
},
{
"key": "w-who-a",
"parent": "w-who",
"key": "u2",
"parent": "u1",
"terminal": "done",
"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"
"utterer": "npc"
},
{
"key": "w-family",
"parent": "w0",
"utterer": "player",
"text": "Saved his family?"
"key": "u3",
"parent": "u0",
"text": "Saved his family?",
"utterer": "player"
},
{
"key": "w-family-a",
"parent": "w-family",
"key": "u4",
"parent": "u3",
"terminal": "done",
"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"
"utterer": "npc"
}
]
},
{
"key": "scene-7-prove-he-was-an-inventor",
"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": "scene-8-glitch-hunter"
}
]
},
{
"key": "scene-5-dobby",
"type": "dialogue",
"label": "Scene 5 · Dobby",
"x": 200,
"y": 60,
"terminals": [
{
"key": "continue",
"label": "Continue",
"to": "scene-6-1-dobby-tasks"
}
],
"utterances": [
{
"key": "u0",
"npc": "dobby",
"text": "Oh — you're the new Principal Investigator. I'm Dobby, student counsellor. Welcome to the Glitch University PI programme.",
"utterer": "npc"
},
{
"key": "u1",
"parent": "u0",
"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.",
"utterer": "npc"
},
{
"key": "u2",
"parent": "u1",
"terminal": "continue",
"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.",
"utterer": "npc"
}
]
},
{
"key": "scene-6-1-dobby-tasks",
"type": "dialogue",
"label": "Scene 6.1 · Dobby tasks",
"x": 200,
"y": 240,
"terminals": [
{
"key": "continue",
"label": "Find a phone",
"to": "scene-6-note-board"
}
],
"utterances": [
{
"key": "u0",
"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.",
"utterer": "npc"
},
{
"key": "u1",
"parent": "u0",
"text": "Who's the mathematician?",
"utterer": "player",
"awardsFlag": "dobby.knows_barricelli_name"
},
{
"key": "u2",
"parent": "u1",
"npc": "dobby",
"text": "Niels Aall Barricelli. There — don't say I never give you anything.",
"utterer": "npc"
},
{
"key": "u3",
"parent": "u2",
"text": "Tell me more about him.",
"utterer": "player",
"awardsFlag": "dobby.knows_barricelli"
},
{
"key": "u4",
"parent": "u3",
"npc": "dobby",
"text": "Italian-Norwegian mathematician. Niels — spelled with an 'e'. That's all you're getting from me.",
"utterer": "npc"
},
{
"key": "u5",
"parent": "u4",
"terminal": "continue",
"text": "Right. I'll call him.",
"utterer": "player"
},
{
"key": "u6",
"parent": "u2",
"terminal": "continue",
"text": "I'll call him.",
"utterer": "player"
},
{
"key": "u7",
"parent": "u0",
"text": "How do I reach him?",
"utterer": "player"
},
{
"key": "u8",
"parent": "u7",
"npc": "dobby",
"text": "There's a number floating about. Find a phone. You're an investigator — investigate.",
"utterer": "npc"
},
{
"key": "u9",
"parent": "u8",
"terminal": "continue",
"text": "On it.",
"utterer": "player"
}
]
},
{
"key": "scene-6-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": "scene-6-2-glitch-hunter"
},
{
"key": "phone",
"label": "Phone",
"to": "phone-dial-glitch-hunter"
}
]
},
{
"key": "phone-dial-glitch-hunter",
"type": "phone",
"label": "Phone (dial Glitch Hunter)",
"x": 470,
"y": 420,
"terminals": [
{
"key": "call-hunter",
"label": "Glitch Hunter",
"to": "scene-6-2-glitch-hunter",
"npc": "glitch-hunter"
}
]
},
{
"key": "scene-6-2-glitch-hunter",
"type": "dialogue",
"label": "Scene 6.2 · Glitch Hunter",
"x": 200,
"y": 600,
"terminals": [
{
"key": "proceed",
"label": "Take the task",
"to": "scene-6-2-the-task"
},
{
"key": "decline",
"label": "Back",
"to": "scene-6-note-board"
}
],
"utterances": [
{
"key": "u0",
"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?",
"utterer": "npc"
},
{
"key": "u1",
"parent": "u0",
"terminal": "decline",
"text": "Out.",
"utterer": "player"
},
{
"key": "u2",
"parent": "u0",
"text": "I'm in.",
"utterer": "player"
},
{
"key": "u3",
"parent": "u2",
"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?",
"utterer": "npc"
},
{
"key": "u4",
"parent": "u3",
"terminal": "proceed",
"text": "No — no idea who that is.",
"utterer": "player"
},
{
"key": "u5",
"parent": "u3",
"terminal": "proceed",
"text": "Niels Aall Barricelli — a Norwegian-Italian mathematician.",
"utterer": "player",
"requiresFlag": "dobby.knows_barricelli"
},
{
"key": "u6",
"parent": "u3",
"terminal": "proceed",
"text": "Niels Aall Barricelli.",
"utterer": "player",
"requiresFlag": "dobby.knows_barricelli_name"
}
]
},
{
"key": "scene-6-2-the-task",
"type": "dialogue",
"label": "Scene 6.2 · The task",
"x": 200,
"y": 780,
"terminals": [
{
"key": "continue",
"label": "To the board",
"to": "scene-7-prove-he-was-an-inventor"
}
],
"utterances": [
{
"key": "u0",
"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.",
"utterer": "npc"
},
{
"key": "u1",
"parent": "u0",
"terminal": "continue",
"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.",
"utterer": "npc"
}
]
}
+1
View File
@@ -12,6 +12,7 @@
"start": "NODE_ENV=production tsx server/index.ts",
"migrate:up": "tsx server/migrate.ts",
"mystery:import": "tsx scripts/importMysteryTemplate.ts",
"mystery:pull": "tsx scripts/exportMystery.ts",
"test": "vitest run --exclude '**/*.integration.test.ts' --exclude 'e2e/**'",
"test:integration": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgres://osint:osint_secret@localhost:5433/osint_dev} vitest run server/*.integration.test.ts",
"test:e2e": "npm run build && playwright test"
+229
View File
@@ -0,0 +1,229 @@
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { CaseDocument, CaseState, ClaimExhibit, FolderExhibit, NoteExhibit } from '../src/types.js'
import type { TemplateBoardExport } from '../server/levelRepository.js'
import type { NpcDto } from '../server/narrativeRepository.js'
import type { StoryGraphDto, StoryNodeDto, UtteranceDto } from '../server/storyGraphRepository.js'
const DEV_URL = 'http://localhost:8787'
const PROD_URL = 'https://gupi.glitch.university'
type MysterySummary = { id: string; slug: string; title: string; nodes: number }
function slugify(input: string, fallback: string) {
const value = (input || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
return value || fallback
}
// Assign a unique key per id, deriving a readable slug and de-duplicating collisions.
function uniqueKeyer() {
const used = new Set<string>()
return (base: string, fallback: string) => {
const root = slugify(base, fallback)
let key = root
for (let n = 2; used.has(key); n++) key = `${root}-${n}`
used.add(key)
return key
}
}
// Drop undefined and empty arrays so the emitted manifest reads like a hand-written one
// (the importer tolerates sparse levels, so an absent key means "none").
function prune<T extends Record<string, unknown>>(object: T): T {
const out: Record<string, unknown> = {}
for (const [key, value] of Object.entries(object)) {
if (value === undefined) continue
if (Array.isArray(value) && value.length === 0) continue
out[key] = value
}
return out as T
}
async function getJson<T>(baseUrl: string, route: string, authorization?: string): Promise<T> {
const response = await fetch(`${baseUrl}${route}`, { headers: authorization ? { authorization } : undefined })
if (!response.ok) throw new Error(`GET ${route} failed (${response.status}): ${await response.text()}`)
return await response.json() as T
}
// Local dev has no login page; mint an admin cookie via the dev-only endpoint and reuse
// its token as a bearer. Production always requires an explicit OSINT_ADMIN_JWT.
async function resolveAdminJwt(baseUrl: string, isProd: boolean): Promise<string> {
if (process.env.OSINT_ADMIN_JWT) return process.env.OSINT_ADMIN_JWT
if (isProd) throw new Error('OSINT_ADMIN_JWT is required for --prod (mint one inside the gnommo-osint-board container)')
const response = await fetch(`${baseUrl}/api/dev/admin-session`, { redirect: 'manual' })
const cookie = response.headers.get('set-cookie')
const token = cookie?.match(/auth_token=([^;]+)/)?.[1]
if (!token) throw new Error(`Could not obtain a dev admin token from ${baseUrl}/api/dev/admin-session`)
return token
}
// One board (frozen template version) → the importer's level shape.
function serializeLevel(board: TemplateBoardExport, assetFilenames: Map<string, string>) {
const state: CaseState = board.state
const documents = state.exhibits.filter((exhibit): exhibit is CaseDocument => exhibit.type === 'document')
const folders = state.exhibits.filter((exhibit): exhibit is FolderExhibit => exhibit.type === 'folder')
const claims = state.exhibits.filter((exhibit): exhibit is ClaimExhibit => exhibit.type === 'claim')
const notes = state.exhibits.filter((exhibit): exhibit is NoteExhibit => exhibit.type === 'note')
const documentKey = uniqueKeyer()
const documentKeyById = new Map(documents.map((document, index) => [document.id, documentKey(document.title, `doc-${index + 1}`)]))
const folderKey = uniqueKeyer()
const timeline = state.views.find(view => view.type === 'timeline' && view.rangeMode === 'fixed')
return prune({
slug: board.slug,
name: board.name,
title: state.title,
subtitle: state.subtitle || undefined,
timelineRange: timeline?.range,
brief: state.brief.body || state.brief.concepts.length
? { body: state.brief.body, concepts: state.brief.concepts.map(concept => ({ label: concept.label, context: concept.context, expectedPartyKind: concept.expectedPartyKind || 'person' })) }
: undefined,
documents: documents.map(document => prune({
key: documentKeyById.get(document.id)!, title: document.title, fileType: document.fileType, captureKind: document.captureKind,
publishedAt: document.publishedAt || '', body: document.body, metadata: Object.keys(document.metadata || {}).length ? document.metadata : undefined,
requiredFlags: document.requiredFlags, asset: document.assetId ? `assets/${assetFilenames.get(document.assetId)}` : undefined,
})),
folders: folders.map(folder => ({
key: folderKey(folder.title, 'folder'), title: folder.title, content: folder.content, x: folder.x, y: folder.y, width: folder.width,
members: state.relations.filter(relation => relation.type === 'contains' && relation.fromExhibitId === folder.id)
.sort((a, b) => a.sortOrder - b.sortOrder).map(relation => documentKeyById.get(relation.toExhibitId)!).filter(Boolean),
})),
claims: claims.map(claim => ({ key: slugify(claim.statement, `claim`), statement: claim.statement, x: claim.x, y: claim.y, width: claim.width, height: claim.height })),
notes: notes.map(note => prune({ title: note.title || undefined, content: note.content, presentation: note.presentation, x: note.x, y: note.y, width: note.width, height: note.height })),
report: state.report ? { title: state.report.title, requiredForCompletion: state.report.requiredForCompletion } : undefined,
goals: state.goals.map(goal => prune({ key: goal.key, title: goal.title, instructions: goal.instructions, completionMessage: goal.completionMessage, enabled: goal.enabled, requiredFlags: goal.requiredFlags })),
evidenceMatchRules: board.matchRules.map(rule => prune({
name: rule.name, sourceLabel: rule.sourceLabel, sourceUri: rule.sourceUri, flagKey: rule.flagKey, minimumAnchorMatches: rule.minimumAnchorMatches,
enabled: rule.enabled, anchors: rule.anchors.map(anchor => ({ phrase: anchor.phrase, minimumSimilarity: anchor.minimumSimilarity })),
})),
evidenceSemanticRules: board.semanticRules.map(rule => prune({
goalKey: rule.goalKey, name: rule.name, targetSubject: rule.targetSubject, relatedSubject: rule.relatedSubject, assertion: rule.assertion,
successFlagKey: rule.successFlagKey, relatedFlagKey: rule.relatedFlagKey, minimumConfidence: rule.minimumConfidence, evaluatorVersion: rule.evaluatorVersion, enabled: rule.enabled,
})),
})
}
// A node's utterances → the importer's branching form (explicit key + parent + terminal),
// so the dialogue tree and its flag gates round-trip losslessly.
function serializeUtterances(utterances: UtteranceDto[], node: StoryNodeDto, npcKeyById: Map<string, string>) {
const ordered = [...utterances].sort((a, b) => a.sortOrder - b.sortOrder)
const keyById = new Map(ordered.map((utterance, index) => [utterance.id, `u${index}`]))
const terminalKeyById = new Map(node.terminals.map(terminal => [terminal.id, terminal.terminalKey]))
return ordered.map(utterance => prune({
key: keyById.get(utterance.id)!,
parent: utterance.parentUtteranceId ? keyById.get(utterance.parentUtteranceId) : undefined,
terminal: utterance.terminalId ? terminalKeyById.get(utterance.terminalId) : undefined,
npc: utterance.npcId ? npcKeyById.get(utterance.npcId) : undefined,
pose: utterance.poseKey || undefined,
text: utterance.text,
utterer: utterance.utterer,
awardsFlag: utterance.awardsFlag || undefined,
requiresFlag: utterance.requiresFlag || undefined,
}))
}
export async function exportMystery(slug: string, options: { baseUrl: string; adminJwt: string; outDir: string }) {
const { baseUrl, adminJwt, outDir } = options
const authorization = `Bearer ${adminJwt}`
const mysteries = await getJson<MysterySummary[]>(baseUrl, '/api/admin/mysteries', authorization)
const mystery = mysteries.find(candidate => candidate.slug === slug)
if (!mystery) throw new Error(`Mystery '${slug}' not found on ${baseUrl}`)
const [graph, npcs] = await Promise.all([
getJson<StoryGraphDto>(baseUrl, `/api/admin/mysteries/${mystery.id}/graph`, authorization),
getJson<NpcDto[]>(baseUrl, '/api/admin/npcs', authorization),
])
const npcKeyById = new Map(npcs.map(npc => [npc.id, npc.key]))
// Stable, readable key per node (graph nodes arrive in creation order).
const nodeKey = uniqueKeyer()
const nodeKeyById = new Map(graph.nodes.map(node => [node.id, nodeKey(node.label || node.nodeType, node.nodeType)]))
// Fetch each distinct level template version once, in first-appearance order.
const levelByVersion = new Map<string, TemplateBoardExport>()
for (const node of graph.nodes) {
if (node.nodeType !== 'level' || !node.levelTemplateVersionId || levelByVersion.has(node.levelTemplateVersionId)) continue
levelByVersion.set(node.levelTemplateVersionId, await getJson<TemplateBoardExport>(baseUrl, `/api/admin/level-templates/${node.levelTemplateVersionId}/export`, authorization))
}
// Collect document assets across all levels; filenames come from the stored upload name.
const assetFilenames = new Map<string, string>()
const assetFilenameKeyer = uniqueKeyer()
for (const board of levelByVersion.values()) {
for (const exhibit of board.state.exhibits) {
if (exhibit.type !== 'document' || !exhibit.assetId || assetFilenames.has(exhibit.assetId)) continue
const suggested = exhibit.fileName || `${exhibit.assetId}.bin`
const extension = suggested.includes('.') ? suggested.slice(suggested.lastIndexOf('.')) : ''
const base = suggested.slice(0, suggested.length - extension.length) || exhibit.assetId
assetFilenames.set(exhibit.assetId, `${assetFilenameKeyer(base, exhibit.assetId)}${extension}`)
}
}
const levels = [...levelByVersion.values()].map(board => serializeLevel(board, assetFilenames))
// Serialize the graph, gathering the NPCs the flow actually references as we go.
const referencedNpcIds = new Set<string>()
const nodes: Record<string, unknown>[] = []
for (const node of graph.nodes) {
const terminals = node.terminals.map(terminal => {
if (terminal.npcId) referencedNpcIds.add(terminal.npcId)
return prune({
key: terminal.terminalKey, label: terminal.label,
to: terminal.toNodeId ? nodeKeyById.get(terminal.toNodeId) : undefined,
npc: terminal.npcId ? npcKeyById.get(terminal.npcId) : undefined,
})
})
let utterances: Record<string, unknown>[] | undefined
if (node.hasUtterances) {
const raw = await getJson<UtteranceDto[]>(baseUrl, `/api/admin/story-nodes/${node.id}/utterances`, authorization)
for (const utterance of raw) if (utterance.npcId) referencedNpcIds.add(utterance.npcId)
utterances = serializeUtterances(raw, node, npcKeyById)
}
nodes.push(prune({
key: nodeKeyById.get(node.id)!, type: node.nodeType, label: node.label,
x: node.xpos, y: node.ypos, componentKey: node.componentKey || undefined, awardsFlag: node.awardsFlag || undefined,
templateSlug: node.levelTemplateVersionId ? levelByVersion.get(node.levelTemplateVersionId)?.slug : undefined,
terminals, utterances,
}))
}
const cast = npcs.filter(npc => referencedNpcIds.has(npc.id)).map(npc => prune({
key: npc.key, name: npc.name, role: npc.role || undefined, defaultPose: npc.defaultPose || undefined,
phoneNumber: npc.phoneNumber || undefined, email: npc.email || undefined,
poses: npc.poses.map(pose => ({ poseKey: pose.poseKey, assetId: pose.assetId })),
}))
const entry = graph.entryNodeId ? nodeKeyById.get(graph.entryNodeId) : undefined
if (!entry) throw new Error(`Mystery '${slug}' has no entry node; author its graph entrypoint before exporting`)
const outAbs = path.resolve(outDir)
await mkdir(path.join(outAbs, 'assets'), { recursive: true })
for (const [assetId, filename] of assetFilenames) {
const response = await fetch(`${baseUrl}/api/assets/${assetId}`, { headers: { authorization } })
if (!response.ok) throw new Error(`Download asset ${assetId} failed (${response.status})`)
await writeFile(path.join(outAbs, 'assets', filename), Buffer.from(await response.arrayBuffer()))
}
const file = { slug: mystery.slug, title: mystery.title, levels, narrative: { cast, graph: { entry, nodes } } }
await writeFile(path.join(outAbs, 'mystery.json'), JSON.stringify(file, null, 2) + '\n')
return { slug: mystery.slug, outDir: outAbs, levels: levels.length, nodes: nodes.length, cast: cast.length, assets: assetFilenames.size }
}
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ''
if (invokedPath === fileURLToPath(import.meta.url)) {
const args = process.argv.slice(2)
let isProd = false
let outDir: string | undefined
const positionals: string[] = []
for (let i = 0; i < args.length; i++) {
if (args[i] === '--prod') isProd = true
else if (args[i] === '--out') outDir = args[++i]
else positionals.push(args[i])
}
const slug = positionals[0]
if (!slug) throw new Error('Usage: npm run mystery:pull -- <mystery-slug> [--prod] [--out <dir>]')
const baseUrl = isProd ? PROD_URL : (process.env.OSINT_BOARD_URL || DEV_URL)
const adminJwt = await resolveAdminJwt(baseUrl, isProd)
const result = await exportMystery(slug, { baseUrl, adminJwt, outDir: outDir || path.join('mysteries', slug) })
console.log(JSON.stringify({ ...result, source: baseUrl }, null, 2))
}
+8
View File
@@ -397,6 +397,14 @@ app.delete('/api/admin/npcs/:id/poses/:poseKey', requireAdmin, async (req, res,
app.get('/api/admin/level-templates', requireAdmin, async (_req, res, next) => {
try { res.json(await storyGraph.listLevelTemplates()) } catch (error) { next(error) }
})
// Read a frozen template version's board (exhibits, goals, rules) for the mystery:pull
// exporter — read-only, so no editing gate.
app.get('/api/admin/level-templates/:versionId/export', requireAdmin, async (req, res, next) => {
try {
const board = await levels.getTemplateBoard(String(req.params.versionId))
board ? res.json(board) : res.status(404).json({ error: 'Template version not found' })
} catch (error) { next(error) }
})
app.get('/api/admin/mysteries/:id/graph', requireAdmin, async (req, res, next) => {
try {
const graph = await storyGraph.getGraph(String(req.params.id))
+29
View File
@@ -15,6 +15,9 @@ export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype:
export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer | null; storage_provider: 'postgres' | 's3'; object_key: string | null }
export type AssetResponse = { originalName: string; mimeType: string; byteSize: number; stream: NodeJS.ReadableStream }
export type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string }
// A frozen template version's board, read back for round-trip export. `state.goals`
// carries the goals (in author mode); match/semantic rules are read alongside.
export type TemplateBoardExport = { slug: string; name: string; version: number; state: CaseState; matchRules: EvidenceMatchRuleDefinition[]; semanticRules: EvidenceSemanticRuleDefinition[] }
export type EvidenceMatchRuleInput = {
name: string
sourceLabel?: string
@@ -51,6 +54,7 @@ export interface LevelRepository {
listTemplates(): Promise<TemplateSummary[]>
instantiateTemplate(templateSlug: string, input: { id: string; title?: string; version?: number }): Promise<CaseState | null>
saveLevelAsTemplate(levelId: string, input: { slug: string; name: string }): Promise<TemplateSummary | null>
getTemplateBoard(versionId: string): Promise<TemplateBoardExport | null>
getLevel(levelId: string, authorMode?: boolean): Promise<CaseState | null>
saveLevel(state: CaseState, authorMode: boolean): Promise<void>
resetLevel(levelId: string): Promise<CaseState | null>
@@ -347,6 +351,14 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> {
const level = await findLevel(pool, slug)
if (!level) return null
return assembleBoard(level, authorMode)
}
// Assemble a board's CaseState from a level row (id + board_id). Split out from
// assembleLevel so a frozen template version's board can be read the same way (its
// board_id backs the same tables); player-state reads (flags/seen) return empty for
// a template board that no player has instantiated.
async function assembleBoard(level: LevelRow, authorMode = false): Promise<CaseState> {
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult, requirementsResult, flagsResult, seenResult] = await Promise.all([
pool.query<ExhibitRow>(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden,
@@ -739,6 +751,23 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
return templateSummary(input.slug)
},
async getTemplateBoard(versionId) {
const version = (await pool.query<{ board_id: string; title: string; subtitle: string; version: number; slug: string; name: string }>(
`SELECT v.board_id,v.title,v.subtitle,v.version,t.slug,t.name FROM osint.level_template_versions v
JOIN osint.level_templates t ON t.id=v.template_id WHERE v.id=$1`, [versionId])).rows[0]
if (!version) return null
// A synthetic level row over the version's board: id has no player rows (flags/seen
// resolve empty); viewport/revision/status are placeholders the exporter discards.
const level: LevelRow = {
id: versionId, slug: version.slug, board_id: version.board_id, title: version.title, subtitle: version.subtitle,
status: 'template', viewport_x: 0, viewport_y: 28, viewport_zoom: 0.7, updated_at: new Date(), revision: '0',
source_template_version_id: null,
}
const [state, matchRules, semanticRules] = await Promise.all([
assembleBoard(level, true), evidenceMatchRules(pool, version.board_id, true), evidenceSemanticRules(pool, version.board_id, true),
])
return { slug: version.slug, name: version.name, version: version.version, state, matchRules, semanticRules }
},
getLevel(levelId, authorMode = false) { return assembleLevel(levelId, authorMode) },
async saveLevel(state, authorMode) {
let persistedState = state
+5 -4
View File
@@ -25,7 +25,7 @@ export type LevelTemplateOption = { versionId: string; slug: string; name: strin
export type Utterer = 'npc' | 'player'
export type UtteranceDto = {
id: string; nodeId: string; utterer: Utterer; npcId: string | null; poseKey: string | null; text: string
parentUtteranceId: string | null; terminalId: string | null
parentUtteranceId: string | null; terminalId: string | null; awardsFlag: string | null; requiresFlag: string | null
xpos: number; ypos: number; sortOrder: number
}
@@ -190,7 +190,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
async listUtterances(nodeId) {
const result = await pool.query<UtteranceRow>(
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,terminal_id,xpos,ypos,sort_order
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,terminal_id,awards_flag,requires_flag,xpos,ypos,sort_order
FROM osint.utterances WHERE node_id=$1 ORDER BY sort_order,id`, [nodeId])
return result.rows.map(mapUtterance)
},
@@ -203,7 +203,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
await pool.query('INSERT INTO osint.utterances (id,node_id,utterer,text,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7)',
[id, nodeId, input.utterer, input.text || '', input.xpos, input.ypos, order.rows[0].next])
const created = await pool.query<UtteranceRow>(
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,terminal_id,xpos,ypos,sort_order FROM osint.utterances WHERE id=$1`, [id])
`SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,terminal_id,awards_flag,requires_flag,xpos,ypos,sort_order FROM osint.utterances WHERE id=$1`, [id])
return mapUtterance(created.rows[0])
},
@@ -333,13 +333,14 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
type UtteranceRow = {
id: string; node_id: string; utterer: Utterer; npc_id: string | null; pose_key: string | null; text: string
parent_utterance_id: string | null; terminal_id: string | null
parent_utterance_id: string | null; terminal_id: string | null; awards_flag: string | null; requires_flag: string | null
xpos: number; ypos: number; sort_order: number
}
function mapUtterance(row: UtteranceRow): UtteranceDto {
return {
id: row.id, nodeId: row.node_id, utterer: row.utterer, npcId: row.npc_id, poseKey: row.pose_key, text: row.text,
parentUtteranceId: row.parent_utterance_id, terminalId: row.terminal_id,
awardsFlag: row.awards_flag, requiresFlag: row.requires_flag,
xpos: row.xpos, ypos: row.ypos, sortOrder: row.sort_order,
}
}
+11 -8
View File
@@ -878,17 +878,20 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
}
}
if (!drag.current) return
const dx = event.clientX - drag.current.startX, dy = event.clientY - drag.current.startY
if (Math.abs(dx) + Math.abs(dy) > 4) drag.current.moved = true
if (drag.current.kind === 'widget') {
// Snapshot the drag ref: the `update` closures below run inside a React state
// updater (deferred/possibly re-invoked), by which point `drag.current` may be null.
const active = drag.current
const dx = event.clientX - active.startX, dy = event.clientY - active.startY
if (Math.abs(dx) + Math.abs(dy) > 4) active.moved = true
if (active.kind === 'widget') {
const bounds = trashRef.current?.getBoundingClientRect()
const overTrash = Boolean(bounds && event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top && event.clientY <= bounds.bottom)
trashTarget.current = overTrash
setTrashActive(overTrash)
}
if (drag.current.kind === 'thread-tag' && boardRef.current) {
if (drag.current.moved) setExpandedThreadTagId(null)
const connection = state.connections.find(item => item.id === drag.current!.id)
if (active.kind === 'thread-tag' && boardRef.current) {
if (active.moved) setExpandedThreadTagId(null)
const connection = state.connections.find(item => item.id === active.id)
const from = connection ? pointForId(connection.fromExhibitId) : undefined
const to = connection ? pointForId(connection.toExhibitId) : undefined
if (connection && from && to) {
@@ -897,8 +900,8 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
const placement = projectThreadTag(from, to, connection.tightness ?? 65, pointer)
update(s => ({ ...s, connections: s.connections.map(item => item.id === connection.id ? { ...item, tagPosition: placement.positionPercent, tagOffset: placement.lateralOffset } : item) }))
}
} else if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === drag.current!.id ? { ...exhibit, ...next } : exhibit) } })
else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) }))
} else if (active.kind === 'widget') update(s => { const next = moveBoardPoint({ x: active.originX, y: active.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === active.id ? { ...exhibit, ...next } : exhibit) } })
else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: active.originX, y: active.originY }, { x: dx, y: dy }) }))
}
const finishDrag = (event: React.PointerEvent) => {
if (folderLongPress.current?.pointerId === event.pointerId) {