From e37dc651f3dfc09e4a2509a48dd881b6ec595502 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Sun, 23 Aug 2026 22:50:50 +0200 Subject: [PATCH] Add authorable per-level briefing checklist The case briefing showed a wall of body text. Add a checklist of short, player-tickable steps authored per level: a new `checklist` on the level brief (table osint.level_brief_checklist_items), read/written with the board, cloned on template freeze/instantiate, and carried through the mystery importer and the mystery:pull exporter. Player tick state is guidance-only, persisted locally per level rather than on the shared board. Convert the Barricelli inventor-proof brief from prose into a short intro plus a six-step checklist. Co-Authored-By: Claude Opus 4.8 --- migrations/043_brief_checklist.sql | 10 ++++++++++ mysteries/barricelli-files/mystery.json | 11 +++++++++-- scripts/exportMystery.ts | 4 ++-- scripts/importMysteryTemplate.ts | 4 ++-- server/boardClone.ts | 3 +++ server/levelRepository.ts | 8 ++++++-- src/App.tsx | 16 +++++++++++++--- src/styles.css | 7 +++++++ src/types.ts | 2 +- 9 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 migrations/043_brief_checklist.sql diff --git a/migrations/043_brief_checklist.sql b/migrations/043_brief_checklist.sql new file mode 100644 index 0000000..0aaccf2 --- /dev/null +++ b/migrations/043_brief_checklist.sql @@ -0,0 +1,10 @@ +-- Case-briefing checklist: an ordered list of short, player-tickable steps authored per +-- level, sitting alongside the brief body and concepts. Tick state is client-side only, +-- so the server just stores the authored item text and its order. +CREATE TABLE osint.level_brief_checklist_items ( + id UUID PRIMARY KEY, + board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0), + text TEXT NOT NULL +); +CREATE INDEX level_brief_checklist_items_board_order ON osint.level_brief_checklist_items (board_id, sort_order); diff --git a/mysteries/barricelli-files/mystery.json b/mysteries/barricelli-files/mystery.json index 2cf0927..06c0d0a 100644 --- a/mysteries/barricelli-files/mystery.json +++ b/mysteries/barricelli-files/mystery.json @@ -8,8 +8,15 @@ "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": [] + "body": "DEMONSTRATE OSINT SKILL\n\nProve that Nils Aall Barricelli was an inventor, using open sources.", + "checklist": [ + "Find a reliable source online", + "Screenshot it and paste it onto the board", + "Fill in the source, date and URL so it can be verified", + "Connect the source to the claim with red thread", + "Explain what the evidence proves", + "Submit the Case Report" + ] }, "claims": [ { diff --git a/scripts/exportMystery.ts b/scripts/exportMystery.ts index c71e971..a8f8b8a 100644 --- a/scripts/exportMystery.ts +++ b/scripts/exportMystery.ts @@ -75,8 +75,8 @@ function serializeLevel(board: TemplateBoardExport, assetFilenames: Map ({ label: concept.label, context: concept.context, expectedPartyKind: concept.expectedPartyKind || 'person' })) } + brief: state.brief.body || state.brief.concepts.length || state.brief.checklist?.length + ? prune({ body: state.brief.body, concepts: state.brief.concepts.map(concept => ({ label: concept.label, context: concept.context, expectedPartyKind: concept.expectedPartyKind || 'person' })), checklist: state.brief.checklist }) : undefined, documents: documents.map(document => prune({ key: documentKeyById.get(document.id)!, title: document.title, fileType: document.fileType, captureKind: document.captureKind, diff --git a/scripts/importMysteryTemplate.ts b/scripts/importMysteryTemplate.ts index a5a601a..9ea6f4e 100644 --- a/scripts/importMysteryTemplate.ts +++ b/scripts/importMysteryTemplate.ts @@ -45,7 +45,7 @@ type MysteryLevel = { title: string subtitle?: 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 }[]; checklist?: string[] } documents?: MysteryDocument[] 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 }[] @@ -116,7 +116,7 @@ async function importLevel(baseUrl: string, folderDir: string, level: MysteryLev } const folderIds = new Map(levelFolders.map(folder => [folder.key, randomUUID()])) - if (level.brief) state.brief = { body: level.brief.body, concepts: level.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 })), checklist: level.brief.checklist } state.views = state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: level.timelineRange ? 'fixed' : 'auto', range: level.timelineRange } : view) const folders = levelFolders.map(folder => ({ id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content, diff --git a/server/boardClone.ts b/server/boardClone.ts index a4f4afd..fc351c2 100644 --- a/server/boardClone.ts +++ b/server/boardClone.ts @@ -15,6 +15,7 @@ export async function clearBoard(client: PoolClient, boardId: string) { await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [boardId]) + await client.query('DELETE FROM osint.level_brief_checklist_items WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.level_goals WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.evidence_match_rules WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [boardId]) @@ -253,6 +254,8 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ const brief = await client.query<{ body: string }>('SELECT body FROM osint.level_briefs WHERE board_id=$1', [sourceBoardId]) if (brief.rows[0]) await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [targetBoardId, brief.rows[0].body]) + const checklist = await client.query<{ sort_order: number; text: string }>('SELECT sort_order,text FROM osint.level_brief_checklist_items WHERE board_id=$1 ORDER BY sort_order,id', [sourceBoardId]) + for (const row of checklist.rows) await client.query('INSERT INTO osint.level_brief_checklist_items (id,board_id,sort_order,text) VALUES ($1,$2,$3,$4)', [randomUUID(), targetBoardId, row.sort_order, row.text]) const concepts = await client.query<{ id: string; label: string; context_text: string; sort_order: number; expected_party_kind: string | null; resolved_party_exhibit_id: string | null }>('SELECT id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id FROM osint.brief_concepts WHERE board_id=$1 ORDER BY sort_order,id', [sourceBoardId]) diff --git a/server/levelRepository.ts b/server/levelRepository.ts index 7bc7899..6d06d2c 100644 --- a/server/levelRepository.ts +++ b/server/levelRepository.ts @@ -360,7 +360,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec // a template board that no player has instantiated. async function assembleBoard(level: LevelRow, authorMode = false): Promise { const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult, - aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult, requirementsResult, flagsResult, seenResult] = await Promise.all([ + aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult, requirementsResult, flagsResult, seenResult, checklistResult] = await Promise.all([ pool.query(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden, COALESCE(f.title, d.title, n.title, ev.title, p.display_name, claim.statement, '') AS title, COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content, @@ -419,6 +419,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec 'SELECT document_exhibit_id,flag_key FROM osint.document_flag_requirements WHERE board_id=$1 ORDER BY document_exhibit_id,flag_key', [level.board_id]), pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.level_flags WHERE level_id=$1 ORDER BY flag_key', [level.id]), pool.query<{ document_exhibit_id: string }>('SELECT document_exhibit_id FROM osint.level_seen_documents WHERE level_id=$1', [level.id]), + pool.query<{ text: string }>('SELECT text FROM osint.level_brief_checklist_items WHERE board_id=$1 ORDER BY sort_order,id', [level.board_id]), ]) const blocks = new Map() @@ -480,7 +481,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })), viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(), views, revision: Number(level.revision), - brief: { body: briefResult.rows[0]?.body || '', concepts }, goals, report, levelStatus: level.status, editingAllowed: editingEnabled && authorMode, + brief: { body: briefResult.rows[0]?.body || '', concepts, checklist: checklistResult.rows.map(row => row.text) }, goals, report, levelStatus: level.status, editingAllowed: editingEnabled && authorMode, sourceTemplateVersionId: level.source_template_version_id || undefined } return filterLevelVisibility(fullState, flagsResult.rows.map(row => row.flag_key), seenResult.rows.map(row => row.document_exhibit_id), authorMode) } @@ -520,6 +521,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [level.board_id]) + await client.query('DELETE FROM osint.level_brief_checklist_items WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.exhibit_sources WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)', [level.board_id]) await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.document_flag_requirements WHERE board_id=$1',[level.board_id]) @@ -657,6 +659,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec } const brief = state.brief || { body: '', concepts: [] } await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [level.board_id, brief.body || '']) + for (const [sortOrder, item] of (brief.checklist || []).map(text => text.trim()).filter(Boolean).entries()) + await client.query('INSERT INTO osint.level_brief_checklist_items (id,board_id,sort_order,text) VALUES ($1,$2,$3,$4)', [randomUUID(), level.board_id, sortOrder, item]) for (const [sortOrder, concept] of brief.concepts.entries()) { requireUuid(concept.id, 'Brief concept id') if (concept.resolvedPartyExhibitId && !evidenceIds.has(concept.resolvedPartyExhibitId)) throw new Error('Concept resolution references an unknown party') diff --git a/src/App.tsx b/src/App.tsx index d089cc6..fe537f4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -615,6 +615,7 @@ export function App() {

{caseState.title}

document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} arrivingExhibitIds={arrivingExhibitIds} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} /> {briefOpen && item.type === 'party')} @@ -1215,14 +1216,21 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa } -function BriefPanel({ brief, goals, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; goals: LevelGoal[]; parties: PartyExhibit[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) { +function BriefPanel({ levelId, brief, goals, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { levelId: string; brief: LevelBrief; goals: LevelGoal[]; parties: PartyExhibit[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) { const [minimized, setMinimized] = useState(false) const partyById = new Map(parties.map(party => [party.id, party])) + // Player-side checklist ticks: guidance only, so persist locally per level rather than + // on the shared board. + const checklist = brief.checklist || [] + const checkedKey = `gupi-osint-board:checklist:${levelId}` + const [checked, setChecked] = useState>(() => { try { return new Set(JSON.parse(localStorage.getItem(checkedKey) || '[]')) } catch { return new Set() } }) + const toggleChecked = (item: string) => setChecked(previous => { const next = new Set(previous); next.has(item) ? next.delete(item) : next.add(item); localStorage.setItem(checkedKey, JSON.stringify([...next])); return next }) const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length const pending = goals.filter(goal => goal.status === 'pending').length const heading = goals.length ? brief.concepts.length ? 'CASE OBJECTIVES' : 'ASSIGNMENT' : 'CONCEPT CLASSIFICATION' return